Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion bin/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { startRoomPoller, checkRoomMessages } from '../src/team-relay/room-polle
import { startLinearPoller } from '../src/team-relay/linear-poller.mjs';
import { memoryList, memoryGet, memorySet, memoryAppend, memoryDelete, memorySearch } from '../src/team-relay/memory.mjs';
import { moltbookPost, moltbookFeed } from '../src/team-relay/moltbook.mjs';
import { startRoomAutomation } from '../src/team-relay/room-automation.mjs';
import { startRoomAutomation } from '../src/room-automation.mjs';
import { pollDiscord, startDiscordPoller } from '../src/team-relay/discord-poller.mjs';
import { UnifiedPoller } from '../src/team-relay/unified-poller.mjs';
import { groupmindAdapter } from '../src/team-relay/adapters/groupmind.mjs';
Expand Down Expand Up @@ -562,6 +562,33 @@ async function main() {
console.error('Error: poller.rooms, poller.api_key (or poller.api_key_file), and poller.handle must be set in config');
process.exit(1);
}
// Room AUTOMATION rides along with the watcher.
//
// It used to run only under the separate "automate" subcommand, which
// nothing on the fleet launches. That is why /lead answered nobody: the
// handler existed, its module was imported, its tests passed, and no
// process ever executed it. petrus pressed the command twice and got
// silence twice before anyone asked WHICH PROCESS RUNS THIS.
//
// Started here rather than as a second daemon so there is one thing to
// launch and one thing to restart. It keeps its own seen-ids file
// (automation.seen_file), separate from the poller's, so the two never
// consume each other's messages; on a first run with no seen file it
// SEEDS rather than replaying, which is what stops a historical /lead or
// /approve from executing on startup.
//
// Not awaited: both loop forever. A crash in automation must not take the
// poller - and therefore every room notification - down with it.
startRoomAutomation({
rooms: pollerRooms,
apiKey: pollerApiKey,
handle: pollerHandle,
config,
}).catch((e) => {
console.error(`Room automation stopped: ${e?.message || e}`);
console.error(' The poller is still running; chat commands like /lead are not.');
});

