From 98689b787d8477b97a765762f8721396ffc32bc8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 04:12:31 +0100 Subject: [PATCH] persona matrix mouth: each Persona's rooms are the shared MATRIX_ROOMS plus her own MATRIX_ROOMS_ SLUG is the Persona slug upper-cased with "." and "-" turned into "_". Lists are deduplicated; an absent variable gives shared rooms only; a malformed project alias, or two Personas mapping to one variable, is refused at start. The send path's UnknownRoom check already reads the mouth's own list, so another Persona's project room is refused. Co-Authored-By: Claude Opus 5.5 --- tests/persona-matrix-mouth.test.mjs | 23 ++++++++++++++++++++++- tools/persona-matrix-mouth.mjs | 24 ++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs index 463429e..610dd3b 100644 --- a/tests/persona-matrix-mouth.test.mjs +++ b/tests/persona-matrix-mouth.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { resolve, join } from "node:path"; -import { createMouth, createBrakeReader, guarded, redact } from "../tools/persona-matrix-mouth.mjs"; +import { createMouth, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs"; const TOKEN = "syt_SECRET_TOKEN_9f3"; const HS_NAME = "gamecult.test"; @@ -577,3 +577,24 @@ test("a same-server invitation to a room that is not a DM is neither joined nor assert.deepEqual([joinsOf(hs), leavesOf(hs)], [[], []]); } finally { await hs.close(); } }); + +test("project rooms: slug mapping, per-Persona lists, absent variable, malformed alias", () => { + assert.equal(projectRoomsVariable("eureka"), "MATRIX_ROOMS_EUREKA"); + assert.equal(projectRoomsVariable("nova-2.x"), "MATRIX_ROOMS_NOVA_2_X"); + const env = { MATRIX_ROOMS: "#swarm:t, #ops:t", MATRIX_ROOMS_EUREKA: "#eureka-proj:t,#ops:t", MATRIX_ROOMS_NOVA_2: "#nova-proj:t" }; + assert.deepEqual(roomsFor(env, "eureka"), ["#swarm:t", "#ops:t", "#eureka-proj:t"], "shared plus own, deduplicated"); + assert.deepEqual(roomsFor(env, "nova-2"), ["#swarm:t", "#ops:t", "#nova-proj:t"], "dash maps to underscore"); + assert.ok(!roomsFor(env, "nova-2").includes("#eureka-proj:t") && !roomsFor(env, "eureka").includes("#nova-proj:t")); + assert.deepEqual(roomsFor(env, "other"), ["#swarm:t", "#ops:t"], "absent variable: shared rooms only"); + assert.throws(() => roomsFor({ ...env, MATRIX_ROOMS_EUREKA: "#ok:t,nope" }, "eureka"), /MATRIX_ROOMS_EUREKA.*malformed/); +}); + +test("send to another Persona's project room is refused as UnknownRoom", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const env = { MATRIX_ROOMS: `#swarm:${HS_NAME}`, MATRIX_ROOMS_EUREKA: `#eureka-proj:${HS_NAME}`, MATRIX_ROOMS_NOVA: `#nova-proj:${HS_NAME}` }; + const mouth = mouthFor(hs, { rooms: roomsFor(env, "eureka") }); + assert.equal((await mouth.send({ requestId: "p", persona: "eureka", roomAlias: `#nova-proj:${HS_NAME}`, text: "x" })).refusal, "UnknownRoom"); + assert.equal(hs.puts().length, 0); + } finally { await hs.close(); } +}); diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index c991288..6ea8246 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -396,12 +396,32 @@ function credential(env, name) { return readFileSync(join(required(env, "CREDENTIALS_DIRECTORY"), name), "utf8").trim(); } +const ROOM_ALIAS = /^#[^\s:,]+:[^\s,]+$/; + +// The env variable holding one Persona's project rooms: MATRIX_ROOMS_, the slug +// upper-cased with "." and "-" turned into "_". +export function projectRoomsVariable(persona) { + return `MATRIX_ROOMS_${persona.toUpperCase().replace(/[.-]/g, "_")}`; +} + +// One Persona's rooms: the shared MATRIX_ROOMS plus her own project rooms, deduplicated. +// Another Persona's project variable is never read. A malformed project alias is refused. +export function roomsFor(env, persona) { + const list = text => text.split(",").map(s => s.trim()).filter(Boolean); + const name = projectRoomsVariable(persona); + const project = list(env[name] ?? ""); + const bad = project.find(alias => !ROOM_ALIAS.test(alias)); + if (bad) throw new Error(`${name} has a malformed room alias: ${JSON.stringify(bad)} (expected #name:server)`); + return [...new Set([...list(required(env, "MATRIX_ROOMS")), ...project])]; +} + // PERSONAS is the comma-separated list of Persona slugs this process speaks for. // Each slug names its credential (matrix-token-), its cursor directory under // STATE_DIRECTORY and its brake at CONTROL_ROOT//control.cc. export async function main(env = process.env) { const personas = required(env, "PERSONAS").split(",").map(s => s.trim()).filter(Boolean); if (personas.length === 0 || new Set(personas).size !== personas.length || !personas.every(p => SLUG.test(p))) throw new Error("PERSONAS must be distinct slugs"); + if (new Set(personas.map(projectRoomsVariable)).size !== personas.length) throw new Error("PERSONAS must map to distinct MATRIX_ROOMS_ variables"); const tokens = new Map(personas.map(p => [p, credential(env, `matrix-token-${p}`)])); const chatKey = credential(env, "chat-key"); const secrets = [...tokens.values(), chatKey]; @@ -410,13 +430,13 @@ export async function main(env = process.env) { const log = line => console.error(redact(line, secrets)); const homeserver = required(env, "MATRIX_HOMESERVER").replace(/\/$/, ""); const serverName = required(env, "MATRIX_SERVER_NAME"); - const rooms = required(env, "MATRIX_ROOMS").split(",").map(s => s.trim()).filter(Boolean); + const roomsByPersona = new Map(personas.map(persona => [persona, roomsFor(env, persona)])); const stateRoot = required(env, "STATE_DIRECTORY"); const controlRoot = env.CONTROL_ROOT ?? "/etc/gamecult/minds"; const mouths = new Map(personas.map(persona => { mkdirSync(join(stateRoot, persona), { recursive: true }); return [persona, createMouth({ - persona, homeserver, serverName, rooms, token: tokens.get(persona), log: line => log(`${persona}: ${line}`), + persona, homeserver, serverName, rooms: roomsByPersona.get(persona), token: tokens.get(persona), log: line => log(`${persona}: ${line}`), state: cultcacheState({ runtime, path: join(stateRoot, persona, "cursor.cc") }), brake: createBrakeReader({ controlPath: join(controlRoot, persona, "control.cc"), readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }), })];