Skip to content

Commit b1ba74e

Browse files
committed
fix(approval): settle retried and resumed approval decisions
1 parent 0ef5452 commit b1ba74e

8 files changed

Lines changed: 551 additions & 37 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,13 @@ import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
132132
import type { ReactorEmittedEvent } from "@intx/inference";
133133
import { setAgentSourceUnlessClosed } from "../tui/agent-source-sync.js";
134134
import { ensureFreshInferenceSource } from "../subagent/refresh-inference-source.js";
135-
import { getToolApprovalBudget } from "../tui/tool-execution-watchdog.js";
135+
import {
136+
MAX_TOOL_APPROVAL_PAUSE_MS,
137+
getToolApprovalBudget,
138+
} from "../tui/tool-execution-watchdog.js";
139+
import { createApprovalDeliverer } from "../tui/approval-delivery.js";
140+
import { createCorrelationAcceptance } from "../tui/correlation-acceptance.js";
141+
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js";
136142
import { WorkflowHost } from "../workflows/host.js";
137143

138144
const logger = getLogger([LOG_NAMESPACE_ROOT, "exec"]);
@@ -968,10 +974,20 @@ export async function runExec(config: Config): Promise<ExecResult> {
968974
// Consume-once gate for the compaction continuation emit: a replayed
969975
// duplicate of an already-answered emission must not re-deliver.
970976
const continuationGate = createContinuationGate();
977+
// Approval-delivery parity with the TUI: the resume routes its deliver
978+
// through the shared acceptance-tracked deliverer, and the stream sink
979+
// below feeds acceptances so a delivered decision settles instead of
980+
// timing out.
981+
const approvalAcceptance = createCorrelationAcceptance();
982+
const approvalDeliverer = createApprovalDeliverer({
983+
deliverToAgent: (message) => activeAgent.deliver(message),
984+
acceptance: approvalAcceptance,
985+
});
971986
// Cycles persist to the context store only on inference.done; the recorder
972987
// keeps the in-flight cycle's text so an errored or aborted turn leaves
973988
// its partial output in partial.jsonl instead of vanishing.
974989
const sink = (event: ReactorEmittedEvent): void => {
990+
approvalAcceptance.observe(event);
975991
// Chat-director reactor events (replacing the former onTasksChange /
976992
// onActivateTools closures). Exec mode has no live task panel or task
977993
// stdout output today (unlike the TUI's chrome zone) — debug logging
@@ -1062,6 +1078,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
10621078
resolveParkedCallId: (correlationId) =>
10631079
resolveParkedCallIdFromStore(activeStorage, correlationId),
10641080
gate: permissionGate,
1081+
deliver: async (message, stillCurrent) => {
1082+
if (!stillCurrent()) return;
1083+
await approvalDeliverer.deliver(message);
1084+
},
10651085
}).handle(sendResult);
10661086
sendCompleted = true;
10671087
runError = runSink.getRunError();
@@ -1307,9 +1327,28 @@ async function promptPermission(
13071327
});
13081328
stderr.write(` [${scopes.length + 1}] Deny\n`);
13091329
const rl = createInterface({ input, output: stderr });
1330+
let timer: ReturnType<typeof setTimeout> | undefined;
13101331
try {
1311-
const line = (await rl.question("Choice: ")).trim();
1312-
const n = Number(line);
1332+
// The reactor bounds an approval suspension (default 1h); the prompt must
1333+
// settle below that so the gate answers instead of the suspension timing
1334+
// out. The deadline reuses the tool-budget approval-pause ceiling.
1335+
const answer = rl.question("Choice: ");
1336+
const expired = new Promise<undefined>((resolve) => {
1337+
timer = setTimeout(() => resolve(undefined), MAX_TOOL_APPROVAL_PAUSE_MS);
1338+
});
1339+
const line = await Promise.race([answer, expired]);
1340+
if (line === undefined) {
1341+
// The deadline won: rl.close() in the finally below does not settle a
1342+
// pending question, so abandon the loser explicitly. It can never
1343+
// resolve after close; the attached settlement keeps it contained (no
1344+
// floating promise) if a future runtime ever settles it.
1345+
void answer.then(
1346+
() => undefined,
1347+
() => undefined,
1348+
);
1349+
return { allow: false, message: APPROVAL_TIMEOUT_RESULT_TEXT };
1350+
}
1351+
const n = Number(line.trim());
13131352
if (!Number.isInteger(n) || n < 1 || n > scopes.length) {
13141353
return { allow: false };
13151354
}
@@ -1322,6 +1361,7 @@ async function promptPermission(
13221361
...(chosen.pattern !== null ? { persist: chosen } : {}),
13231362
};
13241363
} finally {
1364+
if (timer !== undefined) clearTimeout(timer);
13251365
rl.close();
13261366
if (pauseToken !== undefined) budget?.resume(pauseToken);
13271367
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { ToolCall } from "@intx/types/runtime";
3+
4+
import { DenialMemory, stableRequestId } from "./denial-memory.js";
5+
6+
const fetchCall = (id: string, url: string): ToolCall => ({
7+
id,
8+
name: "web_fetch",
9+
arguments: { url, format: "markdown" },
10+
});
11+
12+
describe("stableRequestId", () => {
13+
test("same URL across distinct call ids shares one fingerprint", () => {
14+
expect(
15+
stableRequestId(fetchCall("call_0", "https://example.com/docs"), "/work"),
16+
).toBe(
17+
stableRequestId(fetchCall("call_1", "https://example.com/docs"), "/work"),
18+
);
19+
});
20+
21+
test("different URLs have independent fingerprints", () => {
22+
expect(
23+
stableRequestId(fetchCall("call_0", "https://example.com/docs"), "/work"),
24+
).not.toBe(
25+
stableRequestId(
26+
fetchCall("call_1", "https://example.com/other"),
27+
"/work",
28+
),
29+
);
30+
});
31+
32+
test("scheme/host case and trailing slashes normalize to one fingerprint", () => {
33+
expect(
34+
stableRequestId(fetchCall("call_0", "https://example.com/docs"), "/work"),
35+
).toBe(
36+
stableRequestId(
37+
fetchCall("call_1", "HTTPS://EXAMPLE.COM/docs/"),
38+
"/work",
39+
),
40+
);
41+
});
42+
43+
// URL paths and queries are case-sensitive (RFC 3986): /Docs and /docs are
44+
// distinct resources and must fingerprint distinctly.
45+
test("case-distinct paths and queries have independent fingerprints", () => {
46+
expect(
47+
stableRequestId(fetchCall("call_0", "https://example.com/Docs"), "/work"),
48+
).not.toBe(
49+
stableRequestId(fetchCall("call_1", "https://example.com/docs"), "/work"),
50+
);
51+
expect(
52+
stableRequestId(
53+
fetchCall("call_0", "https://example.com/search?q=ABC"),
54+
"/work",
55+
),
56+
).not.toBe(
57+
stableRequestId(
58+
fetchCall("call_1", "https://example.com/search?q=abc"),
59+
"/work",
60+
),
61+
);
62+
});
63+
64+
test("tool name participates in the fingerprint", () => {
65+
const args = { url: "https://example.com/docs" };
66+
expect(
67+
stableRequestId({ id: "a", name: "web_fetch", arguments: args }, "/work"),
68+
).not.toBe(
69+
stableRequestId(
70+
{ id: "a", name: "web_search", arguments: args },
71+
"/work",
72+
),
73+
);
74+
});
75+
76+
test("same shell command across distinct call ids shares one fingerprint", () => {
77+
const shellCall = (id: string, command: string): ToolCall => ({
78+
id,
79+
name: "run_shell",
80+
arguments: { command },
81+
});
82+
expect(
83+
stableRequestId(shellCall("call_0", "echo alpha && echo beta"), "/work"),
84+
).toBe(
85+
stableRequestId(shellCall("call_1", "echo alpha && echo beta"), "/work"),
86+
);
87+
expect(
88+
stableRequestId(shellCall("call_2", "echo alpha && echo beta"), "/work"),
89+
).not.toBe(stableRequestId(shellCall("call_3", "echo gamma"), "/work"));
90+
});
91+
});
92+
93+
describe("DenialMemory", () => {
94+
test("a same-fingerprint retry returns the cached reason", () => {
95+
const memory = new DenialMemory();
96+
const first = stableRequestId(
97+
fetchCall("call_0", "https://example.com/docs"),
98+
"/work",
99+
);
100+
const retry = stableRequestId(
101+
fetchCall("call_1", "https://example.com/docs"),
102+
"/work",
103+
);
104+
expect(memory.isDenied(first)).toBeUndefined();
105+
memory.record(first, "denied: needs approval");
106+
expect(memory.isDenied(retry)).toBe("denied: needs approval");
107+
});
108+
109+
test("distinct fingerprints are independent", () => {
110+
const memory = new DenialMemory();
111+
memory.record(
112+
stableRequestId(fetchCall("call_0", "https://example.com/a"), "/work"),
113+
"denied: needs approval",
114+
);
115+
expect(
116+
memory.isDenied(
117+
stableRequestId(fetchCall("call_1", "https://example.com/b"), "/work"),
118+
),
119+
).toBeUndefined();
120+
});
121+
122+
test("first recorded reason wins; clear forgets every denial", () => {
123+
const memory = new DenialMemory();
124+
const stableId = stableRequestId(
125+
fetchCall("call_0", "https://example.com/docs"),
126+
"/work",
127+
);
128+
memory.record(stableId, "first");
129+
memory.record(stableId, "second");
130+
expect(memory.isDenied(stableId)).toBe("first");
131+
memory.clear();
132+
expect(memory.isDenied(stableId)).toBeUndefined();
133+
});
134+
});

‎src/permission/denial-memory.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Stable denial fingerprint for the permission gate's denial memory.
2+
//
3+
// The reactor retries a denied ask-tier call with a fresh tool_call.id; the
4+
// fingerprint must be stable across those retries (same tool + same normalized
5+
// arguments) so the second decide() returns the identical cached reason instead
6+
// of re-evaluating and re-logging. call.id and correlationId never participate.
7+
8+
import type { ToolCall } from "@intx/types/runtime";
9+
10+
import { normalizePathArguments } from "../plugins/path-escape-plugin.js";
11+
import { splitChainedCommand } from "./command.js";
12+
import type { RootsProvider } from "./worktree-roots.js";
13+
14+
const NULL_SEPARATOR = "\0";
15+
16+
// URL paths and queries are case-sensitive (RFC 3986): only the scheme and
17+
// host normalize. The path, query, and fragment keep their case so distinct
18+
// resources fingerprint distinctly.
19+
function normalizeUrl(value: unknown): unknown {
20+
if (typeof value !== "string") return value;
21+
const trimmed = value.trim();
22+
const match = /^(https?:\/\/)([^/?#]*)([\s\S]*)$/i.exec(trimmed);
23+
if (match === null) return value;
24+
const scheme = match[1] ?? "";
25+
const authority = match[2] ?? "";
26+
const rest = match[3] ?? "";
27+
// Userinfo (rarely present) is case-sensitive; only the host lowercases.
28+
const at = authority.lastIndexOf("@");
29+
const userinfo = at === -1 ? "" : authority.slice(0, at + 1);
30+
const host = (at === -1 ? authority : authority.slice(at + 1)).toLowerCase();
31+
return `${scheme.toLowerCase()}${userinfo}${host}${rest}`.replace(/\/+$/, "");
32+
}
33+
34+
function normalizeArguments(
35+
args: Record<string, unknown>,
36+
toolName: string,
37+
): Record<string, unknown> {
38+
const out: Record<string, unknown> = {};
39+
for (const [key, value] of Object.entries(args)) {
40+
if (key === "url") {
41+
out[key] = normalizeUrl(value);
42+
} else if (toolName === "run_shell" && key === "command") {
43+
out[key] = typeof value === "string" ? splitChainedCommand(value) : value;
44+
} else {
45+
out[key] = value;
46+
}
47+
}
48+
return out;
49+
}
50+
51+
export function stableRequestId(
52+
call: ToolCall,
53+
cwd: string = process.cwd(),
54+
rootsProvider: RootsProvider = () => [],
55+
): string {
56+
const identity = normalizePathArguments(call.arguments, cwd, rootsProvider);
57+
return (
58+
call.name +
59+
NULL_SEPARATOR +
60+
JSON.stringify(normalizeArguments(identity, call.name))
61+
);
62+
}
63+
64+
export class DenialMemory {
65+
private readonly reasons = new Map<string, string>();
66+
67+
record(stableId: string, reason: string): void {
68+
if (!this.reasons.has(stableId)) this.reasons.set(stableId, reason);
69+
}
70+
71+
isDenied(stableId: string): string | undefined {
72+
return this.reasons.get(stableId);
73+
}
74+
75+
clear(): void {
76+
this.reasons.clear();
77+
}
78+
}

0 commit comments

Comments
 (0)