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
65 changes: 65 additions & 0 deletions src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions test/confirmations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createIntent,
decideIntent,
getIntent,
expireIntent,
waitForDecision,
listIntents,
startConfirmationsServer,
Expand Down Expand Up @@ -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);
});
Loading