mouth-all-personas: one Matrix mouth serves every Persona; deliveries carry mentions and is_direct, refusals are typed; same-server DM invites accepted; the ring is deleted (Persona turns are the heartbeat's) (squash of eureka-body/mouth-all-personas 7b63031..5b628e2; Soul s1-s3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-30 02:49:59 +01:00
commit cc76f0f2d0
7 changed files with 634 additions and 160 deletions

View file

@ -196,7 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to
Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona.
Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process per Persona that holds the Persona's Matrix access token (a systemd credential) and nobody else does. It reads the configured rooms by `/sync`, keeps each message from another sender as a delivery, and speaks on a `gamecult.persona.chat_send_request.v1` over a loopback CultNet RUDP endpoint, only while the instance brake is released. Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed.
Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-<persona>`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds/<persona>/control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed.
Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority.

View file

@ -0,0 +1 @@
’¥ghost®UnknownPersona

View file

@ -4,11 +4,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import dgram from "node:dgram";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import * as docs from "../tools/persona-chat-documents.mjs";
import { cultcacheState, connectionIdFor, createBrakeReader, loadRuntime, startChatEndpoint } from "../tools/persona-matrix-mouth.mjs";
import { cultcacheState, connectionIdFor, createBrakeReader, loadRuntime, pageOf, startChatEndpoint } from "../tools/persona-matrix-mouth.mjs";
const root = resolve(import.meta.dirname, "..");
const runtime = loadRuntime(resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib")));
@ -19,6 +21,7 @@ const delivery = {
seq: 7, roomAlias: "#operator:gamecult.org", roomId: "!abc:gamecult.org", eventId: "$e1", sender: "@meta:gamecult.org", senderDisplay: "Meta",
text: "hello eureka", atUnixMs: 1761700000000,
context: [{ sender: "@eureka:gamecult.org", text: "earlier", atUnixMs: 1761699999000 }],
mentions: ["@eureka:gamecult.org", "@nova:gamecult.org"], isDirect: true,
};
const samples = {
[docs.kinds.pendingRequest]: { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest },
@ -26,6 +29,7 @@ const samples = {
[docs.kinds.ack]: { tuple: docs.ackTuple({ persona: "eureka", throughSeq: 7 }), parse: docs.parseAck },
[docs.kinds.sendRequest]: { tuple: docs.sendRequestTuple({ requestId: "req-1", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "hi" }), parse: docs.parseSendRequest },
[docs.kinds.sendReceipt]: { tuple: docs.sendReceiptTuple({ requestId: "req-1", eventId: "$sent" }), parse: docs.parseSendReceipt },
[docs.kinds.refusal]: { tuple: docs.refusalTuple({ persona: "ghost", refusal: "UnknownPersona" }), parse: docs.parseRefusal },
};
for (const [kind, { tuple, parse }] of Object.entries(samples)) {
@ -44,9 +48,17 @@ test("the chat documents refuse malformed tuples", () => {
assert.throws(() => docs.parsePendingRequest(["", 0]));
assert.throws(() => docs.parseSendRequest(["r", "eureka", "#a:b", "x".repeat(4001)]));
assert.throws(() => docs.parseSendRequest(["r", "eureka", "#a:b", 5]));
const wire = docs.deliveryTuple(delivery);
assert.equal(wire.length, 11);
assert.deepEqual(docs.parseDelivery(wire), delivery);
assert.throws(() => docs.parseDelivery(wire.slice(0, 9)), /11-field/, "the nine-field delivery is gone: one shape");
assert.throws(() => docs.parseDelivery([...wire.slice(0, 10), "yes"]), /boolean/);
assert.throws(() => docs.parseDelivery([...wire.slice(0, 9), [""], true]));
assert.throws(() => docs.refusalTuple({ persona: "p", refusal: "Invented" }));
assert.throws(() => docs.parseSendReceipt(["r", "$e", "Brake"]), /exactly one/);
assert.throws(() => docs.parseSendReceipt(["r", null, null]), /exactly one/);
assert.throws(() => docs.sendReceiptTuple({ requestId: "r", refusal: "Invented" }));
assert.throws(() => docs.parseSendReceipt(["r", null, "Invented"]), /unknown refusal/);
assert.deepEqual(docs.parseSendReceipt(["r", null, "Brake"]), { requestId: "r", eventId: null, refusal: "Brake" });
});
@ -84,7 +96,7 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a
};
const key = "connection-key-for-test";
const rejected = [];
const server = await startChatEndpoint({ mouth, persona: "eureka", runtime, port: 0, connectionKey: key, runtimeId: "mouth-test", log: line => rejected.push(line) });
const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test", log: line => rejected.push(line) });
const client = dgram.createSocket("udp4");
await new Promise(ok => client.bind(0, "127.0.0.1", ok));
const connection = new runtime.cultnet.CultNetRudpSocketTransportConnection({
@ -111,7 +123,7 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a
assert.equal(document.schemaId, docs.kinds.pending);
const pending = docs.parsePending(msgpack.decode(document.payload));
assert.deepEqual(pending.deliveries.map(d => d.eventId), ["$e1"]);
assert.deepEqual(pending.deliveries[0].context, delivery.context);
assert.deepEqual(pending.deliveries[0], delivery, "sender, mentions and the DM flag cross the wire exactly");
put(docs.kinds.ack, "eureka:7", docs.ackTuple({ persona: "eureka", throughSeq: 7 }));
reply = nextReply();
@ -119,16 +131,22 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a
document = await reply;
assert.equal(document.schemaId, docs.kinds.sendReceipt);
assert.deepEqual(docs.parseSendReceipt(msgpack.decode(document.payload)), { requestId: "req-2", eventId: null, refusal: "Brake" });
reply = nextReply();
put(docs.kinds.ack, "other:9", docs.ackTuple({ persona: "other", throughSeq: 9 }));
document = await reply;
assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload))], [docs.kinds.refusal, { persona: "other", refusal: "UnknownPersona" }], "an ack for an unlisted Persona is refused, typed, and not applied");
assert.deepEqual(calls.acks, [7]);
reply = nextReply();
put(docs.kinds.pendingRequest, "other", docs.pendingRequestTuple({ persona: "other", afterSeq: 0 }));
put(docs.kinds.sendRequest, "req-3", docs.sendRequestTuple({ requestId: "req-3", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "speak" }));
document = await reply;
assert.equal(document.schemaId, docs.kinds.sendReceipt, "a request naming another Persona gets no answer; the next request is still served");
assert.equal(docs.parseSendReceipt(msgpack.decode(document.payload)).eventId, "$sent");
assert.deepEqual(calls.acks, [7], "an ack naming another Persona is not applied");
assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload)).refusal], [docs.kinds.refusal, "UnknownPersona"], "unknown_persona_refused");
reply = nextReply();
put(docs.kinds.sendRequest, "req-9", docs.sendRequestTuple({ requestId: "req-9", persona: "other", roomAlias: "#operator:gamecult.org", text: "speak" }));
document = await reply;
assert.deepEqual(docs.parseSendReceipt(msgpack.decode(document.payload)), { requestId: "req-9", eventId: null, refusal: "UnknownPersona" });
assert.equal(calls.sends.length, 1, "nothing reached a mouth for the unlisted Persona");
} finally {
server.close();
connection.close();
@ -137,13 +155,83 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a
});
test("the RUDP endpoint refuses to listen beyond loopback", async () => {
await assert.rejects(startChatEndpoint({ mouth: {}, persona: "eureka", runtime, port: 0, host: "0.0.0.0", connectionKey: "k", runtimeId: "m" }), /loopback/);
await assert.rejects(startChatEndpoint({ mouths: new Map(), runtime, port: 0, host: "0.0.0.0", connectionKey: "k", runtimeId: "m" }), /loopback/);
});
test("loopback means exactly an IPv4 127.x.y.z address", async () => {
const refuses = host => assert.rejects(startChatEndpoint({ mouth: {}, persona: "eureka", runtime, port: 0, host, connectionKey: "k", runtimeId: "m" }), /loopback/);
const refuses = host => assert.rejects(startChatEndpoint({ mouths: new Map(), runtime, port: 0, host, connectionKey: "k", runtimeId: "m" }), /loopback/);
for (const host of ["9127.0.0.1", "127.0.0.1x", "10.127.0.0.1", "127.0.0", ""]) await refuses(host);
const ok = await startChatEndpoint({ mouth: {}, persona: "eureka", runtime, port: 0, host: "127.10.20.30", connectionKey: "k", runtimeId: "m" }).catch(e => e);
const ok = await startChatEndpoint({ mouths: new Map(), runtime, port: 0, host: "127.10.20.30", connectionKey: "k", runtimeId: "m" }).catch(e => e);
assert.ok(!/loopback/.test(ok?.message ?? ""), "a multi-digit loopback address is accepted by the check");
ok.close?.();
});
test("one process serves N Personas from systemd credentials: each token only in its own requests, nothing joined while braked, and no token on stderr", async () => {
const dir = await mkdtemp(resolve(tmpdir(), "mouth-main-"));
const creds = resolve(dir, "creds"), control = resolve(dir, "control"), stateDir = resolve(dir, "state");
const tokens = { eureka: "syt_AAA_eureka_token", nova: "syt_BBB_nova_token" };
await mkdir(creds, { recursive: true });
for (const [persona, token] of Object.entries(tokens)) await writeFile(resolve(creds, `matrix-token-${persona}`), `${token}\n`);
await writeFile(resolve(creds, "chat-key"), "chat-key-credential\n");
await mkdir(resolve(control, "eureka"), { recursive: true });
await mkdir(resolve(control, "nova"), { recursive: true });
await writeControl(resolve(control, "eureka", "control.cc"), [true, "2026-10-29T00:00:00Z", "op"]);
await writeControl(resolve(control, "nova", "control.cc"), [false, "2026-10-29T00:00:00Z", "op"]); // nova is braked
const seen = [];
const joinedBy = new Map();
const hs = createServer((req, res) => {
const auth = req.headers.authorization ?? "";
const path = decodeURIComponent(new URL(req.url, "http://x").pathname).replace("/_matrix/client/v3", "");
seen.push({ auth, method: req.method, path });
const reply = body => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
if (path === "/account/whoami") return reply({ user_id: auth.includes(tokens.eureka) ? "@eureka:t" : "@nova:t" });
if (path === "/joined_rooms") return reply({ joined_rooms: [...(joinedBy.get(auth) ?? [])] });
if (path.startsWith("/join/")) { joinedBy.set(auth, (joinedBy.get(auth) ?? new Set()).add(path.slice(6))); return reply({ room_id: path.slice(6) }); }
if (path.startsWith("/directory/room/")) return reply({ room_id: "!swarm:t" });
if (path === "/sync") return reply({ next_batch: "b0" });
res.writeHead(404); res.end("{}");
});
await new Promise(ok => hs.listen(0, "127.0.0.1", ok));
const probe = dgram.createSocket("udp4");
await new Promise(ok => probe.bind(0, "127.0.0.1", ok));
const port = probe.address().port;
probe.close();
const child = spawn(process.execPath, [resolve(root, "tools", "persona-matrix-mouth.mjs")], {
env: {
PATH: process.env.PATH, CULTLIB_ROOT: resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib")), PERSONAS: "eureka,nova", CREDENTIALS_DIRECTORY: creds, CONTROL_ROOT: control,
STATE_DIRECTORY: stateDir, MATRIX_HOMESERVER: `http://127.0.0.1:${hs.address().port}`, MATRIX_ROOMS: "#swarm:t", MATRIX_SERVER_NAME: "t", CHAT_RUDP_LISTEN: `127.0.0.1:${port}`,
},
});
let stderr = "";
child.stderr.on("data", c => (stderr += c));
try {
for (let i = 0; i < 100 && !(seen.some(r => r.path === "/sync" && r.auth.includes(tokens.nova)) && seen.some(r => r.path === "/sync" && r.auth.includes(tokens.eureka))); i++) await new Promise(r => setTimeout(r, 50));
const by = persona => seen.filter(r => r.auth === `Bearer ${tokens[persona]}`);
assert.ok(by("eureka").some(r => r.path === "/sync") && by("nova").some(r => r.path === "/sync"), `both accounts sync from one process; stderr: ${stderr}`);
assert.equal(by("eureka").length + by("nova").length, seen.length, "every request carries exactly one Persona's token");
assert.deepEqual(by("eureka").filter(r => r.method === "POST").map(r => r.path), ["/join/!swarm:t"], "the released Persona joins");
assert.deepEqual(by("nova").filter(r => r.method === "POST"), [], "the braked Persona joins nothing");
} finally {
child.kill();
await new Promise(ok => hs.close(ok));
}
assert.ok(!Object.values(tokens).some(t => stderr.includes(t)) && !stderr.includes("chat-key-credential"), "no secret on stderr");
});
test("a reply is paged to the byte budget, oldest first, and one delivery that will not encode is skipped and logged without silencing the rest", () => {
const mk = (n, body) => ({ ...delivery, seq: n, eventId: `$e${n}`, text: body });
const logs = [];
const page = pageOf("eureka", [mk(1, "ok"), { ...mk(2, "bad"), sender: 123 }, mk(3, "fine")], msgpack, l => logs.push(l));
assert.deepEqual(page[1].map(t => t[0]), [1, 3]);
assert.equal(logs.length, 1);
assert.ok(logs[0].includes("delivery 2 skipped"));
const bodies = Array.from({ length: 200 }, (_, i) => mk(i + 1, "x".repeat(3900)));
const paged = pageOf("eureka", bodies, msgpack);
const size = msgpack.encode(paged).length;
assert.ok(size <= docs.limits.replyBytes + 1000, `page of ${size} bytes is over the budget`);
assert.ok(paged[1].length > 1 && paged[1].length < 200);
assert.deepEqual(paged[1].map(t => t[0]), paged[1].map((_, i) => i + 1), "the page is the oldest deliveries");
assert.equal(pageOf("eureka", [mk(1, "x".repeat(4000))], msgpack)[1].length, 1, "a single delivery is always served");
});

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, createRing, redact } from "../tools/persona-matrix-mouth.mjs";
import { createMouth, createBrakeReader, guarded, redact } from "../tools/persona-matrix-mouth.mjs";
const TOKEN = "syt_SECRET_TOKEN_9f3";
const HS_NAME = "gamecult.test";
@ -14,7 +14,7 @@ const message = (id, sender, body, ts) => ({ type: "m.room.message", event_id: i
const tick = () => new Promise(r => setImmediate(r));
// A fake Matrix client-server API: just the endpoints the mouth uses.
async function fakeHomeserver({ sync, failWith } = {}) {
async function fakeHomeserver({ sync, failWith, users, member, direct } = {}) {
const seen = [];
const sent = new Map(); // txnId -> event_id
const server = createServer((req, res) => {
@ -27,10 +27,15 @@ async function fakeHomeserver({ sync, failWith } = {}) {
const reply = (status, body) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
if (failWith) return reply(failWith, { errcode: "M_UNKNOWN_TOKEN", error: `bad token ${record.auth}` });
const p = record.path.replace("/_matrix/client/v3", "");
if (p === "/account/whoami") return reply(200, { user_id: SELF });
const bearer = (record.auth ?? "").replace("Bearer ", "");
if (p === "/account/whoami") return reply(200, { user_id: users?.[bearer] ?? SELF });
if (p === "/joined_rooms") return reply(200, { joined_rooms: member ?? Object.values(ROOMS) });
if (req.method === "PUT" && p.includes("/account_data/m.direct")) return reply(200, {});
if (p.endsWith("/leave")) return reply(200, {});
if (p.includes("/account_data/m.direct")) return direct ? reply(200, direct) : reply(404, { errcode: "M_NOT_FOUND" });
if (p.startsWith("/directory/room/")) return reply(200, { room_id: ROOMS[p.slice("/directory/room/".length)] });
if (p.startsWith("/join/")) return reply(200, { room_id: p.slice(6) });
if (p === "/sync") return reply(200, sync(record.query.get("since")));
if (p === "/sync") return reply(200, sync(record.query.get("since"), bearer));
const m = p.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/);
if (m && req.method === "PUT") {
if (!sent.has(m[2])) sent.set(m[2], `$event${sent.size + 1}`);
@ -57,7 +62,7 @@ const joined = events => ({ rooms: { join: events } });
function mouthFor(hs, overrides = {}) {
return createMouth({
persona: "eureka", homeserver: hs.url, rooms: Object.keys(ROOMS), token: TOKEN, state: memoryState(),
brake: async () => true, ring: async () => {}, log: () => {}, syncTimeoutMs: 1, ...overrides,
serverName: HS_NAME, brake: async () => true, log: () => {}, syncTimeoutMs: 1, ...overrides,
});
}
@ -229,59 +234,6 @@ test("brake reader: only a decoded released brake counts as released", async ()
assert.equal(await readerFor(store({ type: "eureka.control.brake.v1", key: "brake", payload: Buffer.from("not json") }))(), false, "undecodable bytes");
});
test("ring_starts_persona_unit_once: two deliveries in one idle window cause one start; a drained mailbox rings again", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
let rings = 0;
const mouth = mouthFor(hs, { ring: async () => { rings++; } });
await mouth.syncOnce(); // baseline
await mouth.syncOnce(); // delivery 1 rings
await mouth.syncOnce(); // delivery 2, same window, still unacked
await tick();
assert.equal(rings, 1);
} finally { await hs.close(); }
const every = await fakeHomeserver({ sync: since => ({ next_batch: `n${since}`, ...joined({ "!swarm:gamecult.test": { timeline: { events: [message(`$e${since}`, `@alice:${HS_NAME}`, "again", 5)] } } }) }) });
try {
let clock = 0, rings = 0;
const mouth = mouthFor(every, { ring: async () => { rings++; }, now: () => clock });
await mouth.syncOnce(); // baseline
await mouth.syncOnce(); await tick();
assert.equal(rings, 1);
await mouth.syncOnce(); await tick();
assert.equal(rings, 1, "inside the window and unacked: no second start");
await mouth.ack(2);
await mouth.syncOnce(); await tick();
assert.equal(rings, 2, "after a drain a new delivery rings again");
clock = 6 * 60 * 1000;
await mouth.syncOnce(); await tick();
assert.equal(rings, 3, "a delivery left unanswered past the window rings again");
} finally { await every.close(); }
});
test("a failed ring is logged once and never retried", async () => {
const logs = [];
let calls = 0;
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs, { ring: async () => { calls++; throw new Error("systemctl refused"); }, log: l => logs.push(l) });
await mouth.syncOnce(); await mouth.syncOnce(); await tick(); await tick();
assert.equal(calls, 1);
assert.equal(logs.filter(l => l.includes("ring failed")).length, 1);
} finally { await hs.close(); }
});
test("ring runs exactly systemctl start --no-block on the instance's unit, with no token in the child's environment", async () => {
const calls = [];
await createRing({ instance: "eureka", run: (cmd, args, options, cb) => { calls.push({ cmd, args, options }); cb(null); } })();
assert.equal(calls.length, 1);
assert.deepEqual([calls[0].cmd, calls[0].args], ["systemctl", ["start", "--no-block", "mind-persona@eureka.service"]]);
assert.deepEqual(Object.keys(calls[0].options.env), ["PATH"]);
const failed = [];
await createRing({ instance: "eureka", run: (c, a, o, cb) => cb(new Error("denied")), log: l => failed.push(l) })();
assert.deepEqual(failed, ["ring failed: denied"]);
});
test("the Matrix token goes only in the Authorization header: never in logs, errors, deliveries or receipts", async () => {
const logs = [];
const bad = await fakeHomeserver({ sync: script, failWith: 401 });
@ -316,6 +268,7 @@ test("the Matrix token goes only in the Authorization header: never in logs, err
assert.ok(logs.length > 0, "failures were logged");
assert.ok(logs.every(l => !l.includes(TOKEN)), `a log line carried the token: ${logs.find(l => l.includes(TOKEN))}`);
assert.equal(redact(`x ${TOKEN} y`, TOKEN), "x [redacted] y");
assert.equal(redact("a1 b2 a1", ["a1", "b2"]), "[redacted] [redacted] [redacted]");
});
test("a homeserver error is reported with its status and none of its body", async () => {
@ -345,3 +298,282 @@ test("the Discord posting actuator is gone and the Matrix path names no Discord"
assert.throws(() => statSync(resolve(root, "tools", gone)));
}
});
// ---- one mouth, many Personas ----------------------------------------------------
const NOVA = `@nova:${HS_NAME}`;
const TOKEN_NOVA = "syt_OTHER_TOKEN_77x";
test("two_personas_two_queues: one message in a room both joined yields one delivery per Persona with separate seqs and only its own token", async () => {
const both = since => since === null
? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [] } } }) }
: { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$m1", `@alice:${HS_NAME}`, "hello both", 1000)] } } }) };
const hs = await fakeHomeserver({ sync: both, users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA } });
try {
const eureka = mouthFor(hs);
const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA });
await eureka.syncOnce(); await nova.syncOnce();
await eureka.syncOnce();
await eureka.syncOnce(); // a second delivery for eureka only
await nova.syncOnce();
assert.deepEqual(eureka.pendingAfter(0).map(d => d.seq), [1, 2]);
assert.deepEqual(nova.pendingAfter(0).map(d => d.seq), [1], "separate queue, separate seq");
await eureka.ack(2);
assert.equal(eureka.pendingCount, 0);
assert.equal(nova.pendingCount, 1, "one Persona's ack leaves the other's mail alone");
for (const r of hs.seen) assert.ok([`Bearer ${TOKEN}`, `Bearer ${TOKEN_NOVA}`].includes(r.auth));
const novaCalls = hs.seen.filter(r => r.auth === `Bearer ${TOKEN_NOVA}`);
assert.ok(novaCalls.length >= 3 && novaCalls.every(r => !JSON.stringify(r).includes(TOKEN)), "a token never appears in another account's request");
} finally { await hs.close(); }
});
test("a Persona's own words are not mail for it, but are for the other Persona", async () => {
const hs = await fakeHomeserver({
users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA },
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$e", SELF, "eureka speaking", 5)] } } }) },
});
try {
const eureka = mouthFor(hs);
const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA });
for (const m of [eureka, nova]) { await m.syncOnce(); await m.syncOnce(); }
assert.equal(eureka.pendingCount, 0);
assert.deepEqual(nova.pendingAfter(0).map(d => d.sender), [SELF]);
} finally { await hs.close(); }
});
test("mentions_carried: m.mentions user ids and the exact sender id are on the delivery; absent or malformed mentions are empty", async () => {
const withMentions = (id, mentions) => ({ ...message(id, `@meta:${HS_NAME}`, "hi", 7), content: { msgtype: "m.text", body: "hi", ...(mentions === undefined ? {} : { "m.mentions": mentions }) } });
const hs = await fakeHomeserver({
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [
withMentions("$a", { user_ids: [SELF, NOVA] }), withMentions("$b", undefined), withMentions("$c", { user_ids: "nope" }), withMentions("$d", { room: true }),
] } } }) },
});
try {
const mouth = mouthFor(hs);
await mouth.syncOnce();
const made = await mouth.syncOnce();
assert.deepEqual(made.map(d => d.mentions), [[SELF, NOVA], [], [], []]);
assert.ok(made.every(d => d.sender === `@meta:${HS_NAME}`));
assert.ok(made.every(d => d.isDirect === false));
} finally { await hs.close(); }
});
test("dm_flag: a joined room the account marks in m.direct is delivered as a DM, answerable by its room id; other rooms are not", async () => {
const DM = "!dm:gamecult.test";
const hs = await fakeHomeserver({
direct: { [`@meta:${HS_NAME}`]: [DM] },
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", account_data: { events: [{ type: "m.direct", content: { [`@meta:${HS_NAME}`]: [DM, "!dm2:gamecult.test"] } }] }, ...joined({
[DM]: { timeline: { events: [message("$dm", `@meta:${HS_NAME}`, "private", 9)] } },
"!dm2:gamecult.test": { timeline: { events: [message("$dm2", `@meta:${HS_NAME}`, "second", 10)] } },
"!swarm:gamecult.test": { timeline: { events: [message("$pub", `@meta:${HS_NAME}`, "public", 11)] } },
}) },
});
try {
const mouth = mouthFor(hs);
await mouth.syncOnce();
const made = await mouth.syncOnce();
assert.deepEqual(made.map(d => [d.eventId, d.roomAlias, d.isDirect]), [["$dm", DM, true], ["$dm2", "!dm2:gamecult.test", true], ["$pub", `#swarm:${HS_NAME}`, false]]);
assert.equal((await mouth.send({ requestId: "dm-1", persona: "eureka", roomAlias: DM, text: "reply" })).refusal, null);
assert.equal(hs.puts().at(-1).path, `/_matrix/client/v3/rooms/${DM}/send/m.room.message/dm-1`);
} finally { await hs.close(); }
});
test("pending_is_not_ack: repeated reads return the same deliveries until an ack", async () => {
const hs = await fakeHomeserver({ sync: script });
try {
const mouth = mouthFor(hs);
await mouth.syncOnce(); await mouth.syncOnce();
const first = mouth.pendingAfter(0), second = mouth.pendingAfter(0);
assert.deepEqual(first, second);
assert.equal(first.length, 1);
await mouth.ack(first[0].seq);
assert.deepEqual(mouth.pendingAfter(0), []);
} finally { await hs.close(); }
});
test("join_while_braked: no join and no send while the brake is held; joining happens on the round after release; a failing join is logged, redacted, and never throws", async () => {
let open = false;
const logs = [];
const hs = await fakeHomeserver({ sync: script, member: [] });
try {
const mouth = mouthFor(hs, { brake: async () => open, log: l => logs.push(l) });
await mouth.syncOnce();
await mouth.send({ requestId: "r", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "speak" });
assert.equal(hs.seen.filter(r => r.method === "POST" || r.method === "PUT").length, 0, "braked: nothing is joined or sent");
open = true;
await mouth.syncOnce();
assert.deepEqual(hs.seen.filter(r => r.method === "POST").map(r => r.path.split("/join/")[1]).sort(), ["!operator:gamecult.test", "!swarm:gamecult.test"]);
await mouth.syncOnce();
assert.equal(hs.seen.filter(r => r.method === "POST").length, 2, "joined rooms are not joined again");
} finally { await hs.close(); }
const failing = await fakeHomeserver({ sync: script, member: [] });
try {
const mouth = mouthFor(failing, { fetch: (url, init) => String(url).includes("/join/") ? Promise.reject(new Error(`join refused for ${init.headers.authorization}`)) : fetch(url, init), log: l => logs.push(l) });
await mouth.syncOnce(); // no throw: the unit does not crash-loop on a failed join
assert.ok(logs.some(l => l.includes("not joined")) && logs.every(l => !l.includes(TOKEN)));
} finally { await failing.close(); }
});
test("a freshly joined room's history is context, not mail; an already joined room's backlog still is mail after a restart", async () => {
const hs = await fakeHomeserver({
member: ["!operator:gamecult.test"],
sync: since => since === "b0"
? { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$hist", `@alice:${HS_NAME}`, "before I joined", 1)] } }, "!operator:gamecult.test": { timeline: { events: [message("$mail", `@alice:${HS_NAME}`, "waiting for me", 2)] } } }) }
: { next_batch: "b0" },
});
try {
const mouth = mouthFor(hs, { state: memoryState({ nextBatch: "b0", seq: 4 }) });
const made = await mouth.syncOnce();
assert.deepEqual(made.map(d => d.eventId), ["$mail"]);
assert.equal(made[0].seq, 5);
} finally { await hs.close(); }
});
test("pending is bounded: at the bound the mouth stops reading, keeps its position, and resumes after an ack", async () => {
const hs = await fakeHomeserver({ sync: since => ({ next_batch: `n${since}`, ...joined({ "!swarm:gamecult.test": { timeline: { events: [message(`$e${since}`, `@alice:${HS_NAME}`, "again", 5)] } } }) }) });
try {
const mouth = mouthFor(hs, { maxPending: 2 });
await mouth.syncOnce(); // baseline
await mouth.syncOnce(); await mouth.syncOnce();
const syncs = () => hs.seen.filter(r => r.path.endsWith("/sync")).length;
const before = syncs();
assert.deepEqual(await mouth.syncOnce(), []);
assert.equal(syncs(), before, "no /sync while the mailbox is at the bound");
assert.equal(mouth.pendingCount, 2);
await mouth.ack(2);
assert.equal((await mouth.syncOnce()).length, 1);
} finally { await hs.close(); }
});
test("guarded: a failure leaves with every secret redacted, however many tokens the process holds", async () => {
await assert.rejects(guarded([TOKEN, TOKEN_NOVA], async () => { throw new Error(`boom ${TOKEN} then ${TOKEN_NOVA}`); }), err => err.message === "boom [redacted] then [redacted]");
assert.equal(await guarded([TOKEN], async () => 5), 5);
});
test("the ring is gone: the mouth names no unit manager and spawns no child", () => {
const source = readFileSync(resolve(import.meta.dirname, "..", "tools", "persona-matrix-mouth.mjs"), "utf8");
assert.ok(!/systemctl|child_process|execFile|mind-persona@/.test(source));
});
test("delivery_poison_pill: a non-string display name or malformed event fields never reach a delivery; the sender falls back to its localpart", async () => {
const hs = await fakeHomeserver({
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": {
state: { events: [{ type: "m.room.member", state_key: `@alice:${HS_NAME}`, content: { displayname: 123 } }] },
timeline: { events: [
message("$a", `@alice:${HS_NAME}`, "numeric name", "not-a-number"),
{ type: "m.room.message", event_id: "$b", sender: 5, content: { msgtype: "m.text", body: "numeric sender" } },
{ type: "m.room.message", sender: `@alice:${HS_NAME}`, content: { msgtype: "m.text", body: "no event id" } },
] },
} }) },
});
try {
const mouth = mouthFor(hs, { now: () => 42 });
await mouth.syncOnce();
const made = await mouth.syncOnce();
assert.deepEqual(made.map(d => [d.eventId, d.senderDisplay, d.atUnixMs]), [["$a", "alice", 42]]);
} finally { await hs.close(); }
});
test("delivery size: body, context lines and mentions are bounded per delivery, on a character boundary", async () => {
const big = "é".repeat(30000);
const hs = await fakeHomeserver({
sync: since => since === null ? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$h", `@alice:${HS_NAME}`, big, 1)] } } }) }
: { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [{ ...message("$n", `@bob:${HS_NAME}`, big, 2), content: { msgtype: "m.text", body: big, "m.mentions": { user_ids: Array.from({ length: 500 }, (_, i) => `@u${i}:x`) } } }] } } }) },
});
try {
const mouth = mouthFor(hs);
await mouth.syncOnce();
const [d] = await mouth.syncOnce();
assert.ok(Buffer.byteLength(d.text) <= 4000 && d.text === "é".repeat(2000));
assert.ok(d.context.every(c => Buffer.byteLength(c.text) <= 1000));
assert.equal(d.mentions.length, 64);
} finally { await hs.close(); }
});
test("bound_is_a_hard_cap: a round that would pass the cap enqueues only what fits, keeps its position, and the rest arrives once after an ack", async () => {
const four = ["$w", "$x", "$y", "$z"].map((id, i) => message(id, `@alice:${HS_NAME}`, id, 10 + i));
const state = memoryState();
const hs = await fakeHomeserver({
sync: since => since === null ? { next_batch: "b0" } : since === "b0" ? { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: four } } }) } : { next_batch: since },
});
try {
const mouth = mouthFor(hs, { maxPending: 3, state });
await mouth.syncOnce();
assert.deepEqual((await mouth.syncOnce()).map(d => d.eventId), ["$w", "$x", "$y"]);
assert.equal(mouth.pendingCount, 3, "never above the cap");
assert.deepEqual(await mouth.syncOnce(), [], "full: no read");
await mouth.ack(2);
const rest = await mouth.syncOnce();
assert.deepEqual(rest.map(d => d.eventId), ["$z"], "the four-message round is read again; the three already taken are not delivered twice");
assert.deepEqual(mouth.pendingAfter(0).map(d => d.eventId), ["$y", "$z"]);
assert.equal(state.box.value.nextBatch, "b0", "the position moves only once the whole round is acked");
await mouth.ack(4);
assert.equal(state.box.value.nextBatch, "b1");
} finally { await hs.close(); }
});
// ---- DM invitations ----------------------------------------------------------------
const DMROOM = "!newdm:gamecult.test";
const invite = (inviter, isDirect = true) => ({ [DMROOM]: { invite_state: { events: [{ type: "m.room.member", state_key: SELF, sender: inviter, content: { membership: "invite", is_direct: isDirect } }] } } });
const inviteSync = (inviter, isDirect) => since => since === null ? { next_batch: "b0" }
: since === "b0" ? { next_batch: "b1", rooms: { invite: invite(inviter, isDirect) } }
: since === "b1" ? { next_batch: "b2", ...joined({ [DMROOM]: { timeline: { events: [message("$first", `@meta:${HS_NAME}`, "hello in private", 3)] } } }) }
: { next_batch: since };
const joinsOf = hs => hs.seen.filter(r => r.method === "POST" && r.path.includes("/join/")).map(r => r.path.split("/join/")[1]);
const leavesOf = hs => hs.seen.filter(r => r.method === "POST" && r.path.endsWith("/leave"));
test("dm_invite_accepted: a same-server DM invitation is joined, remembered in m.direct, and its messages arrive as is_direct deliveries", async () => {
const hs = await fakeHomeserver({ sync: inviteSync(`@meta:${HS_NAME}`) });
try {
const mouth = mouthFor(hs);
await mouth.syncOnce(); await mouth.syncOnce();
assert.deepEqual(joinsOf(hs), [DMROOM]);
const saved = hs.puts().find(r => r.path.endsWith("/account_data/m.direct"));
assert.deepEqual(saved.body, { [`@meta:${HS_NAME}`]: [DMROOM] });
const [d] = await mouth.syncOnce();
assert.deepEqual([d.eventId, d.roomAlias, d.isDirect, d.sender], ["$first", DMROOM, true, `@meta:${HS_NAME}`], "the message sent before the join is mail, not history");
assert.equal((await mouth.send({ requestId: "dm", persona: "eureka", roomAlias: DMROOM, text: "hi" })).refusal, null);
} finally { await hs.close(); }
});
test("dm_invite_waits_for_release: an invitation received while braked is not joined until the brake is released", async () => {
let open = false;
const hs = await fakeHomeserver({ sync: inviteSync(`@meta:${HS_NAME}`) });
try {
const mouth = mouthFor(hs, { brake: async () => open });
await mouth.syncOnce(); await mouth.syncOnce(); await mouth.syncOnce();
assert.deepEqual(hs.seen.filter(r => r.method === "POST" || r.method === "PUT"), [], "braked: no join, no leave, nothing written");
open = true;
await mouth.syncOnce();
assert.deepEqual(joinsOf(hs), [DMROOM]);
} finally { await hs.close(); }
});
test("dm_invite_other_server_declined: an invitation from another server is declined, never joined, brake or no brake", async () => {
const hs = await fakeHomeserver({ sync: inviteSync("@mallory:evil.example") });
try {
const mouth = mouthFor(hs);
await mouth.syncOnce(); await mouth.syncOnce();
assert.deepEqual(joinsOf(hs), []);
assert.deepEqual(leavesOf(hs).map(r => r.path), [`/_matrix/client/v3/rooms/${DMROOM}/leave`]);
await mouth.syncOnce();
assert.equal(leavesOf(hs).length, 1, "answered once");
} finally { await hs.close(); }
const lookalike = await fakeHomeserver({ sync: inviteSync(`@mallory:${HS_NAME}.evil.example`) });
try {
const mouth = mouthFor(lookalike);
await mouth.syncOnce(); await mouth.syncOnce();
assert.deepEqual(joinsOf(lookalike), [], "the server name must match exactly");
} finally { await lookalike.close(); }
});
test("a same-server invitation to a room that is not a DM is neither joined nor declined", async () => {
const hs = await fakeHomeserver({ sync: inviteSync(`@meta:${HS_NAME}`, false) });
try {
const mouth = mouthFor(hs);
await mouth.syncOnce(); await mouth.syncOnce();
assert.deepEqual([joinsOf(hs), leavesOf(hs)], [[], []]);
} finally { await hs.close(); }
});