await startRoomPoller({
rooms: pollerRooms,
apiKey: pollerApiKey,
Expand Down
7 changes: 7 additions & 0 deletions bin/iak-mcp-daemon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ startConfirmationsServer({
port: cc.port || 8788,
host: cc.host || '127.0.0.1',
authToken: cc.auth_token || '',
// Per-agent principal tokens. startConfirmationsServer has accepted these
// since the team-lead work, but NOTHING EVER PASSED THEM: this call read
// auth_token and stopped, so `principals` in dogfood.json was inert and
// POST /lead answered "this daemon has no per-agent tokens" whatever was
// configured. That is why /lead never worked end to end -- the missing
// bearer in callDaemon was only half of it.
principals: cc.principals || {},
receiptsPath: config?.receipts?.path,
announce: serverAnnounce,
wakeScript,
Expand Down
308 changes: 297 additions & 11 deletions src/confirmations.mjs

Large diffs are not rendered by default.

385 changes: 342 additions & 43 deletions src/room-automation.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/team-relay/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// team-relay — generic room/comms modules for IDE Agent Kit

export { startRoomPoller, checkRoomMessages } from './room-poller.mjs';
export { startRoomAutomation } from './room-automation.mjs';
export { startRoomAutomation } from '../room-automation.mjs';
export { startWebhookServer } from './webhook-server.mjs';
export { pollDiscord, startDiscordPoller } from './discord-poller.mjs';
export { UnifiedPoller } from './unified-poller.mjs';
Expand Down
134 changes: 134 additions & 0 deletions test/authorization-boundary.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// The authorization boundary, over real HTTP, against real intents.
//
// @codexmb, after the daemon was restarted with this code live: "message
// delivery and presence of handleLeadCommand do not verify the authorization
// boundary." A commit hash proves which code loaded, not what it permits.
//
// FOUR INSTRUMENT FAILURES PRECEDED THE FIRST REAL MEASUREMENT HERE, and each
// one would have produced a confident wrong answer:
//
// wrong route /intent/:id/decide -> 404 for EVERYTHING. One test still
// "passed" because it asserted notEqual(status, 200); 404 is
// not 200. It would pass against a server with no authorization
// code at all.
// wrong header the server reads `Authorization: Bearer`, not X-API-Key, so
// no token resolved and EVERYTHING was 403. A boundary that
// refuses every caller looks secure and tests nothing.
// a lying sed printed "header corrected" while changing no file.
// wrong shape assert.throws() against setLead, which RETURNS a refusal. Had
// I reported that failure it would have read as "the code does
// not refuse self-appointment".
//
// Hence: every case asserts a specific STATUS and a specific REASON. A refusal
// for the wrong reason is not a pass.

import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startConfirmationsServer, createIntent, setLead, OWNER_HANDLE }
from '../src/confirmations.mjs';

const OWNER = 'tok-owner', LEAD = 'tok-lead', OTHER = 'tok-other';

async function boot() {
const receiptsPath = join(mkdtempSync(join(tmpdir(), 'authz-')), 'receipts.jsonl');
const server = startConfirmationsServer({
port: 0, receiptsPath,
principals: { [OWNER]: OWNER_HANDLE, [LEAD]: 'leadagent', [OTHER]: 'otheragent' },
});
// listen() is async; address() is null until 'listening'. Not awaiting this
// is what made the first version of this file hang forever.
await new Promise((res, rej) => {
server.once('error', rej);
server.listening ? res() : server.once('listening', res);
});
setLead('leadagent', { actor: OWNER_HANDLE, receiptsPath });
return { server, port: server.address().port, receiptsPath,
shut: () => new Promise((r) => server.close(r)) };
}

const mkIntent = (receiptsPath, opts) =>
createIntent({ prompt: 't', session: 's', channels: [], timeoutSec: 1,
receiptsPath, ...opts });

async function decide(port, id, token, body = { decision: 'approve' }) {
const r = await fetch(`http://127.0.0.1:${port}/intent/${id}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
});
return { status: r.status, body: await r.json() };
}

test('the lead may clear another agent routine work', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath, { fromHandle: 'otheragent' });
const r = await decide(t.port, i.id ?? i, LEAD);
assert.equal(r.status, 200, 'delegation does not work at all');
} finally { await t.shut(); }
});

test('the lead may NOT decide its own request', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath, { fromHandle: 'leadagent' });
const r = await decide(t.port, i.id ?? i, LEAD);
assert.equal(r.status, 403, 'a lead self-approved');
assert.match(r.body.error, /own request/, 'refused, but for the wrong reason');
} finally { await t.shut(); }
});

test('requiresHuman is never delegable', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath,
{ fromHandle: 'otheragent', requiresHuman: true });
const r = await decide(t.port, i.id ?? i, LEAD);
assert.equal(r.status, 403, 'the lead decided an owner-only action');
assert.match(r.body.error, /reserved for/, 'refused, but for the wrong reason');
} finally { await t.shut(); }
});

test('an agent that is not the lead cannot decide', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath, { fromHandle: 'otheragent' });
const r = await decide(t.port, i.id ?? i, OTHER);
assert.equal(r.status, 403, 'any authenticated agent could decide');
} finally { await t.shut(); }
});

test('the owner may still decide an owner-only action', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath,
{ fromHandle: 'otheragent', requiresHuman: true });
const r = await decide(t.port, i.id ?? i, OWNER);
assert.equal(r.status, 200, 'the owner was locked out of their own approvals');
} finally { await t.shut(); }
});

test('an unproven caller cannot claim an actor', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath, { fromHandle: 'otheragent' });
const r = await decide(t.port, i.id ?? i, undefined,
{ decision: 'approve', actor: 'petrus' });
assert.equal(r.status, 403, 'impersonation by assertion is open');
assert.match(r.body.error, /proves no identity/);
} finally { await t.shut(); }
});

test('once principals exist, anonymous is not the owner', async () => {
const t = await boot();
try {
const i = await mkIntent(t.receiptsPath, { fromHandle: 'otheragent' });
const r = await decide(t.port, i.id ?? i, 'tok-not-registered');
assert.equal(r.status, 403, 'the anonymous-is-owner fallback is reachable');
assert.match(r.body.error, /identifies callers/);
} finally { await t.shut(); }
});
78 changes: 78 additions & 0 deletions test/automation-fail-closed.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Seeding must FAIL CLOSED.
//
// @codexmb, reviewing the published module: "First-run fetch failures return []
// and seeding proceeds, allowing the first successful poll to treat historical
// messages as new." On a path that can execute /lead or /approve, a dropped
// packet at startup could replay privileged history.
//
// The linchpin is that a failed read is DISTINGUISHABLE from an empty room.
// Everything above it -- the `ready` gate, the retry, refusing to dispatch --
// depends on this one function returning null rather than [].
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { fetchRoomMessages } from '../src/room-automation.mjs';

async function withServer(handler, run) {
const server = createServer(handler);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
try { return await run(port); } finally { await new Promise((r) => server.close(r)); }
}

test('an HTTP error is null, NOT an empty room', async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response('nope', { status: 500 });
try {
const out = await fetchRoomMessages('r', 'k');
assert.equal(out, null, 'a 500 must not look like "the room is quiet"');
} finally { globalThis.fetch = realFetch; }
});

test('a network failure is null, NOT an empty room', async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
try {
assert.equal(await fetchRoomMessages('r', 'k'), null);
} finally { globalThis.fetch = realFetch; }
});

test('a genuinely empty room is [], which is different', async () => {
// The positive control. Without it, a function that always returned null
// would pass both tests above and break the product completely.
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ messages: [] }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
try {
assert.deepEqual(await fetchRoomMessages('r', 'k'), [], 'an empty room is an answer, not a failure');
} finally { globalThis.fetch = realFetch; }
});

test('messages come back when the room has them', async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ messages: [{ id: 'a' }] }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
try {
assert.deepEqual(await fetchRoomMessages('r', 'k'), [{ id: 'a' }]);
} finally { globalThis.fetch = realFetch; }
});

test('the API key travels in a header, never on a command line', async () => {
// It used to be interpolated into `curl -H "X-API-Key: ${apiKey}"` under
// execSync, which puts the credential in the process table for anyone running
// ps -- and this repo's own rule is that keys never go inline in a shell.
await withServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ messages: [], sawKey: req.headers['x-api-key'] }));
}, async (port) => {
let seenHeader = null;
const realFetch = globalThis.fetch;
globalThis.fetch = async (url, init) => { seenHeader = init?.headers?.['X-API-Key']; return realFetch(`http://127.0.0.1:${port}/`, init); };
try {
await fetchRoomMessages('r', 'secret-key-value');
assert.equal(seenHeader, 'secret-key-value', 'the key must be passed as a header');
} finally { globalThis.fetch = realFetch; }
});
});
51 changes: 51 additions & 0 deletions test/automation-module-wiring.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// The room automation exists TWICE under the same filename:
// src/room-automation.mjs the one the tests import
// src/team-relay/room-automation.mjs the one bin/cli.mjs used to import
//
// On 2026-09-18 the /lead chat command was written into the first and the
// watcher loaded the second. Every unit test passed, the daemon answered, the
// process was restarted -- and petrus got silence twice, because the handler
// was in a file nothing loaded. A green suite against an unloaded module is
// not evidence about the product.
//
// This test asserts the entry points and the tests agree on WHICH module runs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const read = (p) => readFileSync(join(root, p), 'utf8');

test('bin/cli.mjs loads the room-automation module that exports the /lead handler', async () => {
const cli = read('bin/cli.mjs');
const m = /import\s*\{[^}]*startRoomAutomation[^}]*\}\s*from\s*'([^']+)'/.exec(cli);
assert.ok(m, 'cli.mjs does not import startRoomAutomation at all');
const spec = m[1];

// Resolve what cli.mjs actually loads, and require the handler to be in it.
const mod = await import(new URL(spec.replace(/^\.\.\//, '../'), import.meta.url).href)
.catch(async () => import(join(root, spec.replace(/^\.\.\//, ''))));
assert.ok(
typeof mod.handleLeadCommand === 'function',
`cli.mjs imports ${spec}, which does not export handleLeadCommand — ` +
'the chat command would be unreachable no matter how many tests pass',
);
});

test('every re-export of startRoomAutomation points at the same module', () => {
const cli = read('bin/cli.mjs');
const idx = read('src/team-relay/index.mjs');
const specOf = (src) => (/startRoomAutomation[^}]*\}\s*from\s*'([^']+)'/.exec(src) || [])[1];
const a = specOf(cli);
const b = specOf(idx);
assert.ok(a && b, 'could not find both specifiers');
// Different relative depths, same target file.
const norm = (s) => s.replace(/^\.\.\//, '').replace(/^\.\//, '').replace(/^src\//, '');
assert.equal(
norm(a), norm(b),
`bin/cli.mjs loads ${a} but src/team-relay/index.mjs re-exports ${b} — ` +
'two entry points, two different modules, is exactly how /lead was lost',
);
});
Loading
Loading