diff --git a/test/real-agent-demo.mjs b/scripts/real-agent-demo.mjs similarity index 83% rename from test/real-agent-demo.mjs rename to scripts/real-agent-demo.mjs index 906e8b9..848f2b6 100644 --- a/test/real-agent-demo.mjs +++ b/scripts/real-agent-demo.mjs @@ -1,4 +1,7 @@ // Real MCP-driven request_confirmation demo. +// NOT a test: it posts a REAL confirmation card to the room and waits up to +// 10 min. It lived in test/, where a bare `node --test` picked it up and sent +// petrus two live "deploy to production?" cards on 7 Oct 2026. Run by hand. // // Acts as a Claude Code agent: spawns the IAK MCP server, calls // request_confirmation as it would in production, blocks until the user diff --git a/src/confirmations.mjs b/src/confirmations.mjs index 01d3d26..260f46e 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -2210,6 +2210,15 @@ export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log, own // to owners is the fix, silence never is. if (!ownerSet.has(sender)) { emit(`${text} from ${m.from}: sender is not the owner — ignoring`); + // Same multi-poller rule as the unknown-intent branch below: only + // the poller that HOLDS the intent may tell anyone it failed. On + // 7 Oct 2026 petrus approved 2806f59b; the Mini's poller (which + // holds it, and lists him as owner) settled it, while this + // MacBook's poller (no owners configured, never held the id) + // answered "NOT recorded" in the room. Refusing is right; the + // public failure notice from a poller that does not own the + // intent is a false alarm. + if (!getIntent(match[2])) continue; // Answer only senders who plausibly ARE the owner (`petrus`, // `petrus-boox`, a future `petrus-watch`). claudeMB's review caught // that replying to everything amplifies the very misbehaviour this diff --git a/test/confirmations.test.mjs b/test/confirmations.test.mjs index de3f6c2..3206a3c 100644 --- a/test/confirmations.test.mjs +++ b/test/confirmations.test.mjs @@ -1052,3 +1052,37 @@ test('the HTML queue renders the server wording, and never calls an unknown outc server.close(); } }); + +test('chat-reply poller: a non-owner /approve for an intent held elsewhere stays silent', async () => { + // 7 Oct 2026: a second poller with no owners and no such intent posted + // "NOT recorded" over an approval the owning poller had already settled. + _resetForTests(); + const batches = [ + { messages: [] }, + { messages: [ + { id: 'x1', from: 'petrus', body: '/approve 2806f59b', isHuman: true }, + ] }, + ]; + let call = 0; + const posts = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + if (opts && opts.method === 'POST') { posts.push(JSON.parse(opts.body)); return { ok: true, json: async () => ({}) }; } + const batch = batches[Math.min(call++, batches.length - 1)]; + return { ok: true, json: async () => batch }; + }; + const lines = []; + const handle = startChatReplyPoller({ + apiKey: 'k', room: 'r', intervalMs: 10, + owners: [], + log: (m) => lines.push(m), + }); + try { + await new Promise((r) => setTimeout(r, 120)); + } finally { + clearInterval(handle); + globalThis.fetch = originalFetch; + } + assert.equal(posts.length, 0, 'no room reply for an intent this poller does not hold'); + assert.match(lines.join('\n'), /not the owner/, 'the refusal is still logged'); +});