View file

@ -9,13 +9,24 @@ export const kinds = Object.freeze({
ack: "gamecult.persona.chat_ack.v1",
sendRequest: "gamecult.persona.chat_send_request.v1",
sendReceipt: "gamecult.persona.chat_send_receipt.v1",
refusal: "gamecult.persona.chat_refusal.v1",
});
export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed"]);
export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed", "UnknownPersona"]);
const maxTextBytes = 4000;
const maxContext = 20;
// Per-delivery bounds, applied where the mouth reads Matrix: the body is bounded like a send,
// a context line and a display name are smaller, and a reply is paged to a byte budget.
export const limits = Object.freeze({ textBytes: maxTextBytes, contextBytes: 1000, displayBytes: 256, mentions: 64, replyBytes: 256 * 1024 });
// `value` cut to at most `maxBytes` of UTF-8 without splitting a character.
export function clip(value, maxBytes) {
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
return Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8").replace(/<2F>$/, "");
}
export function pendingRequestTuple({ persona, afterSeq }) {
return [text(persona, "persona"), seq(afterSeq, "after_seq")];
}
@ -50,23 +61,38 @@ export function sendReceiptTuple({ requestId, eventId = null, refusal = null })
export function parseSendReceipt(t) {
const [requestId, eventId, refusal] = tuple(t, 3, "chat_send_receipt");
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal };
}
// A typed refusal for a request that names a Persona the mouth does not serve.
export function refusalTuple({ persona, refusal }) {
if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`);
return [text(persona, "persona"), refusal];
}
export function parseRefusal(t) {
const [persona, refusal] = tuple(t, 2, "chat_refusal");
if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
return { persona: text(persona, "persona"), refusal };
}
export function deliveryTuple(d) {
return [
seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), text(d.eventId, "event_id"),
text(d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"),
d.context.slice(-maxContext).map(c => [text(c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]),
d.mentions.map(id => text(id, "mention")), flag(d.isDirect, "is_direct"),
];
}
export function parseDelivery(t) {
const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context] = tuple(t, 9, "delivery");
const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context, mentions, isDirect] = tuple(t, 11, "delivery");
if (!Array.isArray(context)) throw new Error("delivery context must be a list");
if (!Array.isArray(mentions)) throw new Error("delivery mentions must be a list");
return {
seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: text(eventId, "event_id"),
sender: text(sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"),
context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: text(cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }),
mentions: mentions.map(id => text(id, "mention")), isDirect: flag(isDirect, "is_direct"),
};
}
@ -108,6 +134,10 @@ function text(v, what) {
if (typeof v !== "string" || v === "") throw new Error(`${what} must be a non-empty string`);
return v;
}
function flag(v, what) {
if (typeof v !== "boolean") throw new Error(`${what} must be a boolean`);
return v;
}
function seq(v, what) {
if (!Number.isSafeInteger(v) || v < 0) throw new Error(`${what} must be a non-negative integer`);
return v;

View file

@ -1,21 +1,23 @@
// The Matrix ear and mouth of one Persona. One process per Persona.
// The Matrix ear and mouth of every Persona. One process: one account (token) and
// one /sync loop per Persona, one CultNet endpoint for all of them.
//
// Ear: /sync the configured rooms; every message from another sender becomes a
// delivery kept in memory. The /sync position (next_batch) is persisted only when
// an ack covers it, so a restart before the ack re-delivers instead of losing.
// Ear: /sync the configured rooms (and the account's direct rooms); every message
// from another sender becomes a delivery kept in memory, carrying its sender id,
// its m.mentions user ids and whether the room is a DM. The /sync position
// (next_batch) is persisted only when an ack covers it, so a restart before the
// ack re-delivers instead of losing. Reading wakes nothing: the heartbeat polls
// with a non-consuming chat_pending_request and decides who runs.
// Mouth: a send request becomes one PUT with txnId = request_id (a retry is
// idempotent) and only while the instance brake is released.
// Ring: the first delivery of an idle window starts the Persona's one-shot unit.
// Starting is a request; the unit's own gate decides.
// idempotent), and a room is joined, only while that Persona's brake is released.
//
// The Matrix access token is read here, from a systemd credential, and nowhere
// else. It is never put in a document, an environment passed to a child, or a log.
// Each Matrix access token is read here, from its own systemd credential, and
// nowhere else. It is never put in a document, an environment passed to a child,
// or a log, and it is only ever presented in its own Persona's Authorization header.
//
// Everything outside this process is a port (fetch, state, brake, ring, clock), so
// the core runs against a fake homeserver with no CultLib and no daemon.
import { execFile } from "node:child_process";
// Everything outside this process is a port (fetch, state, brake, clock), so the
// core runs against a fake homeserver with no CultLib and no daemon.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { mkdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { createSocket } from "node:dgram";
import { resolve, join } from "node:path";
@ -25,16 +27,20 @@ import * as docs from "./persona-chat-documents.mjs";
const API = "/_matrix/client/v3";
const BRAKE_TYPE = "eureka.control.brake.v1";
const CONTEXT = 20;
const RING_WINDOW_MS = 5 * 60 * 1000;
const MAX_PENDING = 500;
const SLUG = /^[A-Za-z0-9_-]{1,64}$/;
export function createMouth({
persona, homeserver, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, ring,
now = Date.now, log = () => {}, syncTimeoutMs = 30000,
persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake,
now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING,
}) {
if (!persona || !homeserver || !token || !rooms?.length) throw new Error("persona, homeserver, rooms and token are required");
const say = message => log(redact(String(message), token));
const roomIdByAlias = new Map();
const aliasByRoomId = new Map();
const joinedAliases = new Set();
const freshRooms = new Set(); // joined by this process since the last sync: their history is context, not mail
const directRooms = new Set(); // room ids the account marks as DMs (m.direct)
const history = new Map(); // room_id -> last CONTEXT messages, oldest first
const displayNames = new Map();
let selfId = null;
@ -43,8 +49,10 @@ export function createMouth({
let pending = []; // deliveries not yet acked
let batches = []; // { token, maxSeq }: positions awaiting an ack
let committed = null; // the position that is persisted
let lastRingAt = -Infinity;
let started = false;
const directContent = {}; // the account's m.direct content: user id -> room ids
const invites = new Map(); // room id -> { inviter, direct }: invitations not yet answered
const processed = new Set(); // event ids read since the position last advanced
async function matrix(method, path, body) {
let response;
@ -61,58 +69,139 @@ export function createMouth({
return response.json();
}
async function start() {
const saved = await state.load();
cursor = committed = saved?.nextBatch ?? null;
seqCounter = saved?.seq ?? 0;
selfId = (await matrix("GET", "/account/whoami")).user_id;
for (const alias of rooms) {
const { room_id: roomId } = await matrix("GET", `/directory/room/${encodeURIComponent(alias)}`);
await matrix("POST", `/join/${encodeURIComponent(roomId)}`, {});
roomIdByAlias.set(alias, roomId);
aliasByRoomId.set(roomId, alias);
const released = async () => { try { return (await brake()) === true; } catch { return false; } };
const noteDirect = content => {
for (const [user, ids] of Object.entries(content ?? {})) {
if (!Array.isArray(ids)) continue;
directContent[user] = ids.filter(id => typeof id === "string");
directContent[user].forEach(id => directRooms.add(id));
}
started = true;
};
function noteInvites(invited) {
for (const [roomId, room] of Object.entries(invited ?? {})) {
const event = (room.invite_state?.events ?? []).find(e => e.type === "m.room.member" && e.state_key === selfId && typeof e.sender === "string");
if (event) invites.set(roomId, { inviter: event.sender, direct: event.content?.is_direct === true });
}
}
// A DM invitation from an account on our own homeserver is accepted, and only while the brake is
// released; one received while braked waits for the release. An invitation from any other server is
// declined. A same-server invitation to a room that is not a DM is not ours to accept and is dropped.
async function answerInvites() {
let free = null;
for (const [roomId, invite] of [...invites]) {
try {
const room = encodeURIComponent(roomId);
if (!serverName || invite.inviter.slice(invite.inviter.indexOf(":") + 1) !== serverName) {
await matrix("POST", `/rooms/${room}/leave`, {});
invites.delete(roomId);
} else if (!invite.direct) {
invites.delete(roomId);
} else {
free ??= await released();
if (!free) continue;
await matrix("POST", `/join/${room}`, {});
invites.delete(roomId);
directRooms.add(roomId);
directContent[invite.inviter] = [...new Set([...(directContent[invite.inviter] ?? []), roomId])];
await matrix("PUT", `/user/${encodeURIComponent(selfId)}/account_data/m.direct`, directContent).catch(error => say(`m.direct not saved: ${error.message}`));
}
} catch (error) {
say(`invite to ${roomId} not answered: ${error.message}`);
}
}
}
const unjoined = () => joinedAliases.size < rooms.length;
// Join the configured rooms the account is not in, only while the brake is released.
// One room failing is logged and retried on the next round; it never stops the others.
async function ensureRooms() {
const member = new Set((await matrix("GET", "/joined_rooms")).joined_rooms ?? []);
let free = null;
for (const alias of rooms.filter(a => !joinedAliases.has(a))) {
try {
let roomId = roomIdByAlias.get(alias);
if (!roomId) ({ room_id: roomId } = await matrix("GET", `/directory/room/${encodeURIComponent(alias)}`));
roomIdByAlias.set(alias, roomId);
if (!member.has(roomId)) {
free ??= await released();
if (!free) continue;
await matrix("POST", `/join/${encodeURIComponent(roomId)}`, {});
freshRooms.add(roomId);
}
aliasByRoomId.set(roomId, alias);
joinedAliases.add(alias);
} catch (error) {
say(`room ${alias} not joined: ${error.message}`);
}
}
}
async function start() {
if (!started) {
const saved = await state.load();
cursor = committed = saved?.nextBatch ?? null;
seqCounter = saved?.seq ?? 0;
selfId = (await matrix("GET", "/account/whoami")).user_id;
try { noteDirect(await matrix("GET", `/user/${encodeURIComponent(selfId)}/account_data/m.direct`)); } catch { /* no m.direct: no DMs yet */ }
started = true;
}
if (unjoined()) await ensureRooms();
}
// One /sync round. Returns the deliveries it made.
async function syncOnce() {
if (!started) await start();
if (!started || unjoined()) await start();
if (pending.length >= maxPending) return []; // unanswered mail is not outrun: the position stays where it is
const filter = encodeURIComponent(JSON.stringify({ room: { timeline: { limit: CONTEXT } } }));
const since = cursor === null ? "" : `&since=${encodeURIComponent(cursor)}`;
const reply = await matrix("GET", `/sync?timeout=${cursor === null ? 0 : syncTimeoutMs}&filter=${filter}${since}`);
const baseline = cursor === null; // first ever sync: history is context, not mail
for (const event of reply.account_data?.events ?? []) if (event.type === "m.direct") noteDirect(event.content);
noteInvites(reply.rooms?.invite);
await answerInvites();
const made = [];
for (const [roomId, room] of Object.entries(reply.rooms?.join ?? {})) {
const alias = aliasByRoomId.get(roomId);
let truncated = false; // the pending cap was reached inside this round
rounds: for (const [roomId, room] of Object.entries(reply.rooms?.join ?? {})) {
const alias = aliasByRoomId.get(roomId) ?? (directRooms.has(roomId) ? roomId : undefined);
if (!alias) continue;
for (const event of [...(room.state?.events ?? []), ...(room.timeline?.events ?? [])]) {
if (event.type === "m.room.member" && event.content?.displayname) displayNames.set(event.state_key, event.content.displayname);
const shown = event.content?.displayname;
if (event.type === "m.room.member" && typeof event.state_key === "string" && typeof shown === "string" && shown !== "") displayNames.set(event.state_key, docs.clip(shown, docs.limits.displayBytes));
}
for (const event of room.timeline?.events ?? []) {
if (event.type !== "m.room.message" || typeof event.content?.body !== "string") continue;
const entry = { sender: event.sender, text: event.content.body, atUnixMs: event.origin_server_ts ?? now() };
if (typeof event.sender !== "string" || event.sender === "" || typeof event.event_id !== "string" || event.event_id === "") continue;
if (processed.has(event.event_id)) continue; // read already in a round the cap kept from advancing
const ts = event.origin_server_ts;
const entry = { sender: event.sender, text: docs.clip(event.content.body, docs.limits.textBytes), atUnixMs: Number.isSafeInteger(ts) && ts >= 0 ? ts : now() };
const context = (history.get(roomId) ?? []).slice(-CONTEXT);
if (!baseline && event.sender !== selfId) {
const deliverable = !baseline && !freshRooms.has(roomId) && event.sender !== selfId;
if (deliverable && pending.length + made.length >= maxPending) { truncated = true; break rounds; }
processed.add(event.event_id);
if (deliverable) {
const mentioned = event.content["m.mentions"]?.user_ids;
made.push({
seq: ++seqCounter, roomAlias: alias, roomId, eventId: event.event_id, sender: event.sender,
senderDisplay: displayNames.get(event.sender) ?? localpart(event.sender), text: entry.text, atUnixMs: entry.atUnixMs,
context: context.map(c => ({ ...c })),
context: context.map(c => ({ ...c, text: docs.clip(c.text, docs.limits.contextBytes) })),
mentions: Array.isArray(mentioned) ? mentioned.filter(id => typeof id === "string" && id !== "" && id.length <= 255).slice(0, docs.limits.mentions) : [],
isDirect: directRooms.has(roomId),
});
}
history.set(roomId, [...context, entry].slice(-CONTEXT));
}
}
const wasIdle = pending.length === 0;
pending.push(...made);
cursor = reply.next_batch;
batches.push({ token: reply.next_batch, maxSeq: seqCounter });
if (!truncated) { // a truncated round leaves the position where it was: the rest stays on the server
cursor = reply.next_batch;
batches.push({ token: reply.next_batch, maxSeq: seqCounter });
processed.clear();
freshRooms.clear();
}
await commit();
if (made.length > 0) await state.save({ nextBatch: committed, seq: seqCounter }); // sequence numbers never repeat across a restart
if (made.length > 0 && (wasIdle || now() - lastRingAt >= RING_WINDOW_MS)) {
lastRingAt = now();
Promise.resolve().then(ring).catch(error => say(`ring failed: ${error?.message ?? error}`));
}
return made;
}
@ -133,20 +222,18 @@ export function createMouth({
}
function pendingAfter(afterSeq) {
return pending.filter(d => d.seq > afterSeq).map(d => ({ ...d }));
return pending.filter(d => d.seq > afterSeq).map(d => ({ ...d, context: d.context.map(c => ({ ...c })), mentions: [...d.mentions] }));
}
async function send({ requestId, persona: asked, roomAlias, text }) {
const refuse = refusal => ({ requestId, eventId: null, refusal });
if (asked !== persona) return refuse("PersonaMismatch");
let released = false;
try { released = (await brake()) === true; } catch { released = false; }
if (!released) return refuse("Brake");
if (!(await released())) return refuse("Brake");
if (typeof text !== "string" || text.trim() === "") return refuse("EmptyText");
if (!started) await start();
const roomId = roomIdByAlias.get(roomAlias);
if (!roomId) return refuse("UnknownRoom");
try {
if (!started || unjoined()) await start();
const roomId = joinedAliases.has(roomAlias) ? roomIdByAlias.get(roomAlias) : directRooms.has(roomAlias) ? roomAlias : null;
if (!roomId) return refuse("UnknownRoom");
const sent = await matrix("PUT", `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(requestId)}`, { msgtype: "m.text", body: text });
return { requestId, eventId: sent.event_id, refusal: null };
} catch (error) {
@ -156,10 +243,14 @@ export function createMouth({
}
async function run(signal) {
const pause = ms => new Promise(r => { const t = setTimeout(r, ms); signal?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
while (!signal?.aborted) {
try { await syncOnce(); } catch (error) {
try {
await syncOnce();
if (pending.length >= maxPending) await pause(1000);
} catch (error) {
say(`sync failed: ${error.message}`);
await new Promise(r => { const t = setTimeout(r, 5000); signal?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
await pause(5000);
}
}
}
@ -167,24 +258,22 @@ export function createMouth({
return { start, syncOnce, ack, pendingAfter, send, run, get pendingCount() { return pending.length; } };
}
export function redact(message, token) {
return token ? message.split(token).join("[redacted]") : message;
// Replace every secret in a message. `secrets` is one string or a list.
export function redact(message, secrets) {
let out = String(message);
for (const secret of [secrets].flat()) if (secret) out = out.split(secret).join("[redacted]");
return out;
}
// Run `fn`; whatever it throws leaves with every secret redacted.
export async function guarded(secrets, fn) {
try { return await fn(); } catch (error) { throw new Error(redact(error?.message ?? String(error), secrets)); }
}
function localpart(userId) {
return userId.replace(/^@/, "").split(":")[0];
}
// Ring: start the Persona's one-shot unit. No retry loop, no token in its environment.
export function createRing({ instance, run = execFile, log = () => {} }) {
return () => new Promise(done => {
run("systemctl", ["start", "--no-block", `mind-persona@${instance}.service`], { env: { PATH: process.env.PATH ?? "" } }, error => {
if (error) log(`ring failed: ${error.message}`);
done();
});
});
}
// Brake: released only when the instance's control store decodes to a released
// brake. Absent, undecodable or held reads are all "not released".
export function createBrakeReader({ controlPath, readStore, decode }) {
@ -208,16 +297,38 @@ export function connectionIdFor(connectionKey) {
return createHash("sha256").update(connectionKey).digest().readUInt32BE(0);
}
// CultNet RUDP endpoint on loopback. Requests arrive as document_put_raw messages.
export async function startChatEndpoint({ mouth, persona, runtime, port, connectionKey, runtimeId, socketFactory = createSocket, host = "127.0.0.1", log = () => {} }) {
// The reply to a chat_pending_request: deliveries oldest first, each encoded on its own so one that
// will not encode is skipped and logged without silencing the rest, up to the reply byte budget.
// The caller acks what it has read and asks again.
export function pageOf(persona, deliveries, msgpack, log = () => {}) {
const page = [];
let bytes = 0;
for (const delivery of deliveries) {
let tuple, size;
try { tuple = docs.deliveryTuple(delivery); size = msgpack.encode(tuple).length; } catch (error) {
log(`delivery ${delivery?.seq} skipped: ${error?.message ?? error}`);
continue;
}
if (page.length > 0 && bytes + size > docs.limits.replyBytes) break;
page.push(tuple);
bytes += size;
}
return [persona, page];
}
// CultNet RUDP endpoint on loopback, one for every Persona in `mouths` (persona -> mouth).
// Requests arrive as document_put_raw messages. A request naming a Persona this
// process does not serve is answered with a typed UnknownPersona refusal.
export async function startChatEndpoint({ mouths, runtime, port, connectionKey, runtimeId, socketFactory = createSocket, host = "127.0.0.1", log = () => {} }) {
if (!/^127\.\d+\.\d+\.\d+$/.test(host)) throw new Error("the chat endpoint listens on loopback only");
const { msgpack, cultnet } = runtime;
const socket = socketFactory("udp4");
await new Promise((ok, fail) => { socket.once("error", fail); socket.once("listening", ok); socket.bind(port, host); });
const connectionId = connectionIdFor(connectionKey);
const connection = new cultnet.CultNetRudpSocketTransportConnection({ runtimeId, socket, mode: "server", connectionId, initialSequence: 100, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200 });
const respond = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(cultnet.encodeCultNetMessageForWire(
const respond = (persona, kind, recordKey, tuple) => connection.send("schema", msgpack.encode(cultnet.encodeCultNetMessageForWire(
docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: runtimeId, sourceAgentId: persona, sourceRole: "bifrost-persona-matrix-mouth" }), "cultnet.schema.v0")));
const refuseUnknown = persona => respond(persona, docs.kinds.refusal, persona, docs.refusalTuple({ persona, refusal: "UnknownPersona" }));
let busy = Promise.resolve();
const handle = async value => {
try {
@ -227,16 +338,19 @@ export async function startChatEndpoint({ mouth, persona, runtime, port, connect
const tuple = msgpack.decode(document.payload);
if (document.schemaId === docs.kinds.pendingRequest) {
const request = docs.parsePendingRequest(tuple);
if (request.persona !== persona) throw new Error("chat request names another Persona");
respond(docs.kinds.pending, persona, docs.pendingTuple({ persona, deliveries: mouth.pendingAfter(request.afterSeq) }));
const mouth = mouths.get(request.persona);
if (!mouth) return refuseUnknown(request.persona);
respond(request.persona, docs.kinds.pending, request.persona, pageOf(request.persona, mouth.pendingAfter(request.afterSeq), msgpack, log));
} else if (document.schemaId === docs.kinds.ack) {
const request = docs.parseAck(tuple);
if (request.persona !== persona) throw new Error("chat ack names another Persona");
const mouth = mouths.get(request.persona);
if (!mouth) return refuseUnknown(request.persona);
await mouth.ack(request.throughSeq);
} else {
const request = docs.parseSendRequest(tuple);
const receipt = await mouth.send(request);
respond(docs.kinds.sendReceipt, receipt.requestId, docs.sendReceiptTuple(receipt));
const mouth = mouths.get(request.persona);
const receipt = mouth ? await mouth.send(request) : { requestId: request.requestId, eventId: null, refusal: "UnknownPersona" };
respond(request.persona, docs.kinds.sendReceipt, receipt.requestId, docs.sendReceiptTuple(receipt));
}
} catch (error) {
log(`chat endpoint rejected a frame: ${error?.message ?? error}`);
@ -275,27 +389,36 @@ function credential(env, name) {
return readFileSync(join(required(env, "CREDENTIALS_DIRECTORY"), name), "utf8").trim();
}
// 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 persona = required(env, "PERSONA");
const instance = env.MIND_INSTANCE ?? persona; // the unit template is instanced by the Persona's mind
if (!/^[A-Za-z0-9_-]{1,64}$/.test(instance)) throw new Error("MIND_INSTANCE is not a slug");
const token = credential(env, "matrix-token");
const runtime = loadRuntime(resolve(env.CULTLIB_ROOT ?? resolve(import.meta.dirname, "..", "..", "CultLib")));
const log = line => console.error(line);
const controlPath = join(env.CONTROL_ROOT ?? "/etc/gamecult/minds", instance, "control.cc");
const mouth = createMouth({
persona, token, log,
homeserver: required(env, "MATRIX_HOMESERVER").replace(/\/$/, ""),
rooms: required(env, "MATRIX_ROOMS").split(",").map(s => s.trim()).filter(Boolean),
state: cultcacheState({ runtime, path: env.STATE_PATH ?? join(required(env, "STATE_DIRECTORY"), "cursor.cc") }),
brake: createBrakeReader({ controlPath, readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }),
ring: createRing({ instance, log }),
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");
const tokens = new Map(personas.map(p => [p, credential(env, `matrix-token-${p}`)]));
const chatKey = credential(env, "chat-key");
const secrets = [...tokens.values(), chatKey];
return guarded(secrets, async () => {
const runtime = loadRuntime(resolve(env.CULTLIB_ROOT ?? resolve(import.meta.dirname, "..", "..", "CultLib")));
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 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}`),
state: cultcacheState({ runtime, path: join(stateRoot, persona, "cursor.cc") }),
brake: createBrakeReader({ controlPath: join(controlRoot, persona, "control.cc"), readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }),
})];
}));
const listen = required(env, "CHAT_RUDP_LISTEN"); // host:port, loopback
const colon = listen.lastIndexOf(":");
await startChatEndpoint({ mouths, runtime, host: listen.slice(0, colon), port: Number(listen.slice(colon + 1)), connectionKey: chatKey, runtimeId: "bifrost-persona-mouth", log });
await Promise.all([...mouths.values()].map(mouth => mouth.run()));
});
await mouth.start();
const listen = required(env, "CHAT_RUDP_LISTEN"); // host:port, loopback
const colon = listen.lastIndexOf(":");
await startChatEndpoint({ mouth, persona, runtime, host: listen.slice(0, colon), port: Number(listen.slice(colon + 1)), connectionKey: credential(env, "chat-key"), runtimeId: `bifrost-persona-mouth-${persona}`, log });
await mouth.run();
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {