Skip to content

Commit 8baf6aa

Browse files
Merge pull request #1519 from corbitsdev/cl-10852-deliver-approvals-for-parked-calls-in-queue-drained-turns
fix(tui): deliver approvals for parked calls in queue-drained turns
2 parents 99f67eb + de08ca8 commit 8baf6aa

7 files changed

Lines changed: 686 additions & 13 deletions

File tree

‎src/permission/reactor-authorize.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type WorkerGrantStore,
3232
} from "./worker-grant.js";
3333
import { canonicalToolName } from "../agent/canonical-tool-name.js";
34+
import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
3435
import { getSubAgentIdentity } from "../subagent/identity-context.js";
3536
import {
3637
FLEET_VERBS,
@@ -100,6 +101,23 @@ function resolveWorkerCwd(cwd: string | undefined): string {
100101
return cwd ?? getSubAgentIdentity()?.cwd ?? process.cwd();
101102
}
102103

104+
/**
105+
* The call as DynamicToolRunner dispatches it: canonical engine name plus
106+
* hidden-alias argument coercion (gpt `shell` -> run_shell via
107+
* coerceShellArgs). Authorize sees the raw wire call; the middleware that
108+
* consumes an authorized retry sees the dispatched call. Keying both on the
109+
* dispatched shape keeps one fingerprint per executed call. Invalid hidden
110+
* arguments never dispatch, so they keep their raw shape.
111+
*/
112+
function dispatchedCall(call: ToolCallType): ToolCallType {
113+
const engine = canonicalToolName(call.name);
114+
try {
115+
return prepareDispatchedToolCall(call, engine);
116+
} catch {
117+
return { ...call, name: engine };
118+
}
119+
}
120+
103121
function workerCallIdentity(
104122
sessionId: string,
105123
call: ToolCallType,
@@ -110,10 +128,11 @@ function workerCallIdentity(
110128
args: Record<string, unknown>;
111129
cwd: string;
112130
} {
131+
const dispatched = dispatchedCall(call);
113132
return {
114133
sessionId,
115-
canonicalTool: canonicalToolName(call.name),
116-
args: (call.arguments ?? {}) as Record<string, unknown>,
134+
canonicalTool: dispatched.name,
135+
args: (dispatched.arguments ?? {}) as Record<string, unknown>,
117136
cwd,
118137
};
119138
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// gpt-family workers call run_shell as `shell`. Reactor authz sees that raw
2+
// wire call; the permission middleware sees DynamicToolRunner's
3+
// prepareDispatchedToolCall output, whose coerceShellArgs rebuilds the
4+
// arguments. The authorized retry must match the same envelope at both
5+
// stages.
6+
import { describe, expect, test } from "bun:test";
7+
import type { ToolCall } from "@intx/types/runtime";
8+
9+
import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
10+
import { gateToolCall } from "../plugins/permission-plugin.js";
11+
import { createPermissionGate } from "./gate.js";
12+
import { workerPermissionGate } from "./reactor-authorize.js";
13+
import { WorkerGrantStore } from "./worker-grant.js";
14+
15+
const CWD = process.cwd();
16+
17+
// The call shape a gpt-family worker sends for run_shell.
18+
const wireCall = (id: string): ToolCall => ({
19+
id,
20+
name: "shell",
21+
arguments: {
22+
command: "git status --short",
23+
timeout: 120000,
24+
cwd: CWD,
25+
background: false,
26+
stop: "",
27+
},
28+
});
29+
30+
function extractRequestId(reason: string): string {
31+
const match = /grant request ([0-9a-f-]{36})/.exec(reason);
32+
if (match?.[1] === undefined) throw new Error(`no grant id: ${reason}`);
33+
return match[1];
34+
}
35+
36+
describe("gpt-family worker authorized retry identity", () => {
37+
test("approved retry of a `shell` call executes once instead of minting a new grant request", async () => {
38+
// Parent: reactor-gated interactive gate, nothing pre-approved, no auto.
39+
const parentGate = createPermissionGate({
40+
approvals: [],
41+
cwd: CWD,
42+
skipPermissions: false,
43+
interactive: true,
44+
reactorGated: true,
45+
auto: false,
46+
requestApproval: async () => ({ allow: false }),
47+
});
48+
const store = new WorkerGrantStore();
49+
const workerGate = workerPermissionGate(parentGate, {
50+
sessionId: "worker-gpt",
51+
store,
52+
});
53+
54+
// 1. First attempt: reactor authz denies and records envelope A.
55+
const denied = await workerGate.authorizeCall(wireCall("call-1"));
56+
if (denied.effect !== "deny") throw new Error("expected worker deny");
57+
const requestId = extractRequestId(denied.reason);
58+
const envelope = store.peek(requestId);
59+
expect(envelope?.canonicalTool).toBe("run_shell");
60+
61+
// 2. approve_worker_call: parent approved the frozen call (allow-once,
62+
// no session grant) and flipped the envelope to authorized.
63+
expect(store.authorizeRetry(requestId)).toBe(true);
64+
65+
// 3. Retry, stage 1: reactor authz on the raw wire call allows via
66+
// hasAuthorizedRetry (does not consume).
67+
const retryAuthz = await workerGate.authorizeCall(wireCall("call-2"));
68+
expect(retryAuthz).toEqual({ effect: "allow" });
69+
expect(store.peek(requestId)?.status).toBe("authorized");
70+
71+
// 4. Retry, stage 2: DynamicToolRunner coerces the `shell` call onto
72+
// run_shell, then the posix middleware runs gateToolCall.
73+
const dispatched = prepareDispatchedToolCall(
74+
wireCall("call-2"),
75+
"run_shell",
76+
);
77+
expect(dispatched.arguments).not.toEqual(wireCall("x").arguments);
78+
79+
let executed = 0;
80+
const result = await gateToolCall(
81+
workerGate,
82+
dispatched,
83+
new AbortController().signal,
84+
async (call) => {
85+
executed++;
86+
return { callId: call.id, content: "ran" };
87+
},
88+
);
89+
90+
expect(result.isError).not.toBe(true);
91+
expect(executed).toBe(1);
92+
expect(store.peek(requestId)?.status).toBe("consumed");
93+
94+
// One grant, one execution: a replay of the same call fails closed at
95+
// both stages, and no second envelope was minted.
96+
const replay = await gateToolCall(
97+
workerGate,
98+
prepareDispatchedToolCall(wireCall("call-3"), "run_shell"),
99+
new AbortController().signal,
100+
async (call) => {
101+
executed++;
102+
return { callId: call.id, content: "ran" };
103+
},
104+
);
105+
expect(replay.isError).toBe(true);
106+
expect(String(replay.content)).toContain("already consumed");
107+
expect((await workerGate.authorizeCall(wireCall("call-4"))).effect).toBe(
108+
"deny",
109+
);
110+
expect(executed).toBe(1);
111+
});
112+
113+
test("an altered retry (different command) still fails closed", async () => {
114+
const parentGate = createPermissionGate({
115+
approvals: [],
116+
cwd: CWD,
117+
skipPermissions: false,
118+
interactive: true,
119+
reactorGated: true,
120+
auto: false,
121+
requestApproval: async () => ({ allow: false }),
122+
});
123+
const store = new WorkerGrantStore();
124+
const workerGate = workerPermissionGate(parentGate, {
125+
sessionId: "worker-gpt-alt",
126+
store,
127+
});
128+
const denied = await workerGate.authorizeCall(wireCall("call-1"));
129+
if (denied.effect !== "deny") throw new Error("expected worker deny");
130+
const requestId = extractRequestId(denied.reason);
131+
expect(store.authorizeRetry(requestId)).toBe(true);
132+
133+
const altered: ToolCall = {
134+
...wireCall("call-2"),
135+
arguments: { ...wireCall("x").arguments, command: "git push --force" },
136+
};
137+
expect((await workerGate.authorizeCall(altered)).effect).toBe("deny");
138+
const result = await gateToolCall(
139+
workerGate,
140+
prepareDispatchedToolCall(altered, "run_shell"),
141+
new AbortController().signal,
142+
async (call) => ({ callId: call.id, content: "ran" }),
143+
);
144+
expect(result.isError).toBe(true);
145+
expect(store.peek(requestId)?.status).toBe("authorized");
146+
});
147+
});

‎src/session/approval-resume.test.ts‎

Lines changed: 147 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
InboundMessage,
1313
} from "@intx/types/runtime";
1414

15+
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js";
1516
import type { PermissionGate } from "../permission/gate.js";
1617
import { createExtraDeniedPathMatcher } from "../plugins/secret-guard-plugin.js";
1718
import {
@@ -23,6 +24,7 @@ import {
2324
resolveSuspendedApprovalFromStore,
2425
} from "./approval-resume.js";
2526
import { createSessionOperationQueue } from "../tui/delivery-queue.js";
27+
import { createParkedOverlayAbortBinding } from "../tui/runner/parked-overlay-abort.js";
2628
import { defined } from "../../testkit/defined.js";
2729
import { redactedCorrelationId } from "./redacted-correlation.js";
2830
import {
@@ -936,6 +938,78 @@ describe("approval identity ordering", () => {
936938
expect(decisionBody(message).outcome).toBe("rejected");
937939
});
938940

941+
test("generation change during catch history drops the parked call once", async () => {
942+
const ctx = deferredResume();
943+
const failure = new Error("gate exploded");
944+
ctx.resolveSuspended.mockImplementation(async () => {
945+
throw failure;
946+
});
947+
ctx.history.mockResolvedValueOnce([]).mockImplementationOnce(async () => {
948+
ctx.invalidate();
949+
return [];
950+
});
951+
const handling = ctx.resume.handle(suspension("corr-A", "echo same"));
952+
ctx.lookup.resolve("call-A");
953+
await expect(handling).rejects.toBe(failure);
954+
expect(ctx.onDropped).toHaveBeenCalledTimes(1);
955+
expect(ctx.onDropped).toHaveBeenCalledWith(APPROVAL_DROPPED_NOTICE);
956+
expect(ctx.deliver).toHaveBeenCalledTimes(1);
957+
const message = ctx.deliver.mock.calls[0]?.[0];
958+
if (message === undefined)
959+
throw new Error("expected cancellation rejection");
960+
expect(deliveredCorrelationId(message)).toBe("corr-A");
961+
expect(decisionBody(message)).toEqual({
962+
outcome: "rejected",
963+
message: APPROVAL_DROPPED_NOTICE,
964+
});
965+
});
966+
967+
test("a gate throw with no generation change rejects once as approval surface failed", async () => {
968+
const ctx = deferredResume();
969+
const failure = new Error("gate exploded");
970+
ctx.resolveSuspended.mockImplementation(async () => {
971+
throw failure;
972+
});
973+
const handling = ctx.resume.handle(suspension("corr-A", "echo same"));
974+
ctx.lookup.resolve("call-A");
975+
await expect(handling).rejects.toBe(failure);
976+
expect(ctx.onDropped).not.toHaveBeenCalled();
977+
expect(ctx.deliver).toHaveBeenCalledTimes(1);
978+
const message = ctx.deliver.mock.calls[0]?.[0];
979+
if (message === undefined) throw new Error("expected surface rejection");
980+
expect(deliveredCorrelationId(message)).toBe("corr-A");
981+
expect(decisionBody(message)).toEqual({
982+
outcome: "rejected",
983+
message: "approval surface failed",
984+
});
985+
});
986+
987+
test("generation change during catch history after a timeout does not reject again", async () => {
988+
const ctx = deferredResume();
989+
const failure = new Error("gate exploded");
990+
ctx.resolveSuspended.mockImplementation(async () => {
991+
throw failure;
992+
});
993+
const reading = Promise.withResolvers<undefined>();
994+
const history = Promise.withResolvers<ConversationTurn[]>();
995+
ctx.history.mockResolvedValueOnce([]).mockImplementationOnce(() => {
996+
reading.resolve(undefined);
997+
return history.promise;
998+
});
999+
const handling = ctx.resume.handle(suspension("corr-A", "echo same"));
1000+
ctx.lookup.resolve("call-A");
1001+
await reading.promise;
1002+
expect(ctx.resolveSuspended).toHaveBeenCalledTimes(1);
1003+
expect(ctx.registered()).toBeUndefined();
1004+
ctx.invalidate();
1005+
history.resolve([timeoutTurn("call-A")]);
1006+
await expect(handling).rejects.toBe(failure);
1007+
expect(ctx.onDropped).toHaveBeenCalledTimes(1);
1008+
expect(ctx.onDropped).toHaveBeenCalledWith(APPROVAL_DROPPED_NOTICE);
1009+
expect(ctx.deliver).not.toHaveBeenCalled();
1010+
expect(ctx.registered()).toBeUndefined();
1011+
});
1012+
9391013
for (const timedOutCallId of ["call-A", "call-B"]) {
9401014
test(`generation change during post-gate history with ${timedOutCallId} timeout preserves exact-call cancellation`, async () => {
9411015
const ctx = deferredResume();
@@ -1192,10 +1266,9 @@ describe("approval resume retry re-await", () => {
11921266
expect(delivered).toHaveLength(1);
11931267
});
11941268

1195-
// Concurrent duplicates share one in-flight rejection (one gate, zero
1196-
// deliveries), then a retry re-gates and delivers exactly once, and later
1197-
// retries memoize.
1198-
test("a shared rejection re-gates once, delivers once, then memoizes", async () => {
1269+
// Concurrent duplicates share one in-flight gate failure (one gate, one
1270+
// rejection delivered), and later retries memoize.
1271+
test("a shared gate failure rejects the parked call once, then memoizes", async () => {
11991272
const gate = Promise.withResolvers<{ allow: boolean }>();
12001273
const { resume, delivered, resolveSuspended } = retryHarness({
12011274
allow: true,
@@ -1210,15 +1283,79 @@ describe("approval resume retry re-await", () => {
12101283
await expect(first).rejects.toBe(failure);
12111284
await expect(second).rejects.toBe(failure);
12121285
expect(resolveSuspended).toHaveBeenCalledTimes(1);
1213-
expect(delivered).toHaveLength(0);
1214-
1215-
expect(await resume.handle(suspension("corr-A", "echo alpha"))).toBe(true);
1216-
expect(resolveSuspended).toHaveBeenCalledTimes(2);
1286+
// Nothing retries a parked call, so the failure answers it.
12171287
expect(delivered).toHaveLength(1);
1218-
expect(decisionBody(onlyDecision(delivered)).outcome).toBe("approved");
1288+
expect(decisionBody(onlyDecision(delivered)).outcome).toBe("rejected");
12191289

12201290
expect(await resume.handle(suspension("corr-A", "echo alpha"))).toBe(true);
1221-
expect(resolveSuspended).toHaveBeenCalledTimes(2);
1291+
expect(resolveSuspended).toHaveBeenCalledTimes(1);
12221292
expect(delivered).toHaveLength(1);
12231293
});
1294+
test("a gate failure after the reactor timed the call out delivers nothing", async () => {
1295+
const delivered: InboundMessage[] = [];
1296+
let timedOut = false;
1297+
const failure = new Error("gate exploded");
1298+
const resume = createApprovalResume({
1299+
getAgent: () => ({
1300+
deliver: (message: InboundMessage) => {
1301+
delivered.push(message);
1302+
},
1303+
history: async () =>
1304+
timedOut
1305+
? ([
1306+
{
1307+
role: "user",
1308+
content: [
1309+
{
1310+
type: "tool_result",
1311+
callId: "call-A",
1312+
content: [
1313+
{ type: "text", text: APPROVAL_TIMEOUT_RESULT_TEXT },
1314+
],
1315+
},
1316+
],
1317+
},
1318+
] as unknown as Awaited<ReturnType<Agent["history"]>>)
1319+
: [],
1320+
}),
1321+
resolveParkedCallId: () => "call-A",
1322+
gate: {
1323+
resolveSuspended: async () => {
1324+
timedOut = true;
1325+
throw failure;
1326+
},
1327+
} as unknown as PermissionGate,
1328+
});
1329+
await expect(
1330+
resume.handle(suspension("corr-A", "echo alpha")),
1331+
).rejects.toBe(failure);
1332+
expect(delivered).toEqual([]);
1333+
});
1334+
});
1335+
1336+
describe("parked overlay registration after the operator answers", () => {
1337+
// approve_worker_call raises a nested permission prompt as soon as its
1338+
// redispatched call starts, while the decision that resumed it is still being
1339+
// delivered. That prompt's abort signal merges the parked overlay controller.
1340+
test("a permission prompt raised while the decision is delivered is not pre-aborted", async () => {
1341+
const binding = createParkedOverlayAbortBinding();
1342+
const identity = new AbortController();
1343+
const { agent, gate } = createApprovalResumeHarness();
1344+
let promptSignal: AbortSignal | undefined;
1345+
const resume = createApprovalResume({
1346+
getAgent: () => agent,
1347+
gate,
1348+
resolveParkedCallId: () => "call-A",
1349+
registerOverlayAbort: binding.registerOverlayAbort,
1350+
deliver: async (message) => {
1351+
promptSignal = binding.identitySignal(identity.signal);
1352+
agent.deliver(message);
1353+
},
1354+
});
1355+
expect(
1356+
await resume.handle(suspension("corr-A", "git status --short")),
1357+
).toBe(true);
1358+
expect(promptSignal).toBeDefined();
1359+
expect(promptSignal?.aborted).toBe(false);
1360+
});
12241361
});

0 commit comments

Comments
 (0)