Skip to content

Commit 45f92e3

Browse files
committed
fix(runtime-host): isolate shared approval waiters
Generated-by: OpenAI Codex
1 parent ac6acc0 commit 45f92e3

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,16 @@ const IDENTITY = Object.freeze({
6464
clientInstanceId: 'desktop-client-secret',
6565
});
6666

67-
test('cancels pending managed admission and retries with the canonical provider identity', async () => {
67+
test('cancels managed approval owners and joiners with the canonical provider identity', async () => {
6868
await withStore(async ({ store }) => {
6969
const order: string[] = [];
70+
const toolCallByInvocation = new Map<string, string>();
71+
const cancelledToolCalls: string[] = [];
72+
let acceptedCount = 0;
73+
let resolveThirdAccepted!: () => void;
74+
const thirdAccepted = new Promise<void>((resolve) => {
75+
resolveThirdAccepted = resolve;
76+
});
7077
const interactions = createInteractionCoordinator(store);
7178
const runOwner = interactions.bindRun(RUN);
7279
const capabilities = new HostClientCapabilityCoordinator({
@@ -79,7 +86,10 @@ test('cancels pending managed admission and retries with the canonical provider
7986
connection = capabilities.attachConnection(IDENTITY, {
8087
send: async (frame) => {
8188
if (frame.kind === 'client.capability.call') {
89+
toolCallByInvocation.set(frame.invocationId, frame.toolCallId);
8290
order.push('accepted');
91+
acceptedCount += 1;
92+
if (acceptedCount === 3) resolveThirdAccepted();
8393
connection.accept({
8494
kind: 'client.capability.accepted',
8595
invocationId: frame.invocationId,
@@ -88,6 +98,9 @@ test('cancels pending managed admission and retries with the canonical provider
8898
url: 'https://example.com/private?token=secret',
8999
},
90100
});
101+
} else if (frame.kind === 'client.capability.cancel') {
102+
const toolCallId = toolCallByInvocation.get(frame.invocationId);
103+
if (toolCallId) cancelledToolCalls.push(toolCallId);
91104
} else if (frame.kind === 'client.capability.admitted') {
92105
order.push('admitted');
93106
connection.accept({
@@ -202,6 +215,31 @@ test('cancels pending managed admission and retries with the canonical provider
202215
assert.equal(retryRequest.request.kind, 'client_capability');
203216
if (retryRequest.request.kind !== 'client_capability') return;
204217

218+
const joinedCaller = new AbortController();
219+
const joined = runtime.settleToolCall({
220+
tool,
221+
turnId: RUN.turnId,
222+
stepId: 'step-3',
223+
toolCallId: 'browser-call-3',
224+
input: {},
225+
abortSignal: joinedCaller.signal,
226+
eventSink: {
227+
push: () => undefined,
228+
pushAndWaitUntilConsumed: async () => undefined,
229+
},
230+
});
231+
await thirdAccepted;
232+
assert.deepEqual(
233+
(await store.listSessionPending(RUN.sessionId)).map((pending) => pending.requestId),
234+
[retryRequest.requestId],
235+
);
236+
237+
joinedCaller.abort(new DOMException('Joined caller stopped', 'AbortError'));
238+
await joined;
239+
assert.deepEqual(cancelledToolCalls, ['browser-call-1', 'browser-call-3']);
240+
assert.deepEqual(order, ['accepted', 'accepted', 'accepted']);
241+
assert.equal((await store.readInteraction(retryRequest.requestId))?.outcome, undefined);
242+
205243
const answered = await interactions.handlers['interaction.answer'](
206244
{
207245
sessionId: RUN.sessionId,
@@ -219,7 +257,7 @@ test('cancels pending managed admission and retries with the canonical provider
219257
});
220258
assert.equal(grant?.providerId, providerId);
221259
assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] });
222-
assert.deepEqual(order, ['accepted', 'accepted', 'approved', 'T1', 'admitted']);
260+
assert.deepEqual(order, ['accepted', 'accepted', 'accepted', 'approved', 'T1', 'admitted']);
223261
} finally {
224262
snapshot?.release();
225263
await connection.close();

packages/runtime-host/src/server/client-capability-coordinator.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1143,7 +1143,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
11431143
clientCapabilityScopeIdentity(target.scope),
11441144
].join('\0');
11451145
const existing = this.#pendingApprovals.get(key);
1146-
if (existing) return existing;
1146+
if (existing) return waitForClientCapabilityApproval(existing, input.callerSignal);
11471147
const pending = this.#interactions
11481148
.requestClientCapabilityApproval({
11491149
sessionId: input.sessionId,
@@ -1635,6 +1635,42 @@ function clientCapabilityOwnerIdentitiesEqual(
16351635
);
16361636
}
16371637

1638+
function waitForClientCapabilityApproval(
1639+
approval: Promise<'allow' | 'deny'>,
1640+
callerSignal: AbortSignal | undefined,
1641+
): Promise<'allow' | 'deny'> {
1642+
if (!callerSignal) return approval;
1643+
if (callerSignal.aborted) {
1644+
return Promise.reject(clientCapabilityCallerAbortError(callerSignal));
1645+
}
1646+
return new Promise((resolve, reject) => {
1647+
const cleanup = (): void => callerSignal.removeEventListener('abort', onAbort);
1648+
const onAbort = (): void => {
1649+
cleanup();
1650+
reject(clientCapabilityCallerAbortError(callerSignal));
1651+
};
1652+
callerSignal.addEventListener('abort', onAbort, { once: true });
1653+
void approval.then(
1654+
(decision) => {
1655+
cleanup();
1656+
resolve(decision);
1657+
},
1658+
(error) => {
1659+
cleanup();
1660+
reject(error);
1661+
},
1662+
);
1663+
});
1664+
}
1665+
1666+
function clientCapabilityCallerAbortError(signal: AbortSignal): Error {
1667+
const reason = signal.reason;
1668+
if (reason instanceof Error) return reason;
1669+
return new Error(typeof reason === 'string' ? reason : 'Client Capability caller cancelled', {
1670+
...(reason === undefined ? {} : { cause: reason }),
1671+
});
1672+
}
1673+
16381674
function canonicalJson(value: unknown): string {
16391675
if (value === null || typeof value === 'boolean' || typeof value === 'number') {
16401676
return JSON.stringify(value);

0 commit comments

Comments
 (0)