From 75b41f4d41c956c7450cf8f7ea0cdc77fdf5e826 Mon Sep 17 00:00:00 2001 From: claudemm Date: Fri, 18 Sep 2026 10:22:09 +0300 Subject: [PATCH] Add POST /intent/:id/expire so a timed-out gate settles its own intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PreToolUse gate waits IAK_GATE_TIMEOUT for a human, then proceeds under IAK_GATE_DEFAULT=allow and POSTs /intent//expire so the request stops sitting in the queue. The daemon has never implemented that route, so every timed-out intent stayed `pending` for ever: on 2026-09-18 there were 31 of them, the oldest created three minutes after the running daemon started, each one a command that had already executed and a button that could no longer change anything. expireIntent is deliberately not decideIntent: - a human approve/deny that arrived first is left untouched, and the call reports the existing decision rather than overwriting it; - it never runs an action. decideIntent executes an action bound to the intent, and a timeout is not consent to do that. waitForDecision also reported an expiry as {status:'decided', decision:null}, which is how a caller ends up believing someone approved something. It now reports {status:'expired'}. Tests cover: settles without deciding, leaves the pending queue, idempotent, never overrides a human decision, releases waiters, unknown id, and the HTTP route including its 404. This makes the buttons honest. It does NOT address the larger point that the gate notifies a human about commands it will allow regardless — that is a policy decision, raised separately. Co-Authored-By: Claude Opus 5 --- src/confirmations.mjs | 65 ++++++++++++++++++++++++++++++++++ test/confirmations.test.mjs | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/confirmations.mjs b/src/confirmations.mjs index b56ebfa..9243e18 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -315,6 +315,45 @@ export function getIntent(id) { }; } +// Settle an intent that timed out while nobody answered it. +// +// The PreToolUse gate waits IAK_GATE_TIMEOUT for a human, then proceeds under +// IAK_GATE_DEFAULT=allow and calls this so the intent stops sitting in the +// queue. Without it the request stays `pending` for ever: the phone shows a +// list of commands that already ran, and every later tap is a no-op on a +// decision that can no longer change anything. (2026-09-18: 31 such entries +// had accumulated, the oldest three minutes after the daemon started.) +// +// Deliberately NOT decideIntent(): +// - a real human approve/deny must win, so an already-decided intent is left +// exactly as it is; +// - expiry must never run an action. decideIntent() looks for an action bound +// to the intent and executes it; a timeout is not consent to do that. +export function expireIntent(id, { timeoutSec, receiptsPath } = {}) { + const i = intents.get(id); + if (!i) return { ok: false, error: `unknown intent ${id}` }; + if (i.status === 'expired') return { ok: true, idempotent: true, status: 'expired' }; + if (i.status !== 'pending') { + // A human got there first. Their decision stands; say so rather than + // quietly overwriting it. + return { ok: true, noop: true, status: i.status, decision: i.decision }; + } + i.status = 'expired'; + i.decidedAt = Date.now(); + i.decision = null; + i.timeoutSec = timeoutSec ?? null; + postReceipt(receiptsPath, { + kind: 'intent.expired', id, timeoutSec: i.timeoutSec, + expiredAt: i.decidedAt, prompt: i.prompt, + }); + // Release anything still waiting so it does not hang on a settled intent. + for (const r of i.resolvers) { + try { r({ decision: null, expired: true, id }); } catch {} + } + i.resolvers = []; + return { ok: true, status: 'expired', timeoutSec: i.timeoutSec }; +} + // Decide an intent. Returns true if decided, false if id unknown or already // decided. Idempotent for same decision; rejects different decision after // settle. @@ -577,6 +616,9 @@ export function waitForDecision(id, { timeoutMs }) { }, timeoutMs); const resolverWithCleanup = (val) => { clearTimeout(timer); + // An expiry is not a decision. Reporting it as `decided` with a null + // decision is how a caller ends up believing someone approved something. + if (val.expired) { resolve({ status: 'expired', decision: null }); return; } resolve({ status: 'decided', decision: val.decision }); }; i.resolvers.push(resolverWithCleanup); @@ -939,6 +981,29 @@ export function startConfirmationsServer({ }); return; } + const expireMatch = url.pathname.match(/^\/intent\/([^/]+)\/expire$/); + if (req.method === 'POST' && expireMatch) { + const id = expireMatch[1]; + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let payload = {}; + if (body) { + try { payload = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'invalid json' })); + return; + } + } + const result = expireIntent(id, { + timeoutSec: payload.timeout_sec ?? payload.timeoutSec ?? null, + receiptsPath, + }); + res.writeHead(result.ok ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }); + return; + } if (req.method === 'GET' && url.pathname === '/intents') { // ?status=pending narrows the list. Without it the queue returns every // intent ever created, decided ones included, so the phone shows a list diff --git a/test/confirmations.test.mjs b/test/confirmations.test.mjs index de3f6c2..590a39d 100644 --- a/test/confirmations.test.mjs +++ b/test/confirmations.test.mjs @@ -9,6 +9,7 @@ import { createIntent, decideIntent, getIntent, + expireIntent, waitForDecision, listIntents, startConfirmationsServer, @@ -1052,3 +1053,72 @@ test('the HTML queue renders the server wording, and never calls an unknown outc server.close(); } }); + + +// --- expiry: a timed-out gate must settle its own intent ------------------- +// Regression cover for 2026-09-18: the PreToolUse gate called +// POST /intent/:id/expire on timeout-allow, the daemon had no such route, and +// 31 already-executed commands sat `pending` with live buttons. + +test('expireIntent settles a pending intent without deciding it', () => { + _resetForTests(); + return createIntent({ prompt: 'p', announce: async () => {} }).then((id) => { + const r = expireIntent(id, { timeoutSec: 120 }); + assert.equal(r.ok, true); + assert.equal(r.status, 'expired'); + const i = listIntents().find((x) => x.id === id); + assert.equal(i.status, 'expired'); + // Expiry is not consent: no approve/deny may be recorded. + assert.equal(i.decision, null); + // And it leaves the pending queue, which is the whole point. + assert.equal(listIntents().filter((x) => x.status === 'pending').length, 0); + }); +}); + +test('expireIntent is idempotent and never overrides a human decision', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + assert.equal(expireIntent(id).ok, true); + assert.equal(expireIntent(id).idempotent, true); + + const decided = await createIntent({ prompt: 'q', announce: async () => {} }); + decideIntent(decided, 'deny'); + const r = expireIntent(decided, { timeoutSec: 120 }); + assert.equal(r.ok, true); + assert.equal(r.noop, true); + // The human's deny stands. + assert.equal(listIntents().find((x) => x.id === decided).decision, 'deny'); +}); + +test('expireIntent releases waiters instead of leaving them hanging', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + const wait = waitForDecision(id, { timeoutMs: 1500 }); + expireIntent(id, { timeoutSec: 1 }); + const r = await wait; + assert.equal(r.status, 'expired'); +}); + +test('expireIntent rejects an unknown id', () => { + _resetForTests(); + assert.equal(expireIntent('nope').ok, false); +}); + +test('HTTP POST /intent/:id/expire settles it, 404s an unknown id', async () => { + _resetForTests(); + httpServer = httpServer || startConfirmationsServer({ port: TEST_PORT, host: '127.0.0.1' }); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + const ok = await fetch(`http://127.0.0.1:${TEST_PORT}/intent/${id}/expire`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ timeout_sec: 120 }), + }); + assert.equal(ok.status, 200); + assert.equal((await ok.json()).status, 'expired'); + assert.equal(listIntents().find((x) => x.id === id).status, 'expired'); + + const missing = await fetch(`http://127.0.0.1:${TEST_PORT}/intent/nope/expire`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + assert.equal(missing.status, 404); +});