persona matrix mouth: each Persona's rooms are the shared MATRIX_ROOMS plus her own MATRIX_ROOMS_<SLUG>
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 04:12:31 +01:00
commit 98689b787d
2 changed files with 44 additions and 3 deletions

View file

@ -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(); }
});

View file

@ -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_<SLUG>, 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-<slug>), its cursor directory under
// STATE_DIRECTORY and its brake at CONTROL_ROOT/<slug>/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_<SLUG> 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 }),
})];