From 6265f685eeab8f2cf0876f4a12ee071d7ad83db1 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:59:33 +0800 Subject: [PATCH 01/30] refactor(runtime-host): separate capability acceptance from admission Generated-by: OpenAI Codex --- ...lient-capability-invocation-broker.test.ts | 137 ++++++++++ .../client-capability-invocation-broker.ts | 251 ++++++++++++------ 2 files changed, 310 insertions(+), 78 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts diff --git a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts new file mode 100644 index 0000000000..38c66a639b --- /dev/null +++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ClientCapabilityHostFrame } from '../protocol/index.js'; +import { ClientCapabilityInvocationBroker } from '../server/client-capability-invocation-broker.js'; + +interface Registration { + readonly connectionId: string; + readonly registrationId: string; +} + +const registration: Registration = { + connectionId: 'connection-a', + registrationId: 'registration-a', +}; + +const binding = { + offerId: 'desktop-browser', + hostPathAccess: 'none' as const, + descriptor: { + serverId: 'desktop_browser', + name: 'browser_snapshot', + inputSchema: { type: 'object' }, + }, +}; + +const context = { + sessionId: 'session-a', + turnId: 'turn-a', + toolCallId: 'tool-call-a', + cwd: '/tmp', +}; + +describe('ClientCapabilityInvocationBroker', () => { + test('keeps provider acceptance paused until explicit admission', async () => { + const sent: ClientCapabilityHostFrame[] = []; + let broker!: ClientCapabilityInvocationBroker; + broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ + send: async (frame) => { + sent.push(frame); + if (frame.kind === 'client.capability.call') { + queueMicrotask(() => + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + }), + ); + } + if (frame.kind === 'client.capability.admitted') { + queueMicrotask(() => + broker.accept('connection-a', { + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { content: [{ type: 'text', text: 'ok' }] }, + }), + ); + } + }, + }), + onRegistrationIdle: () => {}, + }); + + const prepared = broker.prepare(registration, binding, {}, context, undefined, 20); + await prepared.waitUntilAccepted(); + await delay(40); + assert.equal( + sent.some((frame) => frame.kind === 'client.capability.admitted'), + false, + ); + + assert.deepEqual(await prepared.admit(), { content: [{ type: 'text', text: 'ok' }] }); + assert.equal(sent.filter((frame) => frame.kind === 'client.capability.admitted').length, 1); + broker.close(); + }); + + test('preserves immediate admission for invoke callers', async () => { + const sent: ClientCapabilityHostFrame[] = []; + let broker!: ClientCapabilityInvocationBroker; + broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ + send: async (frame) => { + sent.push(frame); + if (frame.kind === 'client.capability.call') { + queueMicrotask(() => + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + }), + ); + } + if (frame.kind === 'client.capability.admitted') { + queueMicrotask(() => + broker.accept('connection-a', { + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { content: [{ type: 'text', text: 'ok' }] }, + }), + ); + } + }, + }), + onRegistrationIdle: () => {}, + }); + + assert.deepEqual(await broker.invoke(registration, binding, {}, context, undefined, 1_000), { + content: [{ type: 'text', text: 'ok' }], + }); + assert.deepEqual( + sent.map((frame) => frame.kind), + ['client.capability.call', 'client.capability.admitted', 'client.capability.release'], + ); + broker.close(); + }); +}); + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts index c77bc98bf3..30a22e313c 100644 --- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts +++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts @@ -75,11 +75,15 @@ interface InvocationState void; readonly reject: (error: Error) => void; + readonly resolveAccepted: () => void; + readonly rejectAccepted: (error: Error) => void; readonly signal?: AbortSignal; readonly onAbort?: () => void; readonly onProgress?: (current: number, total: number) => void; - readonly timer: NodeJS.Timeout; - phase: 'dispatched' | 'accepted' | 'chunks'; + readonly timeoutMs: number; + timer: NodeJS.Timeout | undefined; + acceptedSettled: boolean; + phase: 'dispatched' | 'accepted' | 'admitted' | 'chunks'; progress?: { current: number; total: number }; chunks?: { readonly byteLength: number; @@ -89,6 +93,14 @@ interface InvocationState; + /** Crosses the admission cut and returns the provider result. */ + admit(): Promise; +} + export interface ClientCapabilityInvocationBrokerOptions< Registration extends ClientCapabilityInvocationRegistration, > { @@ -109,7 +121,7 @@ export class ClientCapabilityInvocationBroker< this.#onRegistrationIdle = options.onRegistrationIdle; } - invoke( + async invoke( registration: Registration, binding: ClientCapabilityInvocationBinding, args: Record, @@ -118,7 +130,29 @@ export class ClientCapabilityInvocationBroker< timeoutMs: number, onProgress?: (current: number, total: number) => void, ): Promise { - return this.#invoke(registration, signal, timeoutMs, onProgress, (invocationId) => ({ + const prepared = this.prepare( + registration, + binding, + args, + context, + signal, + timeoutMs, + onProgress, + ); + await prepared.waitUntilAccepted(); + return prepared.admit(); + } + + prepare( + registration: Registration, + binding: ClientCapabilityInvocationBinding, + args: Record, + context: ClientCapabilityInvocationContext, + signal: AbortSignal | undefined, + timeoutMs: number, + onProgress?: (current: number, total: number) => void, + ): PreparedClientCapabilityInvocation { + return this.#prepare(registration, signal, timeoutMs, onProgress, (invocationId) => ({ kind: 'client.capability.call', invocationId, registrationId: registration.registrationId, @@ -133,7 +167,7 @@ export class ClientCapabilityInvocationBroker< })); } - invokeService( + async invokeService( registration: Registration, serviceId: string, version: string, @@ -142,7 +176,29 @@ export class ClientCapabilityInvocationBroker< signal: AbortSignal | undefined, timeoutMs: number, ): Promise { - return this.#invoke(registration, signal, timeoutMs, undefined, (invocationId) => ({ + const prepared = this.prepareService( + registration, + serviceId, + version, + method, + input, + signal, + timeoutMs, + ); + await prepared.waitUntilAccepted(); + return prepared.admit(); + } + + prepareService( + registration: Registration, + serviceId: string, + version: string, + method: string, + input: Record, + signal: AbortSignal | undefined, + timeoutMs: number, + ): PreparedClientCapabilityInvocation { + return this.#prepare(registration, signal, timeoutMs, undefined, (invocationId) => ({ kind: 'client.capability.service_call', invocationId, registrationId: registration.registrationId, @@ -153,37 +209,39 @@ export class ClientCapabilityInvocationBroker< })); } - #invoke( + #prepare( registration: Registration, signal: AbortSignal | undefined, timeoutMs: number, onProgress: ((current: number, total: number) => void) | undefined, frameFor: (invocationId: string) => ClientCapabilityHostFrame, - ): Promise { + ): PreparedClientCapabilityInvocation { const sender = this.#senderFor(registration.connectionId); if (!sender) { - return Promise.reject( - new ClientCapabilityInvocationError( - 'capability_lost', - 'Client Capability provider is unavailable', - ), + throw new ClientCapabilityInvocationError( + 'capability_lost', + 'Client Capability provider is unavailable', ); } - if (signal?.aborted) return Promise.reject(abortReason(signal)); + if (signal?.aborted) throw abortReason(signal); const activeForConnection = [...this.#invocations.values()].filter( (invocation) => invocation.registration.connectionId === registration.connectionId, ).length; if (activeForConnection >= MAX_CONCURRENT_INVOCATIONS_PER_CONNECTION) { - return Promise.reject( - new ClientCapabilityInvocationError( - 'provider_overloaded', - 'Client Capability provider has too many active invocations', - ), + throw new ClientCapabilityInvocationError( + 'provider_overloaded', + 'Client Capability provider has too many active invocations', ); } const invocationId = randomUUID(); - return new Promise((resolve, reject) => { + let resolveAccepted!: () => void; + let rejectAccepted!: (error: Error) => void; + const accepted = new Promise((resolve, reject) => { + resolveAccepted = resolve; + rejectAccepted = reject; + }); + const result = new Promise((resolve, reject) => { const onAbort = signal ? () => { const invocation = this.#invocations.get(invocationId); @@ -192,45 +250,32 @@ export class ClientCapabilityInvocationBroker< this.#settle( invocation, undefined, - invocation.phase === 'dispatched' + invocation.phase === 'dispatched' || invocation.phase === 'accepted' ? asError(abortReason(signal)) : new ToolOutcomeUnknownError( - 'Client Capability invocation was cancelled after provider acceptance', + 'Client Capability invocation was cancelled after admission', ), true, ); } : undefined; - const timer = setTimeout(() => { - const invocation = this.#invocations.get(invocationId); - if (!invocation) return; - void sender.send({ kind: 'client.capability.cancel', invocationId }).catch(() => {}); - this.#settle( - invocation, - undefined, - invocation.phase === 'dispatched' - ? new ClientCapabilityInvocationError( - 'timed_out', - 'Client Capability invocation timed out before provider acceptance', - ) - : new ToolOutcomeUnknownError( - 'Client Capability invocation timed out after provider acceptance', - ), - true, - ); - }, timeoutMs); const invocation: InvocationState = { invocationId, registration, resolve, reject, + resolveAccepted, + rejectAccepted, signal, onAbort, onProgress, - timer, + timeoutMs, + timer: undefined, + acceptedSettled: false, phase: 'dispatched', }; this.#invocations.set(invocationId, invocation); + this.#startTimeout(invocation); if (onAbort) signal?.addEventListener('abort', onAbort, { once: true }); void sender.send(frameFor(invocationId)).catch(() => { const current = this.#invocations.get(invocationId); @@ -246,6 +291,51 @@ export class ClientCapabilityInvocationBroker< ); }); }); + // A caller waiting only for provider acceptance still receives the same + // failure through `accepted`; keep the result rejection from becoming + // an unrelated unhandled rejection. + void result.catch(() => {}); + return { + invocationId, + waitUntilAccepted: () => accepted, + admit: async () => { + await accepted; + const invocation = this.#invocations.get(invocationId); + if (!invocation) return result; + if (invocation.phase === 'accepted') { + invocation.phase = 'admitted'; + this.#startTimeout(invocation); + const currentSender = this.#senderFor(invocation.registration.connectionId); + if (!currentSender) { + this.#settle( + invocation, + undefined, + new ClientCapabilityInvocationError( + 'capability_lost', + 'Client Capability provider disappeared before admission', + ), + false, + ); + } else { + void currentSender + .send({ kind: 'client.capability.admitted', invocationId }) + .catch(() => { + const current = this.#invocations.get(invocationId); + if (!current) return; + this.#settle( + current, + undefined, + new ToolOutcomeUnknownError( + 'Client Capability admission acknowledgement could not be delivered', + ), + false, + ); + }); + } + } + return result; + }, + }; } accept(connectionId: string, frame: ClientCapabilityClientFrame): void { @@ -263,36 +353,10 @@ export class ClientCapabilityInvocationBroker< throw new Error('Client Capability invocation was accepted more than once'); } invocation.phase = 'accepted'; - const sender = this.#senderFor(invocation.registration.connectionId); - if (!sender) { - this.#settle( - invocation, - undefined, - new ClientCapabilityInvocationError( - 'capability_lost', - 'Client Capability provider disappeared during acceptance', - ), - false, - ); - return; - } - void sender - .send({ - kind: 'client.capability.admitted', - invocationId: invocation.invocationId, - }) - .catch(() => { - const current = this.#invocations.get(invocation.invocationId); - if (!current) return; - this.#settle( - current, - undefined, - new ToolOutcomeUnknownError( - 'Client Capability acceptance acknowledgement could not be delivered', - ), - false, - ); - }); + if (invocation.timer) clearTimeout(invocation.timer); + invocation.timer = undefined; + invocation.acceptedSettled = true; + invocation.resolveAccepted(); return; } case 'client.capability.rejected': @@ -307,7 +371,7 @@ export class ClientCapabilityInvocationBroker< ); return; case 'client.capability.failed': - if (invocation.phase === 'dispatched') { + if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') { throw new Error('Client Capability failure arrived before acceptance'); } this.#settle( @@ -318,7 +382,7 @@ export class ClientCapabilityInvocationBroker< ); return; case 'client.capability.progress': - if (invocation.phase !== 'accepted' && invocation.phase !== 'chunks') { + if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') { throw new Error('Client Capability progress arrived before acceptance'); } if ( @@ -332,13 +396,13 @@ export class ClientCapabilityInvocationBroker< invocation.onProgress?.(frame.current, frame.total); return; case 'client.capability.result': - if (invocation.phase !== 'accepted') { + if (invocation.phase !== 'admitted') { throw new Error('Client Capability result arrived outside the accepted phase'); } this.#settle(invocation, frame.result, undefined, true); return; case 'client.capability.result_start': - if (invocation.phase !== 'accepted') { + if (invocation.phase !== 'admitted') { throw new Error('Client Capability result chunks started outside the accepted phase'); } invocation.phase = 'chunks'; @@ -360,10 +424,10 @@ export class ClientCapabilityInvocationBroker< this.#settle( invocation, undefined, - invocation.phase === 'dispatched' + invocation.phase === 'dispatched' || invocation.phase === 'accepted' ? new ClientCapabilityInvocationError( 'capability_lost', - 'Client Capability provider disconnected before accepting the call', + 'Client Capability provider disconnected before admission', ) : new ToolOutcomeUnknownError( 'Client Capability provider disconnected after accepting the call', @@ -415,6 +479,29 @@ export class ClientCapabilityInvocationBroker< this.#settle(invocation, decodeClientCapabilityResult(decoded), undefined, true); } + #startTimeout(invocation: InvocationState): void { + if (invocation.timer) clearTimeout(invocation.timer); + invocation.timer = setTimeout(() => { + const current = this.#invocations.get(invocation.invocationId); + if (!current) return; + const sender = this.#senderFor(current.registration.connectionId); + void sender + ?.send({ kind: 'client.capability.cancel', invocationId: current.invocationId }) + .catch(() => {}); + this.#settle( + current, + undefined, + current.phase === 'dispatched' || current.phase === 'accepted' + ? new ClientCapabilityInvocationError( + 'timed_out', + 'Client Capability invocation timed out before admission', + ) + : new ToolOutcomeUnknownError('Client Capability invocation timed out after admission'), + true, + ); + }, invocation.timeoutMs); + } + #settle( invocation: InvocationState, result: ClientCapabilityCallResult | undefined, @@ -423,7 +510,7 @@ export class ClientCapabilityInvocationBroker< ): void { if (this.#invocations.get(invocation.invocationId) !== invocation) return; this.#invocations.delete(invocation.invocationId); - clearTimeout(invocation.timer); + if (invocation.timer) clearTimeout(invocation.timer); if (invocation.onAbort && invocation.signal) { invocation.signal.removeEventListener('abort', invocation.onAbort); } @@ -437,6 +524,14 @@ export class ClientCapabilityInvocationBroker< }) .catch(() => {}); } + if (!invocation.acceptedSettled) { + invocation.acceptedSettled = true; + if (error) invocation.rejectAccepted(error); + else + invocation.rejectAccepted( + new Error('Client Capability invocation settled before provider acceptance'), + ); + } if (error) invocation.reject(error); else if (result) invocation.resolve(result); else invocation.reject(new Error('Client Capability invocation settled without an outcome')); From 78a6d539c91a4d187d668dd970ad8bb730745eef Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:02:16 +0800 Subject: [PATCH 02/30] feat(runtime-host): carry capability admission evidence Generated-by: OpenAI Codex --- .../main/runtime-host-native-capabilities.ts | 4 +-- packages/cli/src/mcp-capability-provider.ts | 2 +- .../client-capability-channel.test.ts | 16 +++++++--- .../client-capability-coordinator.test.ts | 32 ++++++++++++++++--- ...lient-capability-invocation-broker.test.ts | 2 ++ .../client-capability-protocol.test.ts | 2 ++ .../__tests__/client-capability-uds.test.ts | 4 +-- .../execution-host-continuation.test.ts | 2 +- .../execution-model-composition.test.ts | 1 + .../src/__tests__/oauth-coordinator.test.ts | 1 + .../__tests__/root-turn-coordinator.test.ts | 2 ++ .../src/client/client-capability-channel.ts | 8 +++-- .../src/client/client-capability.ts | 5 +-- .../src/client/oauth-presentation.ts | 2 +- .../src/protocol/client-capability.ts | 31 +++++++++++++++++- .../client-capability-invocation-broker.ts | 11 ++++--- 16 files changed, 99 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index e812492c62..27a84e3843 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -238,7 +238,7 @@ async function invokeAdditionalService( options: Parameters>[1], ): Promise> { options.signal.throwIfAborted(); - await options.accept(); + await options.accept({ kind: "none" }); options.signal.throwIfAborted(); return service.call(frame.method, frame.input, { signal: options.signal }); } @@ -331,7 +331,7 @@ async function invokeNativeTool( const parameters = requireZodSchema(binding.tool); const args = await parameters.parseAsync(frame.arguments); signal.throwIfAborted(); - await options.accept(); + await options.accept({ kind: "none" }); signal.throwIfAborted(); usedSessionIds.add(frame.sessionId); providerOptions.onSessionUsed?.(frame.sessionId); diff --git a/packages/cli/src/mcp-capability-provider.ts b/packages/cli/src/mcp-capability-provider.ts index d1b8d34608..ad79fe7146 100644 --- a/packages/cli/src/mcp-capability-provider.ts +++ b/packages/cli/src/mcp-capability-provider.ts @@ -104,7 +104,7 @@ export function createMcpCapabilityProvider( capabilityBindingKey(frame.offerId, frame.serverId, frame.toolName), ); if (!binding) throw new Error('MCP capability is not part of the published snapshot'); - await options.accept(); + await options.accept({ kind: 'none' }); return projectMcpResult( await manager.callTool(binding, frame.arguments, { signal: options.signal }), ); diff --git a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts index f1cab263cc..fd48cffabb 100644 --- a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts @@ -95,7 +95,7 @@ test('Client Capability channel runs a self-described Host service through admis callService: async (frame, options) => { assert.equal(frame.method, 'present'); assert.equal(accepted, false); - await options.accept(); + await options.accept({ kind: 'none' }); accepted = true; return { kind: 'presented' }; }, @@ -134,7 +134,11 @@ test('Client Capability channel runs a self-described Host service through admis await new Promise((resolve) => setImmediate(resolve)); assert.equal(accepted, true); assert.deepEqual(written, [ - { kind: 'client.capability.accepted', invocationId: 'service_invocation' }, + { + kind: 'client.capability.accepted', + invocationId: 'service_invocation', + admissionEvidence: { kind: 'none' }, + }, { kind: 'client.capability.result', invocationId: 'service_invocation', @@ -227,7 +231,7 @@ test('Client Capability channel forwards admitted tool progress before the resul }, ], call: async (_frame, options) => { - await options.accept(); + await options.accept({ kind: 'none' }); options.progress?.(1, 3); options.progress?.(2, 3); return { content: [] }; @@ -272,7 +276,11 @@ test('Client Capability channel forwards admitted tool progress before the resul await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(written, [ - { kind: 'client.capability.accepted', invocationId: 'progress-invocation' }, + { + kind: 'client.capability.accepted', + invocationId: 'progress-invocation', + admissionEvidence: { kind: 'none' }, + }, { kind: 'client.capability.progress', invocationId: 'progress-invocation', diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index ddb155ab6d..f99a1aedf0 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -45,6 +45,7 @@ describe('Host Client Capability coordinator', () => { connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); connection.accept({ kind: 'client.capability.result', @@ -129,6 +130,7 @@ describe('Host Client Capability coordinator', () => { connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); connection.accept({ kind: 'client.capability.result', @@ -192,6 +194,7 @@ describe('Host Client Capability coordinator', () => { connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); connection.accept({ kind: 'client.capability.result', @@ -883,6 +886,7 @@ describe('Host Client Capability coordinator', () => { first.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); first.accept({ kind: 'client.capability.result', @@ -898,6 +902,7 @@ describe('Host Client Capability coordinator', () => { second.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); second.accept({ kind: 'client.capability.result', @@ -1105,6 +1110,7 @@ describe('Host Client Capability coordinator', () => { connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); callArrived(); }, @@ -1174,7 +1180,11 @@ describe('Host Client Capability coordinator', () => { { name: 'chunk before start', transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ kind: 'client.capability.accepted', invocationId }); + connection.accept({ + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); connection.accept({ kind: 'client.capability.result_chunk', invocationId, @@ -1187,7 +1197,11 @@ describe('Host Client Capability coordinator', () => { { name: 'duplicate start', transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ kind: 'client.capability.accepted', invocationId }); + connection.accept({ + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); connection.accept({ kind: 'client.capability.result_start', invocationId, @@ -1206,7 +1220,11 @@ describe('Host Client Capability coordinator', () => { { name: 'unchunked result after start', transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ kind: 'client.capability.accepted', invocationId }); + connection.accept({ + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); connection.accept({ kind: 'client.capability.result_start', invocationId, @@ -1224,7 +1242,11 @@ describe('Host Client Capability coordinator', () => { { name: 'out-of-order chunk', transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ kind: 'client.capability.accepted', invocationId }); + connection.accept({ + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); connection.accept({ kind: 'client.capability.result_start', invocationId, @@ -1295,6 +1317,7 @@ async function assertLossClassification( connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); } connection.close(); @@ -1410,6 +1433,7 @@ test('Host services stay bound to the explicitly initiating Client connection', connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); return; } diff --git a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts index 38c66a639b..0eccd7ffa6 100644 --- a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts @@ -62,6 +62,7 @@ describe('ClientCapabilityInvocationBroker', () => { broker.accept('connection-a', { kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }), ); } @@ -104,6 +105,7 @@ describe('ClientCapabilityInvocationBroker', () => { broker.accept('connection-a', { kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }), ); } diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index ca0b1a7f39..1f79a4b7a3 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -118,10 +118,12 @@ describe('Client Capability protocol', () => { decodeClientFrame({ kind: 'client.capability.accepted', invocationId: 'invocation', + admissionEvidence: { kind: 'browser_url', url: 'https://example.com/path' }, }), { kind: 'client.capability.accepted', invocationId: 'invocation', + admissionEvidence: { kind: 'browser_url', url: 'https://example.com/path' }, }, ); assert.deepEqual( diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index 17502b79a1..198ce8be3d 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -162,7 +162,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' if (frame.toolName === 'reject_unknown') { throw new Error('Provider rejected before acceptance'); } - await accept(); + await accept({ kind: 'none' }); return { content: [ { @@ -267,7 +267,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' await client.replaceClientCapabilities({ offers: provider.offers, call: async (frame, { accept }) => { - await accept(); + await accept({ kind: 'none' }); return { content: [{ type: 'text', text: `reconnected:${String(frame.arguments.prefix)}` }], }; diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts index 4caff505ca..6b4a1039b9 100644 --- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts @@ -488,7 +488,7 @@ function resumeFixtureProvider( }, ], call: async (_frame, { accept }) => { - await accept(); + await accept({ kind: 'none' }); return { content: [{ type: 'text', text: 'ok' }] }; }, }; diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index a687759586..46bc8b8e9c 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1387,6 +1387,7 @@ test('production backend preserves coordinator Client Capability semantics acros connection?.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); connection?.accept({ kind: 'client.capability.result', diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts index 69e222d419..89050b8a1a 100644 --- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts @@ -890,6 +890,7 @@ async function attachPresentation( connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); return; } diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index f74e18621d..456d7b97d1 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -3662,6 +3662,7 @@ async function assertSessionSuccessorCapabilityDegradation( previousProvider.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); previousProvider.accept({ kind: 'client.capability.result', @@ -3681,6 +3682,7 @@ async function assertSessionSuccessorCapabilityDegradation( followupProvider.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, }); followupProvider.accept({ kind: 'client.capability.result', diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts index 5bf6d20eda..5ed354567e 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -23,6 +23,7 @@ import { CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, decodeClientCapabilityReplaceInput, decodeClientCapabilityResult, + type ClientCapabilityAdmissionEvidence, type ClientCapabilityCallFrame, type ClientCapabilityClientFrame, type ClientCapabilityHostFrame, @@ -282,13 +283,13 @@ export class ClientCapabilityChannel { invocation: ClientCapabilityInvocation, execute: (options: { readonly signal: AbortSignal; - accept(): Promise; + accept(evidence: ClientCapabilityAdmissionEvidence): Promise; progress(current: number, total: number): void; }) => Promise>, ): Promise { let accepted = false; let accepting: Promise | undefined; - const accept = (): Promise => { + const accept = (evidence: ClientCapabilityAdmissionEvidence): Promise => { if (invocation.released) { return Promise.reject(capabilityInvocationAbortReason(invocation)); } @@ -297,6 +298,7 @@ export class ClientCapabilityChannel { this.#options.write({ kind: 'client.capability.accepted', invocationId, + admissionEvidence: evidence, }), admission.promise, ]).then(() => { @@ -335,7 +337,7 @@ export class ClientCapabilityChannel { }), ); if (invocation.released) return; - await accept(); + await accept({ kind: 'none' }); await this.#sendResult(invocationId, result, invocation); } catch (error) { if (invocation.released) return; diff --git a/packages/runtime-host/src/client/client-capability.ts b/packages/runtime-host/src/client/client-capability.ts index d0499d607c..e91ff2cf58 100644 --- a/packages/runtime-host/src/client/client-capability.ts +++ b/packages/runtime-host/src/client/client-capability.ts @@ -20,6 +20,7 @@ import type { ClientCapabilityCallFrame, ClientCapabilityCallResult, + ClientCapabilityAdmissionEvidence, ClientCapabilityOffer, ClientCapabilityServiceCallFrame, ClientCapabilityServiceOffer, @@ -34,7 +35,7 @@ export interface ClientCapabilityProvider { options: { readonly signal: AbortSignal; /** Await immediately before crossing the provider's irreversible admission cut. */ - accept(): Promise; + accept(evidence: ClientCapabilityAdmissionEvidence): Promise; /** Publish bounded live progress after admission. */ progress?(current: number, total: number): void; }, @@ -44,7 +45,7 @@ export interface ClientCapabilityProvider { options: { readonly signal: AbortSignal; /** Await immediately before crossing the provider's irreversible admission cut. */ - accept(): Promise; + accept(evidence: ClientCapabilityAdmissionEvidence): Promise; }, ): Promise>; /** Release provider-owned resources after its final registration is retired. */ diff --git a/packages/runtime-host/src/client/oauth-presentation.ts b/packages/runtime-host/src/client/oauth-presentation.ts index 7100f6eaa4..a24206f497 100644 --- a/packages/runtime-host/src/client/oauth-presentation.ts +++ b/packages/runtime-host/src/client/oauth-presentation.ts @@ -48,7 +48,7 @@ export function createOAuthPresentationClientProvider( assertOAuthPresentationContract(frame); const request = decodeOAuthPresentationRequest(frame.method, frame.input); const url = trustedPresentationUrl(request.url); - await options.accept(); + await options.accept({ kind: 'none' }); await backend.openExternal(url, request.stateHint, options.signal); return decodeOAuthPresentationResult(request.method, { kind: 'presented' }); }, diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 962128275d..ce51734621 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -188,8 +188,13 @@ export type ClientCapabilityHostFrame = export interface ClientCapabilityAcceptedFrame { readonly kind: 'client.capability.accepted'; readonly invocationId: string; + readonly admissionEvidence: ClientCapabilityAdmissionEvidence; } +export type ClientCapabilityAdmissionEvidence = + | { readonly kind: 'none' } + | { readonly kind: 'browser_url'; readonly url: string }; + export interface ClientCapabilityRejectedFrame { readonly kind: 'client.capability.rejected'; readonly invocationId: string; @@ -382,10 +387,15 @@ export function decodeClientCapabilityClientFrame(value: unknown): ClientCapabil const frame = requireRecord(value, 'Client Capability client frame'); switch (frame.kind) { case 'client.capability.accepted': - assertExactKeys(frame, 'Client Capability accepted frame', ['kind', 'invocationId']); + assertExactKeys(frame, 'Client Capability accepted frame', [ + 'kind', + 'invocationId', + 'admissionEvidence', + ]); return { kind: frame.kind, invocationId: requireEntityId(frame.invocationId, 'invocationId'), + admissionEvidence: decodeClientCapabilityAdmissionEvidence(frame.admissionEvidence), }; case 'client.capability.rejected': case 'client.capability.failed': @@ -480,6 +490,25 @@ export function decodeClientCapabilityClientFrame(value: unknown): ClientCapabil } } +function decodeClientCapabilityAdmissionEvidence( + value: unknown, +): ClientCapabilityAdmissionEvidence { + const evidence = requireRecord(value, 'Client Capability admission evidence'); + switch (evidence.kind) { + case 'none': + assertExactKeys(evidence, 'Client Capability admission evidence', ['kind']); + return { kind: evidence.kind }; + case 'browser_url': + assertExactKeys(evidence, 'Client Capability admission evidence', ['kind', 'url']); + return { + kind: evidence.kind, + url: requireString(evidence.url, 'url', 16_384), + }; + default: + throw invalidProtocolFrame('Unknown Client Capability admission evidence kind'); + } +} + export function decodeClientCapabilityHostFrame(value: unknown): ClientCapabilityHostFrame { const frame = requireRecord(value, 'Client Capability Host frame'); switch (frame.kind) { diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts index 30a22e313c..a4eddabd3f 100644 --- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts +++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts @@ -24,6 +24,7 @@ import { CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, decodeClientCapabilityResult, type ClientCapabilityCallResult, + type ClientCapabilityAdmissionEvidence, type ClientCapabilityClientFrame, type ClientCapabilityHostFrame, type ClientCapabilityHostPathAccess, @@ -75,7 +76,7 @@ interface InvocationState void; readonly reject: (error: Error) => void; - readonly resolveAccepted: () => void; + readonly resolveAccepted: (evidence: ClientCapabilityAdmissionEvidence) => void; readonly rejectAccepted: (error: Error) => void; readonly signal?: AbortSignal; readonly onAbort?: () => void; @@ -96,7 +97,7 @@ interface InvocationState; + waitUntilAccepted(): Promise; /** Crosses the admission cut and returns the provider result. */ admit(): Promise; } @@ -235,9 +236,9 @@ export class ClientCapabilityInvocationBroker< } const invocationId = randomUUID(); - let resolveAccepted!: () => void; + let resolveAccepted!: (evidence: ClientCapabilityAdmissionEvidence) => void; let rejectAccepted!: (error: Error) => void; - const accepted = new Promise((resolve, reject) => { + const accepted = new Promise((resolve, reject) => { resolveAccepted = resolve; rejectAccepted = reject; }); @@ -356,7 +357,7 @@ export class ClientCapabilityInvocationBroker< if (invocation.timer) clearTimeout(invocation.timer); invocation.timer = undefined; invocation.acceptedSettled = true; - invocation.resolveAccepted(); + invocation.resolveAccepted(frame.admissionEvidence); return; } case 'client.capability.rejected': From ac6ea4c64e06457f713ba32c31e095cbd72ee17b Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:07:25 +0800 Subject: [PATCH 03/30] feat(storage): persist client capability session grants Generated-by: OpenAI Codex --- ...ent-capability-session-grant-store.test.ts | 80 ++++++ .../src/conversation-operational-state.ts | 3 + .../storage/src/interaction-store-public.ts | 5 + packages/storage/src/interaction-store.ts | 268 ++++++++++++++++++ .../src/sqlite-core-execution-schema.ts | 20 ++ 5 files changed, 376 insertions(+) create mode 100644 packages/storage/src/__tests__/client-capability-session-grant-store.test.ts diff --git a/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts new file mode 100644 index 0000000000..59c6244287 --- /dev/null +++ b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { createConversationOperationalStateStore } from '../conversation-operational-state.js'; +import { + closeSqliteInteractionStoreFacade, + openSqliteInteractiveInteractionStoreForWrite, +} from '../interaction-store.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('persists Client Capability grants for one Session and purges them with it', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-client-capability-grant-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const store = await openSqliteInteractiveInteractionStoreForWrite(owner.lease); + const key = { + sessionId: 'session-1', + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser' as const, + scope: { kind: 'browser_origin' as const, origin: 'https://example.com' }, + }; + try { + const committed = await store.commitClientCapabilitySessionGrant({ + version: 1, + ...key, + grantedAt: 10, + }); + assert.equal(committed.grantedAt, 10); + assert.deepEqual(await store.readClientCapabilitySessionGrant(key), committed); + + const operationalState = createConversationOperationalStateStore(root); + try { + await operationalState.purge('session-1'); + } finally { + operationalState.close(); + } + assert.equal(await store.readClientCapabilitySessionGrant(key), undefined); + } finally { + closeSqliteInteractionStoreFacade(store); + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/conversation-operational-state.ts b/packages/storage/src/conversation-operational-state.ts index 2f87bd8921..e1f1d36df0 100644 --- a/packages/storage/src/conversation-operational-state.ts +++ b/packages/storage/src/conversation-operational-state.ts @@ -86,6 +86,9 @@ class SqliteConversationOperationalStateStore implements ConversationOperational .prepare('DELETE FROM core_root_turn_start_rejections WHERE session_id = ?') .run(sessionId); database.prepare('DELETE FROM core_agent_runs WHERE session_id = ?').run(sessionId); + database + .prepare('DELETE FROM core_client_capability_session_grants WHERE session_id = ?') + .run(sessionId); database.prepare('DELETE FROM workflow_goal_authority WHERE session_id = ?').run(sessionId); }); } diff --git a/packages/storage/src/interaction-store-public.ts b/packages/storage/src/interaction-store-public.ts index 7aa1973d29..5e9135928b 100644 --- a/packages/storage/src/interaction-store-public.ts +++ b/packages/storage/src/interaction-store-public.ts @@ -25,6 +25,7 @@ export { STORED_INTERACTION_OUTCOME_MAX_BYTES, STORED_INTERACTION_REQUEST_MAX_BYTES, + STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES, InteractionStoreError, authenticateInteractionStoreReader, authenticateInteractionStoreWriter, @@ -34,6 +35,10 @@ export { } from './interaction-store.js'; export type { CommitInteractionOutcomeResult, + ClientCapabilityGrantCapability, + ClientCapabilityGrantScope, + ClientCapabilitySessionGrant, + ClientCapabilitySessionGrantKey, EstablishInteractionRequestResult, InteractionIdentity, InteractionMutationFailureResult, diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index 1b53713d51..f33e807510 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -43,6 +43,29 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/; const REMEMBER_SCOPE_ID = /^[0-9a-f]{64}$/; export const STORED_INTERACTION_REQUEST_MAX_BYTES = 20 * 1024; export const STORED_INTERACTION_OUTCOME_MAX_BYTES = 12 * 1024; +export const STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES = 12 * 1024; + +export type ClientCapabilityGrantCapability = 'browser' | 'computer_use' | 'desktop_mcp'; + +export type ClientCapabilityGrantScope = + | { readonly kind: 'browser_origin'; readonly origin: string } + | { readonly kind: 'capability' } + | { readonly kind: 'mcp_tool'; readonly serverId: string; readonly toolName: string }; + +export interface ClientCapabilitySessionGrantKey { + readonly sessionId: string; + readonly providerId: string; + readonly contractId: string; + readonly serverId: string; + readonly toolName: string; + readonly capability: ClientCapabilityGrantCapability; + readonly scope: ClientCapabilityGrantScope; +} + +export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGrantKey { + readonly version: 1; + readonly grantedAt: number; +} export interface InteractionIdentity { readonly sessionId: string; @@ -119,6 +142,9 @@ export interface InteractionStoreReader { readInteraction(requestId: string): Promise; listSessionPending(sessionId: string): Promise; listPending(filter?: PendingInteractionFilter): Promise; + readClientCapabilitySessionGrant( + key: ClientCapabilitySessionGrantKey, + ): Promise; } export interface InteractionStoreWriter extends InteractionStoreReader { @@ -127,6 +153,9 @@ export interface InteractionStoreWriter extends InteractionStoreReader { requestId: string, outcome: InteractionCanonicalOutcome, ): Promise; + commitClientCapabilitySessionGrant( + grant: ClientCapabilitySessionGrant, + ): Promise; } export interface InteractiveInteractionStoreReaderFacade extends InteractionStoreReader { @@ -176,6 +205,8 @@ export async function openSqliteInteractiveInteractionStoreForRead( readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)), listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)), listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)), + readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) => + run(() => store.readClientCapabilitySessionGrant(key)), }); readers.add(facade); sqliteFacadeClosers.set(facade, () => store.close()); @@ -206,10 +237,14 @@ export async function openSqliteInteractiveInteractionStoreForWrite( readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)), listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)), listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)), + readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) => + run(() => store.readClientCapabilitySessionGrant(key)), establishRequest: (input: StoredInteractionRequest) => run(() => store.establishRequest(input)), commitOutcome: (requestId: string, outcome: InteractionCanonicalOutcome) => run(() => store.commitOutcome(requestId, outcome)), + commitClientCapabilitySessionGrant: (grant: ClientCapabilitySessionGrant) => + run(() => store.commitClientCapabilitySessionGrant(grant)), }); writers.add(facade); sqliteWritersByLease.set(lease, facade); @@ -372,6 +407,124 @@ class SqliteInteractionStore implements InteractionStoreWriter { return sortPending(requests); } + async readClientCapabilitySessionGrant( + key: ClientCapabilitySessionGrantKey, + ): Promise { + const candidate = normalizeClientCapabilityGrantKey(key, 'input'); + const scope = clientCapabilityScopeIdentity(candidate.scope); + const row = this.#lease.database + .prepare(` + SELECT record_json + FROM core_client_capability_session_grants + WHERE session_id = ? + AND provider_id = ? + AND contract_id = ? + AND server_id = ? + AND tool_name = ? + AND capability = ? + AND scope_kind = ? + AND scope_value = ? + `) + .get( + candidate.sessionId, + candidate.providerId, + candidate.contractId, + candidate.serverId, + candidate.toolName, + candidate.capability, + candidate.scope.kind, + scope, + ) as { record_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); + } + const grant = normalizeClientCapabilitySessionGrant( + parseJsonRecord(row.record_json, 'Client Capability Session Grant'), + 'record', + ); + if (!isDeepStrictEqual(normalizeClientCapabilityGrantKey(grant, 'record'), candidate)) { + throw new InteractionStoreError( + 'invalid_record', + 'Client Capability Session Grant identity does not match row', + ); + } + return deepFreeze(grant); + } + + async commitClientCapabilitySessionGrant( + grant: ClientCapabilitySessionGrant, + ): Promise { + const candidate = normalizeClientCapabilitySessionGrant(grant, 'input'); + const scope = clientCapabilityScopeIdentity(candidate.scope); + const encoded = encode(candidate, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES) + .toString('utf8') + .trim(); + return this.#lease.transaction('write', () => { + this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_client_capability_session_grants( + session_id, provider_id, contract_id, server_id, tool_name, + capability, scope_kind, scope_value, granted_at, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + candidate.sessionId, + candidate.providerId, + candidate.contractId, + candidate.serverId, + candidate.toolName, + candidate.capability, + candidate.scope.kind, + scope, + candidate.grantedAt, + encoded, + ); + const stored = this.#readClientCapabilitySessionGrant(candidate); + if (!stored) { + throw new InteractionStoreError( + 'io_failed', + 'Client Capability Session Grant publication produced no record', + ); + } + return stored; + }); + } + + #readClientCapabilitySessionGrant( + key: ClientCapabilitySessionGrantKey, + ): ClientCapabilitySessionGrant | undefined { + const scope = clientCapabilityScopeIdentity(key.scope); + const row = this.#lease.database + .prepare(` + SELECT record_json + FROM core_client_capability_session_grants + WHERE session_id = ? AND provider_id = ? AND contract_id = ? + AND server_id = ? AND tool_name = ? AND capability = ? + AND scope_kind = ? AND scope_value = ? + `) + .get( + key.sessionId, + key.providerId, + key.contractId, + key.serverId, + key.toolName, + key.capability, + key.scope.kind, + scope, + ) as { record_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); + } + return deepFreeze( + normalizeClientCapabilitySessionGrant( + parseJsonRecord(row.record_json, 'Client Capability Session Grant'), + 'record', + ), + ); + } + close(): void { this.#lease.close(); } @@ -486,6 +639,121 @@ function normalizeOutcome( return { ...identity(request), outcome }; } +function normalizeClientCapabilitySessionGrant( + value: unknown, + source: DecodeSource, +): ClientCapabilitySessionGrant { + const record = closedRecord( + value, + [ + 'version', + 'sessionId', + 'providerId', + 'contractId', + 'serverId', + 'toolName', + 'capability', + 'scope', + 'grantedAt', + ], + [], + source, + ); + if (record.version !== 1) + decodeFailure(source, 'Invalid Client Capability Session Grant version'); + if (!Number.isSafeInteger(record.grantedAt) || (record.grantedAt as number) < 0) { + decodeFailure(source, 'Invalid Client Capability Session Grant timestamp'); + } + return { + version: 1, + ...normalizeClientCapabilityGrantKey(record, source), + grantedAt: record.grantedAt as number, + }; +} + +function normalizeClientCapabilityGrantKey( + value: unknown, + source: DecodeSource, +): ClientCapabilitySessionGrantKey { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + decodeFailure(source, 'Invalid Client Capability Session Grant key'); + } + const record = value as Record; + const capability = record.capability; + if (capability !== 'browser' && capability !== 'computer_use' && capability !== 'desktop_mcp') { + decodeFailure(source, 'Invalid Client Capability Session Grant capability'); + } + const scope = normalizeClientCapabilityGrantScope(record.scope, source); + if ( + (capability === 'browser' && scope.kind !== 'browser_origin') || + (capability === 'computer_use' && scope.kind !== 'capability') || + (capability === 'desktop_mcp' && scope.kind !== 'mcp_tool') + ) { + decodeFailure(source, 'Client Capability Session Grant scope does not match capability'); + } + return { + sessionId: assertId(record.sessionId, source), + providerId: assertId(record.providerId, source), + contractId: assertId(record.contractId, source), + serverId: assertId(record.serverId, source), + toolName: assertId(record.toolName, source), + capability, + scope, + }; +} + +function normalizeClientCapabilityGrantScope( + value: unknown, + source: DecodeSource, +): ClientCapabilityGrantScope { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + decodeFailure(source, 'Invalid Client Capability Session Grant scope'); + } + const record = value as Record; + switch (record.kind) { + case 'browser_origin': { + const scope = closedRecord(record, ['kind', 'origin'], [], source); + if (typeof scope.origin !== 'string' || scope.origin.length > 16_384) { + decodeFailure(source, 'Invalid Browser origin scope'); + } + let url: URL; + try { + url = new URL(scope.origin); + } catch (error) { + decodeFailure(source, 'Invalid Browser origin scope', error); + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.origin !== scope.origin) { + decodeFailure(source, 'Browser origin scope must be a canonical HTTP origin'); + } + return { kind: 'browser_origin', origin: scope.origin }; + } + case 'capability': + closedRecord(record, ['kind'], [], source); + return { kind: 'capability' }; + case 'mcp_tool': { + const scope = closedRecord(record, ['kind', 'serverId', 'toolName'], [], source); + return { + kind: 'mcp_tool', + serverId: assertId(scope.serverId, source), + toolName: assertId(scope.toolName, source), + }; + } + default: + decodeFailure(source, 'Invalid Client Capability Session Grant scope kind'); + } +} + +function clientCapabilityScopeIdentity(scope: ClientCapabilityGrantScope): string { + switch (scope.kind) { + case 'browser_origin': + return scope.origin; + case 'capability': + return '*'; + case 'mcp_tool': + return `${scope.serverId}\0${scope.toolName}`; + } +} + function identity(value: InteractionIdentity): InteractionIdentity { return { sessionId: value.sessionId, diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index f6fea7c94a..83554fb5e7 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -112,6 +112,26 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS core_client_capability_session_grants ( + session_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + server_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + capability TEXT NOT NULL, + scope_kind TEXT NOT NULL, + scope_value TEXT NOT NULL, + granted_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY ( + session_id, provider_id, contract_id, server_id, tool_name, + capability, scope_kind, scope_value + ) + ); + + CREATE INDEX IF NOT EXISTS core_client_capability_session_grants_session + ON core_client_capability_session_grants(session_id, granted_at); + CREATE TABLE IF NOT EXISTS core_shell_runs ( session_id TEXT NOT NULL, shell_run_id TEXT NOT NULL, From ab194f4cc53d776d667ac05054594b92fa1d5bdc Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:09:37 +0800 Subject: [PATCH 04/30] refactor(core): centralize capability grant identity Generated-by: OpenAI Codex --- packages/core/package.json | 1 + packages/core/src/client-capability-grant.ts | 195 ++++++++++++++++++ .../storage/src/interaction-store-public.ts | 4 - packages/storage/src/interaction-store.ts | 161 ++------------- 4 files changed, 218 insertions(+), 143 deletions(-) create mode 100644 packages/core/src/client-capability-grant.ts diff --git a/packages/core/package.json b/packages/core/package.json index e055d6d74d..8e8a081ea1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,6 +58,7 @@ "./e2e-fixture": "./dist/e2e-fixture.js", "./capabilities": "./dist/capabilities.js", "./capability-audit": "./dist/capability-audit.js", + "./client-capability-grant": "./dist/client-capability-grant.js", "./health": "./dist/health.js", "./search": "./dist/search.js", "./bot-events": "./dist/bot-events.js", diff --git a/packages/core/src/client-capability-grant.ts b/packages/core/src/client-capability-grant.ts new file mode 100644 index 0000000000..b60ee7c507 --- /dev/null +++ b/packages/core/src/client-capability-grant.ts @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { defineObjectShape, hasExactShape } from './record-schema.js'; + +const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/; + +export type ClientCapabilityGrantCapability = 'browser' | 'computer_use' | 'desktop_mcp'; + +export type ClientCapabilityGrantScope = + | { readonly kind: 'browser_origin'; readonly origin: string } + | { readonly kind: 'capability' } + | { readonly kind: 'mcp_tool'; readonly serverId: string; readonly toolName: string }; + +export interface ClientCapabilitySessionGrantKey { + readonly sessionId: string; + readonly providerId: string; + readonly contractId: string; + readonly serverId: string; + readonly toolName: string; + readonly capability: ClientCapabilityGrantCapability; + readonly scope: ClientCapabilityGrantScope; +} + +export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGrantKey { + readonly version: 1; + readonly grantedAt: number; +} + +const GRANT_SHAPE = defineObjectShape()( + [ + 'version', + 'sessionId', + 'providerId', + 'contractId', + 'serverId', + 'toolName', + 'capability', + 'scope', + 'grantedAt', + ], + [], +); +const BROWSER_ORIGIN_SCOPE_SHAPE = defineObjectShape< + Extract +>()(['kind', 'origin'], []); +const CAPABILITY_SCOPE_SHAPE = defineObjectShape< + Extract +>()(['kind'], []); +const MCP_TOOL_SCOPE_SHAPE = defineObjectShape< + Extract +>()(['kind', 'serverId', 'toolName'], []); + +export function decodeClientCapabilitySessionGrantKey( + value: unknown, +): ClientCapabilitySessionGrantKey { + const record = plainRecord(value, 'Client Capability Session Grant key'); + const capability = oneOf( + record.capability, + ['browser', 'computer_use', 'desktop_mcp'] as const, + 'capability', + ); + const scope = decodeClientCapabilityGrantScope(record.scope); + if ( + (capability === 'browser' && scope.kind !== 'browser_origin') || + (capability === 'computer_use' && scope.kind !== 'capability') || + (capability === 'desktop_mcp' && scope.kind !== 'mcp_tool') + ) { + throw new Error('Client Capability Session Grant scope does not match capability'); + } + return deepFreeze({ + sessionId: safeId(record.sessionId, 'sessionId'), + providerId: safeId(record.providerId, 'providerId'), + contractId: safeId(record.contractId, 'contractId'), + serverId: safeId(record.serverId, 'serverId'), + toolName: safeId(record.toolName, 'toolName'), + capability, + scope, + }); +} + +export function decodeClientCapabilitySessionGrant(value: unknown): ClientCapabilitySessionGrant { + const record = plainRecord(value, 'Client Capability Session Grant'); + if (!hasExactShape(record, GRANT_SHAPE)) { + throw new Error('Invalid Client Capability Session Grant fields'); + } + if (record.version !== 1) throw new Error('Invalid Client Capability Session Grant version'); + if ( + typeof record.grantedAt !== 'number' || + !Number.isSafeInteger(record.grantedAt) || + record.grantedAt < 0 + ) { + throw new Error('Invalid Client Capability Session Grant timestamp'); + } + return deepFreeze({ + version: 1, + ...decodeClientCapabilitySessionGrantKey(record), + grantedAt: record.grantedAt, + }); +} + +export function clientCapabilityScopeIdentity(scope: ClientCapabilityGrantScope): string { + switch (scope.kind) { + case 'browser_origin': + return scope.origin; + case 'capability': + return '*'; + case 'mcp_tool': + return `${scope.serverId}\0${scope.toolName}`; + } +} + +function decodeClientCapabilityGrantScope(value: unknown): ClientCapabilityGrantScope { + const record = plainRecord(value, 'Client Capability Session Grant scope'); + switch (record.kind) { + case 'browser_origin': { + if (!hasExactShape(record, BROWSER_ORIGIN_SCOPE_SHAPE)) { + throw new Error('Invalid Browser origin scope fields'); + } + if (typeof record.origin !== 'string' || record.origin.length > 16_384) { + throw new Error('Invalid Browser origin scope'); + } + const url = new URL(record.origin); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.origin !== record.origin) { + throw new Error('Browser origin scope must be a canonical HTTP origin'); + } + return deepFreeze({ kind: 'browser_origin', origin: record.origin }); + } + case 'capability': + if (!hasExactShape(record, CAPABILITY_SCOPE_SHAPE)) { + throw new Error('Invalid capability scope fields'); + } + return Object.freeze({ kind: 'capability' }); + case 'mcp_tool': + if (!hasExactShape(record, MCP_TOOL_SCOPE_SHAPE)) { + throw new Error('Invalid MCP tool scope fields'); + } + return Object.freeze({ + kind: 'mcp_tool', + serverId: safeId(record.serverId, 'scope.serverId'), + toolName: safeId(record.toolName, 'scope.toolName'), + }); + default: + throw new Error('Invalid Client Capability Session Grant scope kind'); + } +} + +function plainRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be a plain record`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} must be a plain record`); + } + return value as Record; +} + +function safeId(value: unknown, label: string): string { + if (typeof value !== 'string' || !SAFE_ID.test(value)) throw new Error(`Invalid ${label}`); + return value; +} + +function oneOf( + value: unknown, + values: T, + label: string, +): T[number] { + if (!values.includes(value)) throw new Error(`Invalid ${label}`); + return value as T[number]; +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) deepFreeze(nested); + Object.freeze(value); + } + return value; +} diff --git a/packages/storage/src/interaction-store-public.ts b/packages/storage/src/interaction-store-public.ts index 5e9135928b..d72040d5ce 100644 --- a/packages/storage/src/interaction-store-public.ts +++ b/packages/storage/src/interaction-store-public.ts @@ -35,10 +35,6 @@ export { } from './interaction-store.js'; export type { CommitInteractionOutcomeResult, - ClientCapabilityGrantCapability, - ClientCapabilityGrantScope, - ClientCapabilitySessionGrant, - ClientCapabilitySessionGrantKey, EstablishInteractionRequestResult, InteractionIdentity, InteractionMutationFailureResult, diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index f33e807510..e5bae20ca0 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -19,6 +19,13 @@ import { resolve } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; +import { + clientCapabilityScopeIdentity, + decodeClientCapabilitySessionGrant, + decodeClientCapabilitySessionGrantKey, + type ClientCapabilitySessionGrant, + type ClientCapabilitySessionGrantKey, +} from '@maka/core/client-capability-grant'; import { decodeInteractionCanonicalOutcome, decodeInteractionRequest, @@ -45,28 +52,6 @@ export const STORED_INTERACTION_REQUEST_MAX_BYTES = 20 * 1024; export const STORED_INTERACTION_OUTCOME_MAX_BYTES = 12 * 1024; export const STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES = 12 * 1024; -export type ClientCapabilityGrantCapability = 'browser' | 'computer_use' | 'desktop_mcp'; - -export type ClientCapabilityGrantScope = - | { readonly kind: 'browser_origin'; readonly origin: string } - | { readonly kind: 'capability' } - | { readonly kind: 'mcp_tool'; readonly serverId: string; readonly toolName: string }; - -export interface ClientCapabilitySessionGrantKey { - readonly sessionId: string; - readonly providerId: string; - readonly contractId: string; - readonly serverId: string; - readonly toolName: string; - readonly capability: ClientCapabilityGrantCapability; - readonly scope: ClientCapabilityGrantScope; -} - -export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGrantKey { - readonly version: 1; - readonly grantedAt: number; -} - export interface InteractionIdentity { readonly sessionId: string; readonly turnId: string; @@ -410,7 +395,7 @@ class SqliteInteractionStore implements InteractionStoreWriter { async readClientCapabilitySessionGrant( key: ClientCapabilitySessionGrantKey, ): Promise { - const candidate = normalizeClientCapabilityGrantKey(key, 'input'); + const candidate = decodeGrantKey(key, 'input'); const scope = clientCapabilityScopeIdentity(candidate.scope); const row = this.#lease.database .prepare(` @@ -439,11 +424,11 @@ class SqliteInteractionStore implements InteractionStoreWriter { if (typeof row.record_json !== 'string') { throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); } - const grant = normalizeClientCapabilitySessionGrant( + const grant = decodeGrant( parseJsonRecord(row.record_json, 'Client Capability Session Grant'), 'record', ); - if (!isDeepStrictEqual(normalizeClientCapabilityGrantKey(grant, 'record'), candidate)) { + if (!isDeepStrictEqual(decodeGrantKey(grant, 'record'), candidate)) { throw new InteractionStoreError( 'invalid_record', 'Client Capability Session Grant identity does not match row', @@ -455,7 +440,7 @@ class SqliteInteractionStore implements InteractionStoreWriter { async commitClientCapabilitySessionGrant( grant: ClientCapabilitySessionGrant, ): Promise { - const candidate = normalizeClientCapabilitySessionGrant(grant, 'input'); + const candidate = decodeGrant(grant, 'input'); const scope = clientCapabilityScopeIdentity(candidate.scope); const encoded = encode(candidate, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES) .toString('utf8') @@ -518,10 +503,7 @@ class SqliteInteractionStore implements InteractionStoreWriter { throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); } return deepFreeze( - normalizeClientCapabilitySessionGrant( - parseJsonRecord(row.record_json, 'Client Capability Session Grant'), - 'record', - ), + decodeGrant(parseJsonRecord(row.record_json, 'Client Capability Session Grant'), 'record'), ); } @@ -639,118 +621,19 @@ function normalizeOutcome( return { ...identity(request), outcome }; } -function normalizeClientCapabilitySessionGrant( - value: unknown, - source: DecodeSource, -): ClientCapabilitySessionGrant { - const record = closedRecord( - value, - [ - 'version', - 'sessionId', - 'providerId', - 'contractId', - 'serverId', - 'toolName', - 'capability', - 'scope', - 'grantedAt', - ], - [], - source, - ); - if (record.version !== 1) - decodeFailure(source, 'Invalid Client Capability Session Grant version'); - if (!Number.isSafeInteger(record.grantedAt) || (record.grantedAt as number) < 0) { - decodeFailure(source, 'Invalid Client Capability Session Grant timestamp'); - } - return { - version: 1, - ...normalizeClientCapabilityGrantKey(record, source), - grantedAt: record.grantedAt as number, - }; -} - -function normalizeClientCapabilityGrantKey( - value: unknown, - source: DecodeSource, -): ClientCapabilitySessionGrantKey { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - decodeFailure(source, 'Invalid Client Capability Session Grant key'); - } - const record = value as Record; - const capability = record.capability; - if (capability !== 'browser' && capability !== 'computer_use' && capability !== 'desktop_mcp') { - decodeFailure(source, 'Invalid Client Capability Session Grant capability'); - } - const scope = normalizeClientCapabilityGrantScope(record.scope, source); - if ( - (capability === 'browser' && scope.kind !== 'browser_origin') || - (capability === 'computer_use' && scope.kind !== 'capability') || - (capability === 'desktop_mcp' && scope.kind !== 'mcp_tool') - ) { - decodeFailure(source, 'Client Capability Session Grant scope does not match capability'); - } - return { - sessionId: assertId(record.sessionId, source), - providerId: assertId(record.providerId, source), - contractId: assertId(record.contractId, source), - serverId: assertId(record.serverId, source), - toolName: assertId(record.toolName, source), - capability, - scope, - }; -} - -function normalizeClientCapabilityGrantScope( - value: unknown, - source: DecodeSource, -): ClientCapabilityGrantScope { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - decodeFailure(source, 'Invalid Client Capability Session Grant scope'); - } - const record = value as Record; - switch (record.kind) { - case 'browser_origin': { - const scope = closedRecord(record, ['kind', 'origin'], [], source); - if (typeof scope.origin !== 'string' || scope.origin.length > 16_384) { - decodeFailure(source, 'Invalid Browser origin scope'); - } - let url: URL; - try { - url = new URL(scope.origin); - } catch (error) { - decodeFailure(source, 'Invalid Browser origin scope', error); - } - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.origin !== scope.origin) { - decodeFailure(source, 'Browser origin scope must be a canonical HTTP origin'); - } - return { kind: 'browser_origin', origin: scope.origin }; - } - case 'capability': - closedRecord(record, ['kind'], [], source); - return { kind: 'capability' }; - case 'mcp_tool': { - const scope = closedRecord(record, ['kind', 'serverId', 'toolName'], [], source); - return { - kind: 'mcp_tool', - serverId: assertId(scope.serverId, source), - toolName: assertId(scope.toolName, source), - }; - } - default: - decodeFailure(source, 'Invalid Client Capability Session Grant scope kind'); +function decodeGrant(value: unknown, source: DecodeSource): ClientCapabilitySessionGrant { + try { + return decodeClientCapabilitySessionGrant(value); + } catch (error) { + decodeFailure(source, 'Invalid Client Capability Session Grant', error); } } -function clientCapabilityScopeIdentity(scope: ClientCapabilityGrantScope): string { - switch (scope.kind) { - case 'browser_origin': - return scope.origin; - case 'capability': - return '*'; - case 'mcp_tool': - return `${scope.serverId}\0${scope.toolName}`; +function decodeGrantKey(value: unknown, source: DecodeSource): ClientCapabilitySessionGrantKey { + try { + return decodeClientCapabilitySessionGrantKey(value); + } catch (error) { + decodeFailure(source, 'Invalid Client Capability Session Grant key', error); } } From 1bb8fe935f32d181d3ef63bc25a77efb9bdbd77c Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:12:27 +0800 Subject: [PATCH 05/30] feat(core): define client capability approval interactions Generated-by: OpenAI Codex --- .../core/src/__tests__/interaction.test.ts | 33 ++++++++ packages/core/src/client-capability-grant.ts | 16 +++- packages/core/src/interaction.ts | 83 ++++++++++++++++++- 3 files changed, 126 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 304a78d04c..30aff2d924 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -33,6 +33,7 @@ import { interactionCanonicalOutcomesEquivalent, isInteractionAnswerValidForRequest, isInteractionCanonicalOutcomeValidForRequest, + projectInteractionClientCapabilityRequest, projectInteractionPermissionRequest, projectInteractionQuestionRequest, projectInteractionSandboxBoundaryRequest, @@ -782,6 +783,38 @@ describe('Interaction decoding and validity', () => { ); }); + test('keeps Client Capability approval tied to the exact Host grant target', () => { + const request = projectInteractionClientCapabilityRequest({ + toolUseId: 'browser-call-1', + target: { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }, + }); + const answer = decodeInteractionAnswer({ kind: 'client_capability', decision: 'allow' }); + const outcome = decodeInteractionCanonicalOutcome({ + kind: 'client_capability_decision', + decision: 'allow', + committedAt: 2, + }); + assert.equal(isInteractionAnswerValidForRequest(request, answer), true); + assert.equal(isInteractionCanonicalOutcomeValidForRequest(request, outcome), true); + assert.deepEqual(decodeInteractionRequest(request), request); + assert.throws(() => + projectInteractionClientCapabilityRequest({ + ...request, + target: { + ...request.target, + scope: { kind: 'browser_origin', origin: 'https://example.com/path' }, + }, + }), + ); + }); + const permission = projectInteractionPermissionRequest(toolPermission); const question = projectInteractionQuestionRequest({ toolUseId: 'q1', diff --git a/packages/core/src/client-capability-grant.ts b/packages/core/src/client-capability-grant.ts index b60ee7c507..502ee25ea0 100644 --- a/packages/core/src/client-capability-grant.ts +++ b/packages/core/src/client-capability-grant.ts @@ -28,8 +28,7 @@ export type ClientCapabilityGrantScope = | { readonly kind: 'capability' } | { readonly kind: 'mcp_tool'; readonly serverId: string; readonly toolName: string }; -export interface ClientCapabilitySessionGrantKey { - readonly sessionId: string; +export interface ClientCapabilityGrantTarget { readonly providerId: string; readonly contractId: string; readonly serverId: string; @@ -38,6 +37,10 @@ export interface ClientCapabilitySessionGrantKey { readonly scope: ClientCapabilityGrantScope; } +export interface ClientCapabilitySessionGrantKey extends ClientCapabilityGrantTarget { + readonly sessionId: string; +} + export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGrantKey { readonly version: 1; readonly grantedAt: number; @@ -71,6 +74,14 @@ export function decodeClientCapabilitySessionGrantKey( value: unknown, ): ClientCapabilitySessionGrantKey { const record = plainRecord(value, 'Client Capability Session Grant key'); + return deepFreeze({ + sessionId: safeId(record.sessionId, 'sessionId'), + ...decodeClientCapabilityGrantTarget(record), + }); +} + +export function decodeClientCapabilityGrantTarget(value: unknown): ClientCapabilityGrantTarget { + const record = plainRecord(value, 'Client Capability Grant target'); const capability = oneOf( record.capability, ['browser', 'computer_use', 'desktop_mcp'] as const, @@ -85,7 +96,6 @@ export function decodeClientCapabilitySessionGrantKey( throw new Error('Client Capability Session Grant scope does not match capability'); } return deepFreeze({ - sessionId: safeId(record.sessionId, 'sessionId'), providerId: safeId(record.providerId, 'providerId'), contractId: safeId(record.contractId, 'contractId'), serverId: safeId(record.serverId, 'serverId'), diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index f49136af08..efc34d1713 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -38,6 +38,10 @@ import { type SandboxBoundaryExpansion, type SandboxBoundaryRequestStatus, } from './sandbox-boundary.js'; +import { + decodeClientCapabilityGrantTarget, + type ClientCapabilityGrantTarget, +} from './client-capability-grant.js'; export * from './interaction-permission-review.js'; @@ -95,10 +99,17 @@ export interface InteractionSandboxBoundaryRequest { readonly justification: string; } +export interface InteractionClientCapabilityRequest { + readonly kind: 'client_capability'; + readonly toolUseId: string; + readonly target: ClientCapabilityGrantTarget; +} + export type InteractionRequest = | InteractionPermissionRequest | InteractionQuestionRequest - | InteractionSandboxBoundaryRequest; + | InteractionSandboxBoundaryRequest + | InteractionClientCapabilityRequest; export type InteractionPermissionDecisionFields = | { readonly decision: 'allow'; readonly rememberForTurn: boolean } @@ -118,10 +129,16 @@ export interface InteractionSandboxBoundaryAnswer { readonly decision: 'allow' | 'deny'; } +export interface InteractionClientCapabilityAnswer { + readonly kind: 'client_capability'; + readonly decision: 'allow' | 'deny'; +} + export type InteractionAnswer = | InteractionPermissionAnswer | InteractionQuestionAnswer - | InteractionSandboxBoundaryAnswer; + | InteractionSandboxBoundaryAnswer + | InteractionClientCapabilityAnswer; export type InteractionCanonicalPermissionOutcome = { readonly kind: 'permission_answer'; @@ -144,6 +161,12 @@ export interface InteractionCanonicalSandboxBoundaryOutcome { readonly committedAt: number; } +export interface InteractionCanonicalClientCapabilityOutcome { + readonly kind: 'client_capability_decision'; + readonly decision: 'allow' | 'deny'; + readonly committedAt: number; +} + export interface InteractionCanonicalClosureOutcome { readonly kind: 'closure'; readonly reason: InteractionClosureReason; @@ -154,6 +177,7 @@ export type InteractionCanonicalOutcome = | InteractionCanonicalPermissionOutcome | InteractionCanonicalQuestionOutcome | InteractionCanonicalSandboxBoundaryOutcome + | InteractionCanonicalClientCapabilityOutcome | InteractionCanonicalClosureOutcome; export type InteractionQuestionProjectionInput = Pick< @@ -173,6 +197,10 @@ const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape()( + ['kind', 'toolUseId', 'target'], + [], +); const PERMISSION_ANSWER_SHAPE = defineObjectShape()( ['kind', 'decision', 'rememberForTurn'], [], @@ -185,6 +213,10 @@ const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape()( + ['kind', 'decision'], + [], +); const PERMISSION_OUTCOME_SHAPE = defineObjectShape()( ['kind', 'reviewer', 'committedAt', 'decision', 'rememberForTurn'], ['rationale', 'riskLevel'], @@ -198,6 +230,11 @@ const SANDBOX_BOUNDARY_OUTCOME_SHAPE = ['kind', 'decision', 'status', 'committedAt'], [], ); +const CLIENT_CAPABILITY_OUTCOME_SHAPE = + defineObjectShape()( + ['kind', 'decision', 'committedAt'], + [], + ); const CLOSURE_OUTCOME_SHAPE = defineObjectShape()( ['kind', 'reason', 'committedAt'], [], @@ -240,6 +277,13 @@ export function decodeInteractionRequest(value: unknown): InteractionRequest { INTERACTION_SANDBOX_BOUNDARY_JUSTIFICATION_MAX_CHARS, ), }; + } else if (record.kind === 'client_capability') { + exact(record, CLIENT_CAPABILITY_REQUEST_SHAPE, 'Client Capability request'); + request = { + kind: 'client_capability', + toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES), + target: decodeClientCapabilityGrantTarget(record.target), + }; } else { throw new Error('Invalid Interaction request kind'); } @@ -271,6 +315,12 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer { kind: 'sandbox_boundary', decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'), }; + } else if (record.kind === 'client_capability') { + exact(record, CLIENT_CAPABILITY_ANSWER_SHAPE, 'Client Capability answer'); + answer = { + kind: 'client_capability', + decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'), + }; } else { throw new Error('Invalid Interaction answer kind'); } @@ -339,6 +389,13 @@ export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCa status, committedAt: safeInteger(record.committedAt, 'committedAt', false), }; + } else if (record.kind === 'client_capability_decision') { + exact(record, CLIENT_CAPABILITY_OUTCOME_SHAPE, 'Client Capability outcome'); + outcome = { + kind: 'client_capability_decision', + decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'), + committedAt: safeInteger(record.committedAt, 'committedAt', false), + }; } else if (record.kind === 'closure') { exact(record, CLOSURE_OUTCOME_SHAPE, 'closure outcome'); outcome = { @@ -423,6 +480,17 @@ export function projectInteractionSandboxBoundaryRequest(input: { }) as InteractionSandboxBoundaryRequest; } +export function projectInteractionClientCapabilityRequest(input: { + readonly toolUseId: string; + readonly target: ClientCapabilityGrantTarget; +}): InteractionClientCapabilityRequest { + return decodeInteractionRequest({ + kind: 'client_capability', + toolUseId: input.toolUseId, + target: input.target, + }) as InteractionClientCapabilityRequest; +} + export function interactionAnswerMatchesRequestKind( request: InteractionRequest, answer: InteractionAnswer, @@ -440,7 +508,9 @@ export function interactionOutcomeMatchesRequestKind( ? outcome.kind === 'permission_answer' : request.kind === 'question' ? outcome.kind === 'question_answer' - : outcome.kind === 'sandbox_boundary_decision') + : request.kind === 'sandbox_boundary' + ? outcome.kind === 'sandbox_boundary_decision' + : outcome.kind === 'client_capability_decision') ); } @@ -476,6 +546,7 @@ export function isInteractionAnswerValidForRequest( ); } if (answer.kind === 'sandbox_boundary') return request.kind === 'sandbox_boundary'; + if (answer.kind === 'client_capability') return request.kind === 'client_capability'; return interactionRememberForTurnIsEligible(request, answer); } @@ -495,6 +566,9 @@ export function isInteractionCanonicalOutcomeValidForRequest( interactionQuestionAnswerCountMatchesRequest(request, outcome.answers) ); } + if (outcome.kind === 'client_capability_decision') { + return request.kind === 'client_capability'; + } return request.kind === 'sandbox_boundary'; } @@ -510,6 +584,9 @@ export function interactionCanonicalOutcomesEquivalent( if (left.kind === 'sandbox_boundary_decision' && right.kind === 'sandbox_boundary_decision') { return left.decision === right.decision && left.status === right.status; } + if (left.kind === 'client_capability_decision' && right.kind === 'client_capability_decision') { + return left.decision === right.decision; + } return left.kind === 'closure' && right.kind === 'closure' && left.reason === right.reason; } From ec1c0c23c1cea096d51aec90dc65744f4ba4c677 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:12:31 +0800 Subject: [PATCH 06/30] feat(storage): atomically commit capability approvals and grants Generated-by: OpenAI Codex --- ...ent-capability-session-grant-store.test.ts | 24 ++- packages/storage/src/interaction-store.ts | 161 +++++++++++++++--- 2 files changed, 163 insertions(+), 22 deletions(-) diff --git a/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts index 59c6244287..eb78459875 100644 --- a/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts +++ b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts @@ -55,13 +55,31 @@ test('persists Client Capability grants for one Session and purges them with it' scope: { kind: 'browser_origin' as const, origin: 'https://example.com' }, }; try { - const committed = await store.commitClientCapabilitySessionGrant({ + const grant = { version: 1, ...key, grantedAt: 10, + } as const; + const established = await store.establishRequest({ + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + requestId: 'request-1', + createdAt: 5, + request: { + kind: 'client_capability', + toolUseId: 'tool-call-1', + target: key, + }, }); - assert.equal(committed.grantedAt, 10); - assert.deepEqual(await store.readClientCapabilitySessionGrant(key), committed); + assert.equal(established.status, 'stable'); + const committed = await store.commitClientCapabilityOutcome( + 'request-1', + { kind: 'client_capability_decision', decision: 'allow', committedAt: 10 }, + grant, + ); + assert.equal(committed.status, 'stable'); + assert.deepEqual(await store.readClientCapabilitySessionGrant(key), grant); const operationalState = createConversationOperationalStateStore(root); try { diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index e5bae20ca0..f16b75e340 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -141,6 +141,14 @@ export interface InteractionStoreWriter extends InteractionStoreReader { commitClientCapabilitySessionGrant( grant: ClientCapabilitySessionGrant, ): Promise; + commitClientCapabilityOutcome( + requestId: string, + outcome: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ): Promise; } export interface InteractiveInteractionStoreReaderFacade extends InteractionStoreReader { @@ -230,6 +238,14 @@ export async function openSqliteInteractiveInteractionStoreForWrite( run(() => store.commitOutcome(requestId, outcome)), commitClientCapabilitySessionGrant: (grant: ClientCapabilitySessionGrant) => run(() => store.commitClientCapabilitySessionGrant(grant)), + commitClientCapabilityOutcome: ( + requestId: string, + outcome: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ) => run(() => store.commitClientCapabilityOutcome(requestId, outcome, grant)), }); writers.add(facade); sqliteWritersByLease.set(lease, facade); @@ -357,6 +373,82 @@ class SqliteInteractionStore implements InteractionStoreWriter { }); } + async commitClientCapabilityOutcome( + requestId: string, + outcome: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ): Promise { + assertId(requestId); + return this.#lease.transaction('write', () => { + const record = readSqliteInteraction(this.#lease, requestId); + if (!record) { + throw new InteractionStoreError( + 'request_not_found', + `Interaction request '${requestId}' does not exist`, + ); + } + if (record.request.request.kind !== 'client_capability') { + throw new InteractionStoreError( + 'invalid_input', + 'Client Capability outcome requires a Client Capability request', + ); + } + const canonical = decodeClientCapabilityOutcome(outcome); + if (!isInteractionCanonicalOutcomeValidForRequest(record.request.request, canonical)) { + throw new InteractionStoreError('invalid_input', 'Outcome is not valid for its request'); + } + const candidateGrant = grant === undefined ? undefined : decodeGrant(grant, 'input'); + const shouldGrant = + canonical.kind === 'client_capability_decision' && canonical.decision === 'allow'; + if (shouldGrant !== (candidateGrant !== undefined)) { + throw new InteractionStoreError( + 'invalid_input', + 'Allowed Client Capability outcome requires exactly one Session Grant', + ); + } + if ( + candidateGrant && + (!isDeepStrictEqual(decodeGrantKey(candidateGrant, 'input'), { + sessionId: record.request.sessionId, + ...record.request.request.target, + }) || + candidateGrant.grantedAt !== canonical.committedAt) + ) { + throw new InteractionStoreError( + 'invalid_input', + 'Client Capability Session Grant does not match its Interaction request', + ); + } + const candidate: StoredInteractionOutcome = { + ...identity(record.request), + outcome: canonical, + }; + const encoded = encode(candidate, STORED_INTERACTION_OUTCOME_MAX_BYTES) + .toString('utf8') + .trim(); + this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_interaction_outcomes(request_id, record_json) + VALUES (?, ?) + `) + .run(requestId, encoded); + const settled = readSqliteInteraction(this.#lease, requestId); + if (!settled?.outcome) { + throw new InteractionStoreError('io_failed', 'Outcome publication produced no record'); + } + const matches = interactionCanonicalOutcomesEquivalent(settled.outcome.outcome, canonical); + if (matches && candidateGrant) this.#commitClientCapabilitySessionGrant(candidateGrant); + return { + status: 'stable', + matches, + record: settled as InteractionRecord & { readonly outcome: StoredInteractionOutcome }, + }; + }); + } + async readInteraction(requestId: string): Promise { assertId(requestId); return readSqliteInteraction(this.#lease, requestId); @@ -446,25 +538,7 @@ class SqliteInteractionStore implements InteractionStoreWriter { .toString('utf8') .trim(); return this.#lease.transaction('write', () => { - this.#lease.database - .prepare(` - INSERT OR IGNORE INTO core_client_capability_session_grants( - session_id, provider_id, contract_id, server_id, tool_name, - capability, scope_kind, scope_value, granted_at, record_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) - .run( - candidate.sessionId, - candidate.providerId, - candidate.contractId, - candidate.serverId, - candidate.toolName, - candidate.capability, - candidate.scope.kind, - scope, - candidate.grantedAt, - encoded, - ); + this.#insertClientCapabilitySessionGrant(candidate, scope, encoded); const stored = this.#readClientCapabilitySessionGrant(candidate); if (!stored) { throw new InteractionStoreError( @@ -476,6 +550,40 @@ class SqliteInteractionStore implements InteractionStoreWriter { }); } + #commitClientCapabilitySessionGrant(grant: ClientCapabilitySessionGrant): void { + const scope = clientCapabilityScopeIdentity(grant.scope); + const encoded = encode(grant, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES) + .toString('utf8') + .trim(); + this.#insertClientCapabilitySessionGrant(grant, scope, encoded); + } + + #insertClientCapabilitySessionGrant( + grant: ClientCapabilitySessionGrant, + scope: string, + encoded: string, + ): void { + this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_client_capability_session_grants( + session_id, provider_id, contract_id, server_id, tool_name, + capability, scope_kind, scope_value, granted_at, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + grant.sessionId, + grant.providerId, + grant.contractId, + grant.serverId, + grant.toolName, + grant.capability, + grant.scope.kind, + scope, + grant.grantedAt, + encoded, + ); + } + #readClientCapabilitySessionGrant( key: ClientCapabilitySessionGrantKey, ): ClientCapabilitySessionGrant | undefined { @@ -637,6 +745,21 @@ function decodeGrantKey(value: unknown, source: DecodeSource): ClientCapabilityS } } +function decodeClientCapabilityOutcome( + value: unknown, +): Extract { + let outcome: InteractionCanonicalOutcome; + try { + outcome = decodeInteractionCanonicalOutcome(value); + } catch (error) { + decodeFailure('input', 'Invalid Client Capability Interaction outcome', error); + } + if (outcome.kind !== 'client_capability_decision' && outcome.kind !== 'closure') { + decodeFailure('input', 'Invalid Client Capability Interaction outcome kind'); + } + return outcome; +} + function identity(value: InteractionIdentity): InteractionIdentity { return { sessionId: value.sessionId, From 1b92ba6ed30022f86e4d24e42561f3d3c3504af8 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:17:02 +0800 Subject: [PATCH 07/30] feat(runtime-host): host client capability approval interactions Generated-by: OpenAI Codex --- .../__tests__/interaction-coordinator.test.ts | 46 ++++ .../src/server/interaction-coordinator.ts | 219 ++++++++++++++++-- .../src/server/interaction-projection.ts | 10 + 3 files changed, 251 insertions(+), 24 deletions(-) diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index 7fa6da931c..8aa44302e4 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -119,6 +119,52 @@ describe('HostInteractionCoordinator', () => { }); }); + test('commits a Client Capability approval and Session Grant before resuming the caller', async () => { + await withStore(async ({ store }) => { + const coordinator = createCoordinator(store); + const owner = coordinator.bindRun(RUN); + const target = { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser' as const, + scope: { kind: 'browser_origin' as const, origin: 'https://example.com' }, + }; + const approval = coordinator.requestClientCapabilityApproval({ + ...RUN, + toolCallId: 'browser-call-1', + target, + }); + let pending = await store.listSessionPending(RUN.sessionId); + while (pending.length === 0) { + await new Promise((resolve) => setImmediate(resolve)); + pending = await store.listSessionPending(RUN.sessionId); + } + const request = pending[0]; + assert.equal(request?.request.kind, 'client_capability'); + assert.ok(request); + + const answered = await coordinator.handlers['interaction.answer']( + { + sessionId: RUN.sessionId, + interactionId: request.requestId, + answer: { kind: 'client_capability', decision: 'allow' }, + }, + connection(), + ); + assert.equal(answered.ok, true); + assert.equal(await approval, 'allow'); + assert.ok( + await store.readClientCapabilitySessionGrant({ sessionId: RUN.sessionId, ...target }), + ); + + await owner.close('turn_terminal'); + owner.release(); + await coordinator.close(); + }); + }); + test('settles a sandbox boundary once through the canonical boundary Store and wakes its graph', async () => { await withStore(async ({ owner, store, stores }) => { const workspace = join(owner.capability.canonicalPath, 'workspace'); diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index 4ef15e9442..c0911419dd 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -17,13 +17,20 @@ * under the License. */ +import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; +import type { + ClientCapabilityGrantTarget, + ClientCapabilitySessionGrant, +} from '@maka/core/client-capability-grant'; import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent } from '@maka/core/events'; import { isInteractionAnswerValidForRequest, + projectInteractionClientCapabilityRequest, projectInteractionSandboxBoundaryRequest, projectInteractionQuestionRequest, type InteractionCanonicalOutcome, + type InteractionClosureReason, } from '@maka/core/interaction'; import type { SandboxBoundaryRequest, @@ -58,6 +65,7 @@ import { } from '../protocol/index.js'; import { answerOutcome, + clientCapabilityCanonicalOutcome, compareStoredInteractionRequests, projectInteractionRecord, projectSandboxBoundaryInteraction, @@ -126,10 +134,22 @@ interface LiveSandboxBoundaryEntry extends LiveEntryBase { readonly continuation: RuntimeSandboxBoundaryContinuation; } -type LiveEntry = LiveQuestionEntry | LiveSandboxBoundaryEntry; +interface LiveClientCapabilityEntry extends LiveEntryBase { + readonly kind: 'client_capability'; + readonly request: StoredInteractionRequest; + readonly decision: Promise<'allow' | 'deny'>; + readonly resolve: (decision: 'allow' | 'deny') => void; + readonly reject: (error: unknown) => void; +} + +type LiveStoredEntry = LiveQuestionEntry | LiveClientCapabilityEntry; +type LiveEntry = LiveStoredEntry | LiveSandboxBoundaryEntry; +type LiveStoredCandidate = + | Omit + | Omit; interface CommittedEntry { - readonly entry: LiveQuestionEntry; + readonly entry: LiveStoredEntry; readonly outcome: StoredInteractionOutcome; } @@ -138,6 +158,21 @@ interface SettledSandboxBoundaryEntry { readonly settlement: SandboxBoundarySettlement; } +export interface ClientCapabilityApprovalInput { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly toolCallId: string; + readonly target: ClientCapabilityGrantTarget; +} + +export class ClientCapabilityApprovalClosedError extends Error { + constructor(readonly reason: InteractionClosureReason) { + super(`Client Capability approval closed: ${reason}`); + this.name = 'ClientCapabilityApprovalClosedError'; + } +} + /** Host-epoch authority for durable Runtime Interactions. */ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { readonly handlers: InteractionOperationHandlerMap = { @@ -208,6 +243,40 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { }); } + async requestClientCapabilityApproval( + input: ClientCapabilityApprovalInput, + ): Promise<'allow' | 'deny'> { + this.#throwIfPoisoned(); + const run = this.#runs.get(runKey(input)); + if (!run || !run.bound || run.released) { + throw new RuntimeInteractionAdmissionRejectedError(input.toolCallId, 'invalid_request'); + } + this.#assertRunOpen(run, input.toolCallId); + const requestId = randomUUID(); + let resolveDecision!: (decision: 'allow' | 'deny') => void; + let rejectDecision!: (error: unknown) => void; + const decision = new Promise<'allow' | 'deny'>((resolve, reject) => { + resolveDecision = resolve; + rejectDecision = reject; + }); + await this.#accept(run, { + kind: 'client_capability', + request: { + ...runIdentity(run), + requestId, + createdAt: this.#now(), + request: projectInteractionClientCapabilityRequest({ + toolUseId: input.toolCallId, + target: input.target, + }), + }, + decision, + resolve: resolveDecision, + reject: rejectDecision, + }); + return decision; + } + beginDrain(): void { this.#accepting = false; } @@ -395,7 +464,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } - #accept(run: BoundRun, candidate: Omit): Promise { + #accept(run: BoundRun, candidate: LiveStoredCandidate): Promise { this.#throwIfPoisoned(); this.#assertRunOpen(run, candidate.request.requestId); if (!this.#accepting) { @@ -413,7 +482,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ), ); } - const entry: LiveQuestionEntry = { ...candidate, run, phase: 'admitting' }; + const entry: LiveStoredEntry = { ...candidate, run, phase: 'admitting' }; this.#live.set(entry.request.requestId, entry); return observed( this.#sessionAdmission @@ -426,7 +495,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } async #establishAdmitted( - entry: LiveQuestionEntry, + entry: LiveStoredEntry, admission: SessionAdmissionLease, ): Promise { this.#throwIfPoisoned(); @@ -636,7 +705,9 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { const record = await this.#readInteraction(input.interactionId); if (record) { if (record.request.sessionId !== input.sessionId) return interactionNotFound(); - return this.#answerQuestion(record, input.answer, admission); + return record.request.request.kind === 'client_capability' + ? this.#answerClientCapability(record, input.answer, admission) + : this.#answerQuestion(record, input.answer, admission); } const sandboxBoundary = await this.#readSandboxBoundary( input.sessionId, @@ -720,6 +791,39 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { return { ok: true, result } as const; } + async #answerClientCapability( + record: InteractionRecord, + answer: InteractionAnswerInput['answer'], + admission: SessionAdmissionLease, + ) { + if (record.outcome) return answerOutcome(recordWithOutcome(record), answer); + if ( + record.request.request.kind !== 'client_capability' || + answer.kind !== 'client_capability' + ) { + return operationConflict('Interaction answer does not match the pending request'); + } + if (!isInteractionAnswerValidForRequest(record.request.request, answer)) { + return operationConflict('Interaction answer does not match the pending request'); + } + const entry = this.#requireLiveClientCapability(record.request); + const canonical = clientCapabilityCanonicalOutcome(answer, this.#now()); + const grant: ClientCapabilitySessionGrant | undefined = + answer.decision === 'allow' + ? { + version: 1, + sessionId: record.request.sessionId, + ...record.request.request.target, + grantedAt: canonical.committedAt, + } + : undefined; + const outcome = await this.#commitClientCapabilityOutcome(entry.request, canonical, grant); + await this.#refreshCanonicalContinuity(entry.request.sessionId, admission); + this.#throwIfPoisoned(); + await this.#applyAndDelete(entry, outcome); + return answerOutcome({ request: record.request, outcome }, answer); + } + async #commitAnswer( entry: LiveQuestionEntry, candidate: Extract, @@ -795,25 +899,36 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { await Promise.all( [...this.#live.values()] .filter((entry) => entry.run === run && entry.phase === 'live') - .map((entry) => entry.continuation.waitForPublication()), + .map((entry) => + entry.kind === 'client_capability' + ? Promise.resolve() + : entry.continuation.waitForPublication(), + ), ); this.#throwIfPoisoned(); for (const entry of [...this.#live.values()]) { - if (entry.kind === 'question' && entry.run === run && entry.phase === 'admitting') { + if (entry.kind !== 'sandbox_boundary' && entry.run === run && entry.phase === 'admitting') { this.#discardAdmitting(entry); } } const pending = await this.#readPending(runIdentity(run)); const committed: CommittedEntry[] = []; for (const request of pending.sort(compareStoredInteractionRequests)) { - const entry = this.#requireLiveQuestion(request); + const entry = + request.request.kind === 'client_capability' + ? this.#requireLiveClientCapability(request) + : this.#requireLiveQuestion(request); + const closureOutcome = { + kind: 'closure' as const, + reason: closure.reason, + committedAt: this.#now(), + }; committed.push({ entry, - outcome: await this.#commitOutcome(request, { - kind: 'closure', - reason: closure.reason, - committedAt: this.#now(), - }), + outcome: + entry.kind === 'client_capability' + ? await this.#commitClientCapabilityOutcome(request, closureOutcome) + : await this.#commitOutcome(request, closureOutcome), }); } const settledSandboxBoundaries: SettledSandboxBoundaryEntry[] = []; @@ -933,11 +1048,16 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } await this.#sessionAdmission.run(sessionId, async (admission) => { for (const request of requests.questions.sort(compareStoredInteractionRequests)) { - await this.#commitOutcome(request, { - kind: 'closure', - reason: 'host_restarted', + const closure = { + kind: 'closure' as const, + reason: 'host_restarted' as const, committedAt: this.#now(), - }); + }; + if (request.request.kind === 'client_capability') { + await this.#commitClientCapabilityOutcome(request, closure); + } else { + await this.#commitOutcome(request, closure); + } } for (const request of requests.sandboxBoundaries) { await this.#settleSandboxBoundary({ @@ -1003,6 +1123,28 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { return result.record.outcome; } + async #commitClientCapabilityOutcome( + request: StoredInteractionRequest, + candidate: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ): Promise { + this.#throwIfPoisoned(); + let result: CommitInteractionOutcomeResult; + try { + result = await this.#store.commitClientCapabilityOutcome(request.requestId, candidate, grant); + } catch (error) { + throw this.#poison(error); + } + this.#throwIfPoisoned(); + if (result.status !== 'stable') throw this.#poison(result.failure); + this.#assertExactRequest(request, result.record.request); + this.#assertOutcomeIdentity(request, result.record.outcome); + return result.record.outcome; + } + async #readInteraction(requestId: string): Promise { this.#throwIfPoisoned(); try { @@ -1085,10 +1227,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } - async #applyAndDelete( - entry: LiveQuestionEntry, - outcome: StoredInteractionOutcome, - ): Promise { + async #applyAndDelete(entry: LiveStoredEntry, outcome: StoredInteractionOutcome): Promise { this.#throwIfPoisoned(); if (this.#live.get(entry.request.requestId) !== entry || entry.phase !== 'live') { throw this.#poison( @@ -1097,6 +1236,21 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ), ); } + if (entry.kind === 'client_capability') { + this.#live.delete(entry.request.requestId); + if (outcome.outcome.kind === 'closure') { + entry.reject(new ClientCapabilityApprovalClosedError(outcome.outcome.reason)); + } else if (outcome.outcome.kind === 'client_capability_decision') { + entry.resolve(outcome.outcome.decision); + } else { + throw this.#poison( + new RuntimeInteractionInvariantError( + `Client Capability Interaction ${entry.request.requestId} has an invalid outcome`, + ), + ); + } + return; + } try { const projected = runtimeQuestionOutcome(outcome.outcome); if (projected.kind === 'closure') { @@ -1179,6 +1333,23 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { return entry; } + #requireLiveClientCapability(request: StoredInteractionRequest): LiveClientCapabilityEntry { + const entry = this.#live.get(request.requestId); + if ( + !entry || + entry.kind !== 'client_capability' || + entry.phase !== 'live' || + !isDeepStrictEqual(entry.request, request) + ) { + throw this.#poison( + new RuntimeInteractionInvariantError( + `Pending Client Capability Interaction ${request.requestId} has no exact live continuation`, + ), + ); + } + return entry; + } + #requireLiveSandboxBoundary(request: SandboxBoundaryRequest): LiveSandboxBoundaryEntry { const entry = this.#live.get(request.requestId); if ( @@ -1264,7 +1435,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } - #discardAdmitting(entry: LiveQuestionEntry): void { + #discardAdmitting(entry: LiveStoredEntry): void { if (entry.phase !== 'admitting' || this.#live.get(entry.request.requestId) !== entry) { throw this.#poison( new RuntimeInteractionInvariantError( @@ -1275,7 +1446,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { this.#live.delete(entry.request.requestId); } - #discardClosedAdmission(entry: LiveQuestionEntry): void { + #discardClosedAdmission(entry: LiveStoredEntry): void { const owned = this.#live.get(entry.request.requestId); if (owned === undefined) return; if (owned !== entry) { diff --git a/packages/runtime-host/src/server/interaction-projection.ts b/packages/runtime-host/src/server/interaction-projection.ts index 672533b843..fe4e60a4ab 100644 --- a/packages/runtime-host/src/server/interaction-projection.ts +++ b/packages/runtime-host/src/server/interaction-projection.ts @@ -169,6 +169,13 @@ export function questionCanonicalOutcome( return { kind: 'question_answer', answers: [...answer.answers], committedAt }; } +export function clientCapabilityCanonicalOutcome( + answer: Extract, + committedAt: number, +): Extract { + return { kind: 'client_capability_decision', decision: answer.decision, committedAt }; +} + export function runtimeQuestionOutcome( outcome: InteractionCanonicalOutcome, ): RuntimeUserQuestionOutcome { @@ -232,6 +239,9 @@ function canonicalOutcomeForHistoricalAnswer( 'Sandbox boundary answers require their canonical boundary settlement', ); } + if (answer.kind === 'client_capability') { + return clientCapabilityCanonicalOutcome(answer, committedAt); + } return answer.decision === 'deny' ? { kind: 'permission_answer', From 7acef858ff7c3e427ae033f27e0c15ca2a1067ac Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:21:09 +0800 Subject: [PATCH 08/30] feat(runtime): prepare client capabilities before durable dispatch Generated-by: OpenAI Codex --- .../runtime/src/__tests__/mcp-tools.test.ts | 53 +++++ .../__tests__/tool-runtime-settlement.test.ts | 40 ++++ packages/runtime/src/mcp-tools.ts | 44 +++- packages/runtime/src/tool-runtime.ts | 204 +++++++++++------- 4 files changed, 268 insertions(+), 73 deletions(-) diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index b3ffdeb2b3..0e38eacb7a 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -243,6 +243,59 @@ test('a trusted composition can apply the Client Capability permission floor and }); }); +test('MCP preparation keeps provider admission separate from execution', async () => { + const toolBinding = binding('prepared-binding'); + const order: string[] = []; + const provider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [boundTool(descriptor('client', 'inspect', true), toolBinding)], + }), + prepareTool: async (_binding, _args, options) => { + order.push(`prepare:${options.context.permissionMode}`); + return { + execute: async () => { + order.push('execute'); + return { content: [{ type: 'text', text: 'ok' }] }; + }, + cancel: () => { + order.push('cancel'); + }, + }; + }, + callTool: async () => assert.fail('Prepared MCP tools must not call the direct path'), + }; + const [tool] = buildMcpTools(provider, { categoryHint: 'client_capability' }); + assert.ok(tool?.prepareExecution); + const boundary = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); + const prepared = await tool.prepareExecution( + {}, + { + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + executionBoundary: boundary, + permissionMode: 'ask', + toolCallId: 'tool-call', + abortSignal: new AbortController().signal, + }, + ); + assert.deepEqual( + await prepared.execute({ + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + executionBoundary: boundary, + permissionMode: 'ask', + toolCallId: 'tool-call', + abortSignal: new AbortController().signal, + emitOutput() {}, + }), + { content: [{ type: 'text', text: 'ok' }] }, + ); + assert.deepEqual(order, ['prepare:ask', 'execute']); +}); + test('a trusted composition can preserve provider-owned activity semantics', () => { const [tool] = buildMcpTools( fakeProvider( diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 65144a1b80..ff053c2af1 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -80,6 +80,46 @@ describe('ToolRuntime settlement', () => { assert.deepEqual(allowed.result, { ok: true }); }); + it('admits a prepared Client Capability in Ask mode before executing it', async () => { + const order: string[] = []; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + categoryHint: 'client_capability', + prepareExecution: async () => { + order.push('prepare'); + return { + execute: async () => { + order.push('execute'); + return { ok: true }; + }, + cancel: () => { + order.push('cancel'); + }, + }; + }, + impl: () => assert.fail('Prepared Client Capability must use its prepared execution'), + }; + const settlement = await makeRuntime({ + readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), + }).settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-managed-prepared', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => {}, + pushAndWaitUntilConsumed: async () => {}, + }, + }); + + assert.deepEqual(order, ['prepare', 'execute']); + assert.deepEqual(settlement.result, { ok: true }); + }); + it('keeps the durable Bash command while omitting it from the durable projection', async () => { const runtime = makeRuntime(); const events: SessionEvent[] = []; diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index fce515ad42..45bfd25e42 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -26,7 +26,8 @@ import type { McpToolDescriptor, McpToolSnapshot, } from '@maka/core/mcp'; -import type { ToolCategory } from '@maka/core/permission'; +import type { PermissionMode, ToolCategory } from '@maka/core/permission'; +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { ToolRecoveryMode } from '@maka/core/runtime-event'; import type { ToolResultContentPart, ToolResultOutput } from './model-protocol.js'; import type { MakaTool } from './tool-runtime.js'; @@ -41,6 +42,11 @@ const TRUNCATION_MARKER = '\n…[truncated by Maka]'; export interface McpToolProvider { toolSnapshot(): McpToolSnapshot; + prepareTool?( + binding: McpToolBinding, + args: Record, + options: Omit, + ): Promise; callTool( binding: McpToolBinding, args: Record, @@ -48,6 +54,13 @@ export interface McpToolProvider { ): Promise; } +export interface McpPreparedToolCall { + execute(options?: { + readonly emitProgress?: (current: number, total: number) => void; + }): Promise; + cancel(): Promise | void; +} + export interface McpToolCallOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number; @@ -60,6 +73,8 @@ export interface McpToolInvocationContext { readonly turnId: string; readonly toolCallId: string; readonly cwd: string; + readonly executionBoundary?: ExecutionBoundary; + readonly permissionMode?: PermissionMode; } export interface BuildMcpToolsOptions { @@ -97,6 +112,33 @@ export function buildMcpTools( categoryHint: options.categoryHint ?? 'network_send', ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), parameters: jsonSchema(descriptor.inputSchema), + ...(provider.prepareTool + ? { + prepareExecution: async (args: unknown, context) => { + const prepared = await provider.prepareTool!(binding, asArguments(args), { + signal: context.abortSignal, + timeoutMs: options.callTimeoutMs, + context: { + sessionId: context.sessionId, + turnId: context.turnId, + toolCallId: context.toolCallId, + cwd: context.cwd, + executionBoundary: context.executionBoundary, + permissionMode: context.permissionMode, + }, + }); + return { + execute: (executionContext) => + prepared.execute({ + ...(executionContext.emitProgress + ? { emitProgress: executionContext.emitProgress } + : {}), + }), + cancel: () => prepared.cancel(), + }; + }, + } + : {}), impl: async (args: unknown, context) => { // Managed network authority applies equally to Direct and nested CodeMode dispatch. if ( diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 5fe9493be9..656eb3f667 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -151,6 +151,23 @@ export interface ToolSettlement { providerError?: string; } +export type MakaToolPreparationContext = Pick< + MakaToolContext, + | 'sessionId' + | 'runId' + | 'turnId' + | 'cwd' + | 'executionBoundary' + | 'permissionMode' + | 'toolCallId' + | 'abortSignal' +>; + +export interface PreparedMakaToolExecution { + execute(context: MakaToolContext): Promise | R; + cancel(): Promise | void; +} + export interface MakaTool

{ /** Canonical (Claude-SDK-style) name. Pi adapter translates to canonical. */ name: string; @@ -193,6 +210,11 @@ export interface MakaTool

{ args: P, context: Pick, ) => unknown; + /** Optional preparation that settles before ToolRuntime crosses its durable T1 cut. */ + prepareExecution?: ( + args: P, + context: MakaToolPreparationContext, + ) => Promise>; /** * Real tool implementation. Implementations must observe `ctx.abortSignal` and * settle promptly after it aborts. Runtime-owned nested calls await this @@ -1383,6 +1405,7 @@ export class ToolRuntime { } let clientCapabilityBoundary: ExecutionBoundary | undefined; + let preparedExecution: PreparedMakaToolExecution | undefined; if (tool.categoryHint === 'client_capability') { try { clientCapabilityBoundary = await this.readExecutionBoundary(); @@ -1398,7 +1421,10 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(reason); } - if (clientCapabilityBoundary.kind !== 'bypass') { + if ( + clientCapabilityBoundary.kind !== 'bypass' && + (this.input.header.permissionMode !== 'ask' || !tool.prepareExecution) + ) { await refuseBeforeDispatch(CLIENT_CAPABILITY_BOUNDARY_MESSAGE, { reason: 'requires_bypass', source: 'client_capability', @@ -1412,6 +1438,35 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(CLIENT_CAPABILITY_BOUNDARY_MESSAGE); } + if (tool.prepareExecution) { + const pauseTarget = this.input.getPermissionPauseTarget(); + pauseTarget?.pause(); + try { + preparedExecution = await tool.prepareExecution(structuredClone(executionArgs) as never, { + sessionId: this.input.sessionId, + turnId, + ...(runId ? { runId } : {}), + cwd: this.input.header.cwd, + executionBoundary: clientCapabilityBoundary, + permissionMode: this.input.header.permissionMode, + toolCallId: toolUseId, + abortSignal: ctx.abortSignal, + }); + } catch (error) { + const reason = formatSyntheticToolErrorText(error); + await refuseBeforeDispatch(reason); + trace?.emit('tool', 'tool_failed', 'Client Capability preparation failed', { + toolUseId, + toolName: tool.name, + status: 'error', + errorClass: 'ClientCapabilityPreparation', + }); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(reason); + } finally { + pauseTarget?.resume(); + } + } } let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; @@ -1446,6 +1501,7 @@ export class ToolRuntime { const reservedSubagentSlot = this.reserveSubagentSlot(tool); if (!reservedSubagentSlot) { + await preparedExecution?.cancel(); await disposeManagedMutationAdmission(managedMutationAdmission); trace?.emit('tool', 'tool_failed', 'Tool execution rejected by runtime limit', { toolUseId, @@ -1473,6 +1529,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), }); } catch (error) { + await preparedExecution?.cancel(); if (reservedSubagentSlot) this.releaseSubagentSlot(tool); await disposeManagedMutationAdmission(managedMutationAdmission); throw error; @@ -1514,82 +1571,85 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); - const invokeTool = () => - tool.impl(structuredClone(executionArgs) as never, { - sessionId: this.input.sessionId, + const toolContext: MakaToolContext = { + sessionId: this.input.sessionId, + turnId, + ...(runId ? { runId } : {}), + ...(this.input.orchestrationMode + ? { orchestrationMode: this.input.orchestrationMode } + : {}), + cwd: this.input.header.cwd, + executionBoundary, + permissionMode: this.input.header.permissionMode, + toolCallId: toolUseId, + // The id the call event actually carries, not the candidate: by here + // `prepareDurableToolAttempt` has pushed it on the dispatch lane. + ...(callEvent?.operationId ? { operationId: callEvent.operationId } : {}), + abortSignal: ctx.abortSignal, + emitOutput: output.emit, + emitProgress: (current, total) => { + const chunk = encodeToolStepProgress({ current, total }); + if (!chunk) return; + queue.push({ + type: 'tool_progress', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + chunk, + ...activityIdentity, + }); + }, + ...(trace + ? { + emitRunTrace: ( + type: + | 'tool_started' + | 'tool_searched' + | 'tool_completed' + | 'tool_failed' + | 'skill_searched' + | 'skill_loaded' + | 'skill_load_failed', + message: string, + data?: Record, + ) => + trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, { + toolUseId, + toolName: tool.name, + ...(data ?? {}), + }), + } + : {}), + ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), + ...(this.input.readChildAgentOutput + ? { readChildAgentOutput: this.input.readChildAgentOutput } + : {}), + ...this.buildChildAgentContext({ turnId, - ...(runId ? { runId } : {}), - ...(this.input.orchestrationMode - ? { orchestrationMode: this.input.orchestrationMode } - : {}), - cwd: this.input.header.cwd, - executionBoundary, - permissionMode: this.input.header.permissionMode, - toolCallId: toolUseId, - // The id the call event actually carries, not the candidate: by here - // `prepareDurableToolAttempt` has pushed it on the dispatch lane. - ...(callEvent?.operationId ? { operationId: callEvent.operationId } : {}), abortSignal: ctx.abortSignal, - emitOutput: output.emit, - emitProgress: (current, total) => { - const chunk = encodeToolStepProgress({ current, total }); - if (!chunk) return; - queue.push({ - type: 'tool_progress', - id: this.input.newId(), - turnId, - ts: this.input.now(), - toolUseId, - chunk, - ...activityIdentity, - }); - }, - ...(trace - ? { - emitRunTrace: ( - type: - | 'tool_started' - | 'tool_searched' - | 'tool_completed' - | 'tool_failed' - | 'skill_searched' - | 'skill_loaded' - | 'skill_load_failed', - message: string, - data?: Record, - ) => - trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, { - toolUseId, - toolName: tool.name, - ...(data ?? {}), - }), - } - : {}), - ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), - ...(this.input.readChildAgentOutput - ? { readChildAgentOutput: this.input.readChildAgentOutput } - : {}), - ...this.buildChildAgentContext({ + trace, + toolUseId, + toolName: tool.name, + queue, + activityIdentity, + }), + askUserQuestion: (questions) => + this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), + requestSandboxBoundary: (expansion, justification) => + this.requestSandboxBoundary( turnId, - abortSignal: ctx.abortSignal, - trace, toolUseId, - toolName: tool.name, + expansion, + justification, + ctx.abortSignal, queue, - activityIdentity, - }), - askUserQuestion: (questions) => - this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), - requestSandboxBoundary: (expansion, justification) => - this.requestSandboxBoundary( - turnId, - toolUseId, - expansion, - justification, - ctx.abortSignal, - queue, - ), - }); + ), + }; + const invokeTool = () => + preparedExecution + ? preparedExecution.execute(toolContext) + : tool.impl(structuredClone(executionArgs) as never, toolContext); const invokeManagedTransform = () => tool.managedMutationTransform!(structuredClone(executionArgs) as never); const prepareOperationValue = async ( From d0220a0a6a76b90ba91e3bf9bdf6548840d8a7f7 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:44:55 +0800 Subject: [PATCH 09/30] fix(storage): share capability grants by scope Generated-by: OpenAI Codex --- ...ent-capability-session-grant-store.test.ts | 7 ++++ packages/storage/src/interaction-store.ts | 35 ++++++++++--------- .../src/sqlite-core-execution-schema.ts | 3 +- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts index eb78459875..888fecbbdb 100644 --- a/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts +++ b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts @@ -80,6 +80,13 @@ test('persists Client Capability grants for one Session and purges them with it' ); assert.equal(committed.status, 'stable'); assert.deepEqual(await store.readClientCapabilitySessionGrant(key), grant); + assert.deepEqual( + await store.readClientCapabilitySessionGrant({ + ...key, + toolName: 'browser_click', + }), + grant, + ); const operationalState = createConversationOperationalStateStore(root); try { diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index f16b75e340..53b1260793 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -496,8 +496,6 @@ class SqliteInteractionStore implements InteractionStoreWriter { WHERE session_id = ? AND provider_id = ? AND contract_id = ? - AND server_id = ? - AND tool_name = ? AND capability = ? AND scope_kind = ? AND scope_value = ? @@ -506,8 +504,6 @@ class SqliteInteractionStore implements InteractionStoreWriter { candidate.sessionId, candidate.providerId, candidate.contractId, - candidate.serverId, - candidate.toolName, candidate.capability, candidate.scope.kind, scope, @@ -520,7 +516,7 @@ class SqliteInteractionStore implements InteractionStoreWriter { parseJsonRecord(row.record_json, 'Client Capability Session Grant'), 'record', ); - if (!isDeepStrictEqual(decodeGrantKey(grant, 'record'), candidate)) { + if (!sameGrantAuthority(decodeGrantKey(grant, 'record'), candidate)) { throw new InteractionStoreError( 'invalid_record', 'Client Capability Session Grant identity does not match row', @@ -593,19 +589,12 @@ class SqliteInteractionStore implements InteractionStoreWriter { SELECT record_json FROM core_client_capability_session_grants WHERE session_id = ? AND provider_id = ? AND contract_id = ? - AND server_id = ? AND tool_name = ? AND capability = ? + AND capability = ? AND scope_kind = ? AND scope_value = ? `) - .get( - key.sessionId, - key.providerId, - key.contractId, - key.serverId, - key.toolName, - key.capability, - key.scope.kind, - scope, - ) as { record_json?: unknown } | undefined; + .get(key.sessionId, key.providerId, key.contractId, key.capability, key.scope.kind, scope) as + | { record_json?: unknown } + | undefined; if (!row) return undefined; if (typeof row.record_json !== 'string') { throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); @@ -620,6 +609,20 @@ class SqliteInteractionStore implements InteractionStoreWriter { } } +function sameGrantAuthority( + left: ClientCapabilitySessionGrantKey, + right: ClientCapabilitySessionGrantKey, +): boolean { + return ( + left.sessionId === right.sessionId && + left.providerId === right.providerId && + left.contractId === right.contractId && + left.capability === right.capability && + left.scope.kind === right.scope.kind && + clientCapabilityScopeIdentity(left.scope) === clientCapabilityScopeIdentity(right.scope) + ); +} + function readSqliteInteraction( lease: Pick, requestId: string, diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index 83554fb5e7..9f49a23c19 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -124,8 +124,7 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { granted_at INTEGER NOT NULL, record_json TEXT NOT NULL, PRIMARY KEY ( - session_id, provider_id, contract_id, server_id, tool_name, - capability, scope_kind, scope_value + session_id, provider_id, contract_id, capability, scope_kind, scope_value ) ); From 86ec29820741c051010d11f18eed8294ef541f0d Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:45:25 +0800 Subject: [PATCH 10/30] feat(runtime-host): authorize managed client capabilities Generated-by: OpenAI Codex --- .../tui-mcp-local-integration.test.ts | 2 + .../client-capability-coordinator.test.ts | 423 ++++++++++++++---- ...lient-capability-invocation-broker.test.ts | 30 ++ .../__tests__/client-capability-uds.test.ts | 2 + .../execution-model-composition.test.ts | 7 +- .../__tests__/fixtures/client-capability.ts | 21 + .../src/__tests__/oauth-coordinator.test.ts | 6 +- .../__tests__/oauth-two-client-uds.test.ts | 3 + .../__tests__/root-turn-coordinator.test.ts | 13 +- .../server/client-capability-coordinator.ts | 220 ++++++++- .../client-capability-invocation-broker.ts | 34 +- .../src/server/execution-composition.ts | 2 + .../src/test-only/client-capability-host.ts | 24 + packages/runtime/src/mcp-tools.ts | 2 + 14 files changed, 667 insertions(+), 122 deletions(-) diff --git a/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts index c564df2daf..aab5ec79ff 100644 --- a/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts @@ -38,6 +38,7 @@ import { import { HostClientCapabilityCoordinator, RuntimePolicyActivationGate, + clientCapabilityCoordinatorTestAdmission, type ClientCapabilitySnapshot, } from '@maka/runtime-host/test-only/client-capability-host'; import { createMcpConfigStore } from '@maka/storage/mcp-config-store'; @@ -71,6 +72,7 @@ test('local TUI discovers, publishes, invokes, republishes, and closes one MCP c idleGraceMs: 60_000, composition: defineInteractiveRuntimeHostComposition(async () => { coordinator = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index f99a1aedf0..7c2b5de07c 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -27,10 +27,14 @@ import type { ClientCapabilityReplaceInput } from '../protocol/index.js'; import { ClientCapabilityInvocationError, HostClientCapabilityCoordinator, + type HostClientCapabilityCoordinatorOptions, } from '../server/client-capability-coordinator.js'; import type { ClientCapabilityConnection } from '../server/client-capability-service.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; -import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js'; +import { + clientCapabilityConnectionIdentity, + clientCapabilityCoordinatorTestAdmission, +} from './fixtures/client-capability.js'; describe('Host Client Capability coordinator', () => { test('freezes active snapshots across replacement and releases stale registrations', async () => { @@ -40,19 +44,27 @@ describe('Host Client Capability coordinator', () => { connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { send: async (frame) => { sent.push(frame); - if (frame.kind !== 'client.capability.call') return; - queueMicrotask(() => { + if (frame.kind === 'client.capability.call') { connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, admissionEvidence: { kind: 'none' }, }); + return; + } + if (frame.kind === 'client.capability.admitted') { + const call = sent.find( + (candidate) => + isRecord(candidate) && + candidate.kind === 'client.capability.call' && + candidate.invocationId === frame.invocationId, + ); connection.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, - result: textResult(`called:${frame.toolName}`), + result: textResult(`called:${isRecord(call) ? String(call.toolName) : 'unknown'}`), }); - }); + } }, }); @@ -125,18 +137,20 @@ describe('Host Client Capability coordinator', () => { let connection!: ClientCapabilityConnection; connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - observedCall = frame; - connection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - connection.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('path independent'), - }); + if (frame.kind === 'client.capability.call') { + observedCall = frame; + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('path independent'), + }); + } }, }); const replaced = await coordinator.handlers['client.capability.replace']( @@ -189,18 +203,20 @@ describe('Host Client Capability coordinator', () => { ), { send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - observedCall = frame; - connection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - connection.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('remote result'), - }); + if (frame.kind === 'client.capability.call') { + observedCall = frame; + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('remote result'), + }); + } }, }, ); @@ -268,9 +284,190 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); - test('reports capability_lost before acceptance and outcome_unknown after acceptance', async () => { - await assertLossClassification(false, 'capability_lost'); - await assertLossClassification(true, 'outcome_unknown'); + test('approves a trusted Browser origin once and reuses the Session Grant across tools', async () => { + let approvedTarget: + | Parameters< + HostClientCapabilityCoordinatorOptions['interactions']['requestClientCapabilityApproval'] + >[0]['target'] + | undefined; + let approvalCount = 0; + const coordinator = createCoordinator(() => undefined, { + interactions: { + requestClientCapabilityApproval: async ({ target }) => { + approvalCount += 1; + approvedTarget = target; + return 'allow'; + }, + }, + grants: { + readClientCapabilitySessionGrant: async (key) => + approvedTarget && + approvedTarget.providerId === key.providerId && + approvedTarget.contractId === key.contractId && + approvedTarget.capability === key.capability && + approvedTarget.scope.kind === 'browser_origin' && + key.scope.kind === 'browser_origin' && + approvedTarget.scope.origin === key.scope.origin + ? { version: 1, ...key, grantedAt: 1 } + : undefined, + }, + }); + const sent: unknown[] = []; + let connection!: ClientCapabilityConnection; + connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'connection-a', + 'desktop-a', + 'test-principal', + 'capability_provider', + ), + { + send: async (frame) => { + sent.push(frame); + if (frame.kind === 'client.capability.call') { + const requestedUrl = + frame.toolName === 'browser_navigate' && typeof frame.arguments.url === 'string' + ? frame.arguments.url + : 'https://example.com/current'; + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'browser_url', url: requestedUrl }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('done'), + }); + } + }, + }, + ); + const replaced = await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-browser', + offers: [ + { + offerId: 'desktop_browser', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Browser', + tools: ['browser_snapshot', 'browser_click', 'browser_navigate'].map((name) => ({ + serverId: 'desktop_browser', + name, + inputSchema: { type: 'object' }, + })), + }, + ], + }, + connectionContext('connection-a'), + ); + assert.equal(replaced.ok, true); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.ok(snapshot); + const tools = new Map(snapshot.tools.map((tool) => [tool.displayName, tool])); + + const preparedSnapshot = await prepare(tools.get('browser_snapshot'), {}, 'tool-snapshot'); + assert.equal(approvalCount, 1); + assert.equal( + sent.some((frame) => isRecord(frame) && frame.kind === 'client.capability.admitted'), + false, + ); + assert.deepEqual( + await preparedSnapshot.execute(managedContext('tool-snapshot')), + textResult('done'), + ); + + const preparedClick = await prepare(tools.get('browser_click'), {}, 'tool-click'); + assert.equal(approvalCount, 1); + assert.deepEqual(await preparedClick.execute(managedContext('tool-click')), textResult('done')); + + const preparedNavigate = await prepare( + tools.get('browser_navigate'), + { url: 'https://other.example/path' }, + 'tool-navigate', + ); + assert.equal(approvalCount, 2); + assert.deepEqual( + await preparedNavigate.execute(managedContext('tool-navigate')), + textResult('done'), + ); + + snapshot.release(); + await connection.close(); + await coordinator.close(); + }); + + test('passes trusted Desktop Settings through managed admission without a Session Grant', async () => { + const coordinator = createCoordinator(); + let connection!: ClientCapabilityConnection; + connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'connection-a', + 'desktop-a', + 'test-principal', + 'capability_provider', + ), + { + send: async (frame) => { + if (frame.kind === 'client.capability.call') { + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('settings'), + }); + } + }, + }, + ); + const replaced = await coordinator.handlers['client.capability.replace']( + { + registrationId: 'registration-settings', + offers: [ + { + offerId: 'desktop_settings', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Settings', + tools: [ + { + serverId: 'desktop_settings', + name: 'MakaClientSettingsGet', + inputSchema: { type: 'object' }, + }, + ], + }, + ], + }, + connectionContext('connection-a'), + ); + assert.equal(replaced.ok, true); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.ok(snapshot); + const prepared = await prepare(snapshot.tools[0], {}, 'tool-settings'); + assert.deepEqual( + await prepared.execute(managedContext('tool-settings')), + textResult('settings'), + ); + snapshot.release(); + await connection.close(); + await coordinator.close(); + }); + + test('reports capability_lost before admission and outcome_unknown after admission', async () => { + await assertLossClassification('before_acceptance', 'capability_lost'); + await assertLossClassification('after_admission', 'outcome_unknown'); }); test('prefers the initiating provider and reports otherwise ambiguous selection', async () => { @@ -882,33 +1079,37 @@ describe('Host Client Capability coordinator', () => { let first!: ClientCapabilityConnection; first = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - first.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - first.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('first'), - }); + if (frame.kind === 'client.capability.call') { + first.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + first.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('first'), + }); + } }, }); let second!: ClientCapabilityConnection; second = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-b'), { send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - second.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - second.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('second'), - }); + if (frame.kind === 'client.capability.call') { + second.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + second.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('second'), + }); + } }, }); await replace( @@ -1095,24 +1296,26 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); - test('cancels accepted work as outcome_unknown and ignores a late provider outcome', async () => { + test('cancels admitted work as outcome_unknown and ignores a late provider outcome', async () => { const coordinator = createCoordinator(); const sent: Array<{ kind: string; invocationId?: string }> = []; let connection!: ClientCapabilityConnection; - let callArrived!: () => void; - const receivedCall = new Promise((resolve) => { - callArrived = resolve; + let admittedArrived!: () => void; + const receivedAdmission = new Promise((resolve) => { + admittedArrived = resolve; }); connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { send: async (frame) => { sent.push(frame); - if (frame.kind !== 'client.capability.call') return; - connection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - callArrived(); + if (frame.kind === 'client.capability.call') { + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + admittedArrived(); + } }, }); await replace(coordinator, 'connection-a', 'registration-a', 'opaque'); @@ -1132,7 +1335,7 @@ describe('Host Client Capability coordinator', () => { }, ); assert.ok(pending); - await receivedCall; + await receivedAdmission; abort.abort(new DOMException('Cancelled by test', 'AbortError')); await assert.rejects( Promise.resolve(pending), @@ -1167,6 +1370,7 @@ describe('Host Client Capability coordinator', () => { const cases = [ { name: 'start before acceptance', + afterAdmission: false, transition: (connection: ClientCapabilityConnection, invocationId: string) => { connection.accept({ kind: 'client.capability.result_start', @@ -1175,16 +1379,12 @@ describe('Host Client Capability coordinator', () => { chunkCount: 1, }); }, - message: /chunks started outside the accepted phase/, + message: /chunks started outside the admitted phase/, }, { name: 'chunk before start', + afterAdmission: true, transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ - kind: 'client.capability.accepted', - invocationId, - admissionEvidence: { kind: 'none' }, - }); connection.accept({ kind: 'client.capability.result_chunk', invocationId, @@ -1196,12 +1396,8 @@ describe('Host Client Capability coordinator', () => { }, { name: 'duplicate start', + afterAdmission: true, transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ - kind: 'client.capability.accepted', - invocationId, - admissionEvidence: { kind: 'none' }, - }); connection.accept({ kind: 'client.capability.result_start', invocationId, @@ -1215,16 +1411,12 @@ describe('Host Client Capability coordinator', () => { chunkCount: 1, }); }, - message: /chunks started outside the accepted phase/, + message: /chunks started outside the admitted phase/, }, { name: 'unchunked result after start', + afterAdmission: true, transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ - kind: 'client.capability.accepted', - invocationId, - admissionEvidence: { kind: 'none' }, - }); connection.accept({ kind: 'client.capability.result_start', invocationId, @@ -1237,16 +1429,12 @@ describe('Host Client Capability coordinator', () => { result: textResult('invalid terminal form'), }); }, - message: /result arrived outside the accepted phase/, + message: /result arrived outside the admitted phase/, }, { name: 'out-of-order chunk', + afterAdmission: true, transition: (connection: ClientCapabilityConnection, invocationId: string) => { - connection.accept({ - kind: 'client.capability.accepted', - invocationId, - admissionEvidence: { kind: 'none' }, - }); connection.accept({ kind: 'client.capability.result_start', invocationId, @@ -1270,12 +1458,25 @@ describe('Host Client Capability coordinator', () => { const invocationArrived = new Promise((resolve) => { resolveInvocation = resolve; }); + let resolveAdmission!: () => void; + const admissionArrived = new Promise((resolve) => { + resolveAdmission = resolve; + }); const connection = coordinator.attachConnection( clientCapabilityConnectionIdentity('connection-a'), { send: async (frame) => { if (frame.kind === 'client.capability.call') { resolveInvocation(frame.invocationId); + if (malformed.afterAdmission) { + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } + } else if (frame.kind === 'client.capability.admitted') { + resolveAdmission(); } }, }, @@ -1286,6 +1487,7 @@ describe('Host Client Capability coordinator', () => { assert.ok(snapshot); const pending = invoke(snapshot.tools[0]); const invocationId = await invocationArrived; + if (malformed.afterAdmission) await admissionArrived; try { assert.throws( @@ -1305,22 +1507,26 @@ describe('Host Client Capability coordinator', () => { }); async function assertLossClassification( - accept: boolean, + phase: 'before_acceptance' | 'after_admission', expected: ClientCapabilityInvocationError['code'] | 'outcome_unknown', ): Promise { const coordinator = createCoordinator(); let connection!: ClientCapabilityConnection; connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - if (accept) { + if (frame.kind === 'client.capability.call') { + if (phase === 'before_acceptance') { + connection.close(); + return; + } connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, admissionEvidence: { kind: 'none' }, }); + } else if (frame.kind === 'client.capability.admitted') { + connection.close(); } - connection.close(); }, }); await replace(coordinator, 'connection-a', 'registration-a', 'opaque'); @@ -1340,13 +1546,44 @@ async function assertLossClassification( function createCoordinator( onModelToolsChanged: () => void = () => undefined, + admission: Pick< + HostClientCapabilityCoordinatorOptions, + 'interactions' | 'grants' + > = clientCapabilityCoordinatorTestAdmission(), ): HostClientCapabilityCoordinator { return new HostClientCapabilityCoordinator({ + ...admission, activation: new RuntimePolicyActivationGate(), onModelToolsChanged, }); } +async function prepare( + tool: ReturnType, + args: Record, + toolCallId: string, +) { + assert.ok(tool?.prepareExecution); + return tool.prepareExecution(args, { + ...managedContext(toolCallId), + permissionMode: 'ask', + }); +} + +function managedContext(toolCallId: string) { + return { + sessionId: 'session-a', + runId: 'run-a', + turnId: 'turn-a', + cwd: '/tmp', + toolCallId, + permissionMode: 'ask' as const, + executionBoundary: createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; +} + async function replace( coordinator: HostClientCapabilityCoordinator, connectionId: string, diff --git a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts index 0eccd7ffa6..35a891126f 100644 --- a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts @@ -132,6 +132,36 @@ describe('ClientCapabilityInvocationBroker', () => { ); broker.close(); }); + + test('cancels accepted work without crossing admission', async () => { + const sent: ClientCapabilityHostFrame[] = []; + let broker!: ClientCapabilityInvocationBroker; + broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ + send: async (frame) => { + sent.push(frame); + if (frame.kind === 'client.capability.call') { + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } + }, + }), + onRegistrationIdle: () => {}, + }); + + const prepared = broker.prepare(registration, binding, {}, context, undefined, 1_000); + await prepared.waitUntilAccepted(); + prepared.cancel(); + await assert.rejects(() => prepared.admit(), /cancelled before admission/u); + assert.deepEqual( + sent.map((frame) => frame.kind), + ['client.capability.call', 'client.capability.cancel', 'client.capability.release'], + ); + broker.close(); + }); }); function delay(ms: number): Promise { diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index 198ce8be3d..19366443e7 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -44,6 +44,7 @@ import { type DomainOperationHandlerMap, } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; +import { clientCapabilityCoordinatorTestAdmission } from './fixtures/client-capability.js'; test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-client-capability-')); @@ -63,6 +64,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' idleGraceMs: 60_000, composition: defineInteractiveRuntimeHostComposition(async () => { coordinator = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 46bc8b8e9c..73831437db 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -28,7 +28,10 @@ import { join } from 'node:path'; import { promisify } from 'node:util'; import { test } from 'node:test'; import { z } from 'zod'; -import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js'; +import { + clientCapabilityConnectionIdentity, + clientCapabilityCoordinatorTestAdmission, +} from './fixtures/client-capability.js'; import { createBypassExecutionBoundary, createManagedExecutionBoundary, @@ -1306,6 +1309,7 @@ test('backend creation does not acquire Client Capabilities beyond a bound tool test('production backend creation continues after a Session Client Capability is lost', async () => { const coordinator = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -1371,6 +1375,7 @@ test('production backend preserves coordinator Client Capability semantics acros const trace: RunTraceEvent[] = []; const calls: Array> = []; const coordinator = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); diff --git a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts index 6bdd854295..3ab6adb0f5 100644 --- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts +++ b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts @@ -18,6 +18,7 @@ */ import type { ClientCapabilityConnectionIdentity } from '../../server/client-capability-service.js'; +import type { HostClientCapabilityCoordinatorOptions } from '../../server/client-capability-coordinator.js'; export function clientCapabilityConnectionIdentity( connectionId: string, @@ -36,3 +37,23 @@ export function clientCapabilityConnectionIdentity( ...(capabilityOwner ? { capabilityOwner } : {}), }; } + +export function clientCapabilityCoordinatorTestAdmission(): Pick< + HostClientCapabilityCoordinatorOptions, + 'interactions' | 'grants' +> { + return { + interactions: { + requestClientCapabilityApproval: async () => { + throw new Error('Unexpected Client Capability approval request'); + }, + }, + grants: { + readClientCapabilitySessionGrant: async (key) => ({ + version: 1, + ...key, + grantedAt: 0, + }), + }, + }; +} diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts index 89050b8a1a..8d34132ca6 100644 --- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts @@ -43,7 +43,10 @@ import { import { HostClientCapabilityCoordinator } from '../server/client-capability-coordinator.js'; import { HostOAuthCoordinator } from '../server/oauth-coordinator.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; -import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js'; +import { + clientCapabilityConnectionIdentity, + clientCapabilityCoordinatorTestAdmission, +} from './fixtures/client-capability.js'; const NOW = 1_800_000_000_000; @@ -1015,6 +1018,7 @@ async function withFixture( assert.ok(connection); const activation = new RuntimePolicyActivationGate(); const capabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation, onModelToolsChanged: () => undefined, }); diff --git a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts index 9e0ad0a09e..4197479b8b 100644 --- a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts @@ -42,6 +42,7 @@ import { type DomainOperationHandlerMap, } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; +import { clientCapabilityCoordinatorTestAdmission } from './fixtures/client-capability.js'; test('two OAuth creates bind distinct entities and present only on their initiating Clients', { timeout: 30_000, @@ -77,6 +78,7 @@ test('two OAuth creates bind distinct entities and present only on their initiat composition: defineInteractiveRuntimeHostComposition(async (context) => { const activation = new RuntimePolicyActivationGate(); const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation, onModelToolsChanged: () => undefined, }); @@ -236,6 +238,7 @@ async function assertProviderDisabledOverUds( composition: defineInteractiveRuntimeHostComposition(async (context) => { const activation = new RuntimePolicyActivationGate(); const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation, onModelToolsChanged: () => undefined, }); diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 456d7b97d1..5abef48433 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -62,7 +62,10 @@ import { WORKHUB_COORDINATION_SESSION_ROLE, } from '@maka/core/session'; import type { MakaTool } from '@maka/runtime/tool-runtime'; -import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js'; +import { + clientCapabilityConnectionIdentity, + clientCapabilityCoordinatorTestAdmission, +} from './fixtures/client-capability.js'; import { openInteractiveExecutionStoresForWrite, type RootTurnAdmission, @@ -557,6 +560,7 @@ test('startup recovery closes a ScheduledTask Run after its pending fire was set test('a failed exact Capability retry does not poison the parked continuation binding', async () => { const capabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -700,6 +704,7 @@ test('a failed exact Capability retry does not poison the parked continuation bi test('resume query preserves Session-before-activation lock ordering', async () => { const activation = new RuntimePolicyActivationGate(); const capabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation, onModelToolsChanged: () => undefined, }); @@ -875,6 +880,7 @@ test('turn.start resolves explicit Skills once before durable admission and repl let blocked = false; let observedCapabilityPreview = false; const capabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -3314,6 +3320,7 @@ test('shutdown contains a successor backend start rejected by Interaction drain' test('Client Capability ambiguity fails before durable root admission', async () => { const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -3393,6 +3400,7 @@ test('an exact active retry preserves the Client Capability admission binding', timeout: 20_000, }, async () => { const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -3488,6 +3496,7 @@ test('mixed-Client queued follow-ups use separate Session successors without con timeout: 20_000, }, async () => { const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -3632,6 +3641,7 @@ async function assertSessionSuccessorCapabilityDegradation( affinity: 'call' | 'turn', ): Promise { const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); @@ -3833,6 +3843,7 @@ test('an exact terminal retry does not require a live Client Capability binding' timeout: 20_000, }, async () => { const clientCapabilities = new HostClientCapabilityCoordinator({ + ...clientCapabilityCoordinatorTestAdmission(), activation: new RuntimePolicyActivationGate(), onModelToolsChanged: () => undefined, }); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index f95b8d8c64..47c46ca078 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -19,12 +19,23 @@ import { createHash } from 'node:crypto'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { buildMcpTools, mcpProxyToolName, type McpToolProvider } from '@maka/runtime/mcp-tools'; +import { + buildMcpTools, + mcpProxyToolName, + type McpPreparedToolCall, + type McpToolProvider, +} from '@maka/runtime/mcp-tools'; import { type MakaTool } from '@maka/runtime/tool-runtime'; import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import { + clientCapabilityScopeIdentity, + type ClientCapabilityGrantTarget, +} from '@maka/core/client-capability-grant'; import { type ToolGroup } from '@maka/runtime/tool-availability'; +import type { InteractiveInteractionStoreWriterFacade } from '@maka/storage/interaction-store'; import { type ClientCapabilityOffer, + type ClientCapabilityAdmissionEvidence, type ClientCapabilityOwnerIdentity, type ClientCapabilityReplaceInput, type ClientCapabilityServiceOffer, @@ -47,10 +58,22 @@ import type { ClientCapabilityConnectionSender, ClientCapabilityService, } from './client-capability-service.js'; +import type { HostInteractionCoordinator } from './interaction-coordinator.js'; // Leave the Host deadline outside the provider's bounded action deadline so an // accepted call can return its real terminal result instead of outcome_unknown. const DEFAULT_CALL_TIMEOUT_MS = 150_000; +const DESKTOP_BROWSER_SERVER_ID = 'desktop_browser'; +const DESKTOP_SETTINGS_SERVER_ID = 'desktop_settings'; +const DESKTOP_BROWSER_TOOLS = new Set([ + 'browser_navigate', + 'browser_snapshot', + 'browser_click', + 'browser_type', + 'browser_wait', + 'browser_extract', +]); +const DESKTOP_SETTINGS_TOOLS = new Set(['MakaClientSettingsGet', 'MakaClientSettingsUpdate']); export { ClientCapabilityInvocationError }; export type { ClientCapabilityInvocationFailure }; @@ -152,6 +175,11 @@ type ClientCapabilityToolBinding = ClientCapabilityBoundTool['binding']; export interface HostClientCapabilityCoordinatorOptions { readonly activation: RuntimePolicyActivationGate; readonly onModelToolsChanged: () => void; + readonly interactions: Pick; + readonly grants: Pick< + InteractiveInteractionStoreWriterFacade, + 'readClientCapabilitySessionGrant' + >; } export interface ClientCapabilityServiceInvocationInput { @@ -181,9 +209,12 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService readonly #activation: RuntimePolicyActivationGate; readonly #onModelToolsChanged: () => void; + readonly #interactions: HostClientCapabilityCoordinatorOptions['interactions']; + readonly #grants: HostClientCapabilityCoordinatorOptions['grants']; readonly #providers = new Map(); readonly #connections = new Map(); readonly #sessions = new Map(); + readonly #pendingApprovals = new Map>(); readonly #previewSessions = new AsyncLocalStorage>(); readonly #pendingConnectionReleases = new Set>(); readonly #invocations: ClientCapabilityInvocationBroker; @@ -193,6 +224,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService constructor(options: HostClientCapabilityCoordinatorOptions) { this.#activation = options.activation; this.#onModelToolsChanged = options.onModelToolsChanged; + this.#interactions = options.interactions; + this.#grants = options.grants; this.#invocations = new ClientCapabilityInvocationBroker({ senderFor: (connectionId) => { const connection = this.#connections.get(connectionId); @@ -567,6 +600,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, categoryHint: 'client_capability', recoveryMode: 'outcome_unknown', + executionLocation: 'remote', }), ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, trusted), { callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, @@ -983,29 +1017,84 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService revision: this.#revision, tools: Object.freeze(boundTools), }); + const resolveBinding = (binding: ClientCapabilityToolBinding) => { + const selectedBinding = bindings.get(binding); + if (!selectedBinding) { + throw new ClientCapabilityInvocationError( + 'capability_lost', + 'Client Capability tool is not part of the frozen offer', + ); + } + const { serverId, name: toolName } = selectedBinding.tool.descriptor; + const dynamicBinding = selectedBinding.registration + ? { + registration: selectedBinding.registration, + tool: selectedBinding.tool, + } + : this.#selectCallBinding( + selectedBinding.contractId, + initiatingProviderId, + toolIdentity(serverId, toolName), + ); + return { selectedBinding, dynamicBinding }; + }; return { toolSnapshot: () => snapshot, - callTool: (binding, args, options) => { - const selectedBinding = bindings.get(binding); - if (!selectedBinding) { - return Promise.reject( - new ClientCapabilityInvocationError( - 'capability_lost', - 'Client Capability tool is not part of the frozen offer', - ), - ); - } - const { serverId, name: toolName } = selectedBinding.tool.descriptor; - const dynamicBinding = selectedBinding.registration - ? { - registration: selectedBinding.registration, - tool: selectedBinding.tool, + prepareTool: async (binding, args, options): Promise => { + const { selectedBinding, dynamicBinding } = resolveBinding(binding); + const prepared = this.#invocations.prepare( + dynamicBinding.registration, + dynamicBinding.tool, + args, + options.context, + options.signal, + options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, + ); + try { + const evidence = await prepared.waitUntilAccepted(); + const boundary = options.context.executionBoundary; + if (!boundary) throw new Error('Client Capability execution boundary is unavailable'); + if (boundary.kind !== 'bypass') { + if (options.context.permissionMode !== 'ask' || !options.context.runId) { + throw new Error('Client Capability is unavailable in the current permission mode'); } - : this.#selectCallBinding( + const target = managedClientCapabilityGrantTarget( selectedBinding.contractId, - initiatingProviderId, - toolIdentity(serverId, toolName), + dynamicBinding.registration, + dynamicBinding.tool, + evidence, ); + if (!target) { + return { + execute: ({ emitProgress } = {}) => prepared.admit(emitProgress), + cancel: () => prepared.cancel(), + }; + } + const key = { sessionId: options.context.sessionId, ...target }; + if (!(await this.#grants.readClientCapabilitySessionGrant(key))) { + const decision = await this.#requestApprovalOnce({ + ...key, + turnId: options.context.turnId, + runId: options.context.runId, + toolCallId: options.context.toolCallId, + }); + if (decision !== 'allow') throw new Error('Client Capability request was denied'); + if (!(await this.#grants.readClientCapabilitySessionGrant(key))) { + throw new Error('Client Capability approval did not publish its Session Grant'); + } + } + } + return { + execute: ({ emitProgress } = {}) => prepared.admit(emitProgress), + cancel: () => prepared.cancel(), + }; + } catch (error) { + prepared.cancel(); + throw error; + } + }, + callTool: (binding, args, options) => { + const { dynamicBinding } = resolveBinding(binding); return this.#invocations.invoke( dynamicBinding.registration, dynamicBinding.tool, @@ -1019,6 +1108,50 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } + #requestApprovalOnce(input: { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly toolCallId: string; + readonly providerId: string; + readonly contractId: string; + readonly serverId: string; + readonly toolName: string; + readonly capability: ClientCapabilityGrantTarget['capability']; + readonly scope: ClientCapabilityGrantTarget['scope']; + }): Promise<'allow' | 'deny'> { + const target: ClientCapabilityGrantTarget = { + providerId: input.providerId, + contractId: input.contractId, + serverId: input.serverId, + toolName: input.toolName, + capability: input.capability, + scope: input.scope, + }; + const key = [ + input.sessionId, + target.providerId, + target.contractId, + target.capability, + clientCapabilityScopeIdentity(target.scope), + ].join('\0'); + const existing = this.#pendingApprovals.get(key); + if (existing) return existing; + const pending = this.#interactions + .requestClientCapabilityApproval({ + sessionId: input.sessionId, + turnId: input.turnId, + runId: input.runId, + toolCallId: input.toolCallId, + target, + }) + .finally(() => { + if (this.#pendingApprovals.get(key) === pending) this.#pendingApprovals.delete(key); + }); + this.#pendingApprovals.set(key, pending); + return pending; + } + #provider(identity: ClientCapabilityConnectionIdentity): ClientProviderState { const providerId = clientProviderId(identity.principalId, identity.clientInstanceId); const trustedProvider = identity.principalKind === 'capability_provider'; @@ -1333,6 +1466,55 @@ function freezeRegistration( }; } +function managedClientCapabilityGrantTarget( + contractId: string, + registration: CapabilityRegistration, + tool: FrozenToolBinding, + evidence: ClientCapabilityAdmissionEvidence, +): ClientCapabilityGrantTarget | undefined { + const { serverId, name: toolName } = tool.descriptor; + if (!registration.trustedProvider) { + throw new Error('Managed Client Capability requires a trusted Desktop provider'); + } + if ( + tool.offerId === DESKTOP_SETTINGS_SERVER_ID && + serverId === DESKTOP_SETTINGS_SERVER_ID && + DESKTOP_SETTINGS_TOOLS.has(toolName) + ) { + if (evidence.kind !== 'none') { + throw new Error('Desktop Settings admission does not accept scope evidence'); + } + return undefined; + } + if ( + tool.offerId !== DESKTOP_BROWSER_SERVER_ID || + serverId !== DESKTOP_BROWSER_SERVER_ID || + !DESKTOP_BROWSER_TOOLS.has(toolName) + ) { + throw new Error(`Client Capability has no managed admission policy: ${serverId}/${toolName}`); + } + if (evidence.kind !== 'browser_url') { + throw new Error('Desktop Browser admission requires URL evidence'); + } + let url: URL; + try { + url = new URL(evidence.url); + } catch { + throw new Error('Desktop Browser admission URL is invalid'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Desktop Browser admission requires an HTTP origin'); + } + return Object.freeze({ + providerId: registration.providerId, + contractId, + serverId, + toolName, + capability: 'browser', + scope: Object.freeze({ kind: 'browser_origin', origin: url.origin }), + }); +} + function clientProviderId(principalId: string, clientInstanceId: string): string { return `${principalId}\0${clientInstanceId}`; } diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts index a4eddabd3f..901ecf1e45 100644 --- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts +++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts @@ -80,7 +80,7 @@ interface InvocationState void; readonly signal?: AbortSignal; readonly onAbort?: () => void; - readonly onProgress?: (current: number, total: number) => void; + onProgress?: (current: number, total: number) => void; readonly timeoutMs: number; timer: NodeJS.Timeout | undefined; acceptedSettled: boolean; @@ -99,7 +99,9 @@ export interface PreparedClientCapabilityInvocation { /** Resolves once the provider has parsed the call and is waiting at its admission cut. */ waitUntilAccepted(): Promise; /** Crosses the admission cut and returns the provider result. */ - admit(): Promise; + admit(onProgress?: (current: number, total: number) => void): Promise; + /** Cancels an accepted call that will not cross the admission cut. */ + cancel(): void; } export interface ClientCapabilityInvocationBrokerOptions< @@ -299,11 +301,12 @@ export class ClientCapabilityInvocationBroker< return { invocationId, waitUntilAccepted: () => accepted, - admit: async () => { + admit: async (onProgress) => { await accepted; const invocation = this.#invocations.get(invocationId); if (!invocation) return result; if (invocation.phase === 'accepted') { + invocation.onProgress = onProgress ?? invocation.onProgress; invocation.phase = 'admitted'; this.#startTimeout(invocation); const currentSender = this.#senderFor(invocation.registration.connectionId); @@ -336,6 +339,23 @@ export class ClientCapabilityInvocationBroker< } return result; }, + cancel: () => { + const invocation = this.#invocations.get(invocationId); + if (!invocation) return; + const currentSender = this.#senderFor(invocation.registration.connectionId); + void currentSender + ?.send({ kind: 'client.capability.cancel', invocationId }) + .catch(() => {}); + this.#settle( + invocation, + undefined, + new ClientCapabilityInvocationError( + 'provider_rejected', + 'Client Capability invocation was cancelled before admission', + ), + true, + ); + }, }; } @@ -373,7 +393,7 @@ export class ClientCapabilityInvocationBroker< return; case 'client.capability.failed': if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') { - throw new Error('Client Capability failure arrived before acceptance'); + throw new Error('Client Capability failure arrived before admission'); } this.#settle( invocation, @@ -384,7 +404,7 @@ export class ClientCapabilityInvocationBroker< return; case 'client.capability.progress': if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') { - throw new Error('Client Capability progress arrived before acceptance'); + throw new Error('Client Capability progress arrived before admission'); } if ( invocation.progress && @@ -398,13 +418,13 @@ export class ClientCapabilityInvocationBroker< return; case 'client.capability.result': if (invocation.phase !== 'admitted') { - throw new Error('Client Capability result arrived outside the accepted phase'); + throw new Error('Client Capability result arrived outside the admitted phase'); } this.#settle(invocation, frame.result, undefined, true); return; case 'client.capability.result_start': if (invocation.phase !== 'admitted') { - throw new Error('Client Capability result chunks started outside the accepted phase'); + throw new Error('Client Capability result chunks started outside the admitted phase'); } invocation.phase = 'chunks'; invocation.chunks = { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 4407a1b9a3..635c91675e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1073,6 +1073,8 @@ export async function createExecutionRuntimeHostComposition( clientCapabilities = new HostClientCapabilityCoordinator({ activation: runtimePolicyActivation, onModelToolsChanged: registerBackendInvalidation, + interactions, + grants: stores.interactionStore, }); oauth = new HostOAuthCoordinator({ runtimePolicy: runtimePolicyStores, diff --git a/packages/runtime-host/src/test-only/client-capability-host.ts b/packages/runtime-host/src/test-only/client-capability-host.ts index 757ce9625c..c82d429b45 100644 --- a/packages/runtime-host/src/test-only/client-capability-host.ts +++ b/packages/runtime-host/src/test-only/client-capability-host.ts @@ -22,3 +22,27 @@ export { type ClientCapabilitySnapshot, } from '../server/client-capability-coordinator.js'; export { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; + +export function clientCapabilityCoordinatorTestAdmission() { + return { + interactions: { + requestClientCapabilityApproval: async () => { + throw new Error('Unexpected Client Capability approval request'); + }, + }, + grants: { + readClientCapabilitySessionGrant: async (key: { + sessionId: string; + providerId: string; + contractId: string; + serverId: string; + toolName: string; + capability: 'browser' | 'computer_use' | 'desktop_mcp'; + scope: + | { kind: 'browser_origin'; origin: string } + | { kind: 'capability' } + | { kind: 'mcp_tool'; serverId: string; toolName: string }; + }) => ({ version: 1 as const, ...key, grantedAt: 0 }), + }, + }; +} diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 45bfd25e42..478f29d1e9 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -70,6 +70,7 @@ export interface McpToolCallOptions { export interface McpToolInvocationContext { readonly sessionId: string; + readonly runId?: string; readonly turnId: string; readonly toolCallId: string; readonly cwd: string; @@ -120,6 +121,7 @@ export function buildMcpTools( timeoutMs: options.callTimeoutMs, context: { sessionId: context.sessionId, + runId: context.runId, turnId: context.turnId, toolCallId: context.toolCallId, cwd: context.cwd, From 6177cb1fd5e82c7ff63db962c7827febaee905cf Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:45:42 +0800 Subject: [PATCH 11/30] feat(desktop): attest browser origins before admission Generated-by: OpenAI Codex --- .../main/__tests__/browser-session.test.ts | 1 + .../__tests__/runtime-host-client-uds.test.ts | 1 + .../runtime-host-desktop-candidate.test.ts | 7 ++ .../runtime-host-native-capabilities.test.ts | 76 +++++++++++++++++-- .../src/main/browser/automation-host.ts | 3 + apps/desktop/src/main/browser/browser-host.ts | 2 + apps/desktop/src/main/runtime-host-boot.ts | 12 +++ .../main/runtime-host-native-capabilities.ts | 51 +++++++++++-- 8 files changed, 142 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-session.test.ts b/apps/desktop/src/main/__tests__/browser-session.test.ts index a81d08db44..08e9a29cec 100644 --- a/apps/desktop/src/main/__tests__/browser-session.test.ts +++ b/apps/desktop/src/main/__tests__/browser-session.test.ts @@ -92,6 +92,7 @@ type HostSpy = { function installHost(overrides: Partial = {}): HostSpy { const spy: HostSpy = { resolved: [], released: [], disposed: [], host: null as never }; const host: BrowserViewHost = { + currentUrl: () => "https://example.com/", canDrive: () => true, resolveEndpoint: async (id) => { spy.resolved.push(id); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 0f6909423e..da520348ce 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -260,6 +260,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn resizeImage: async (bytes) => bytes, nativeCapabilities: { browserTools: [nativeTool()], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: Object.assign([], { clearSession() {}, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 26222c86ad..da5c76d48b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -253,6 +253,7 @@ test('rejects a stale Host identity when raw Session IDs collide', async () => { const computerReleased: string[] = []; const nativeCapabilities: DesktopRuntimeHostCandidateDeps['nativeCapabilities'] = { browserTools: [nativeTool()], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession: (sessionId) => { browserReleased.push(sessionId); }, @@ -375,6 +376,7 @@ test('starts without registering an empty native capability set', async () => { host.connection, deps(ipc, { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), releaseComputerUseSession() {}, @@ -394,6 +396,7 @@ test('refreshes native capabilities with a new immutable provider snapshot', asy const candidate = await createDesktopRuntimeHostCandidate(host.connection, { ...deps(ipc, { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), releaseComputerUseSession() {}, @@ -448,6 +451,7 @@ test('releases all native Session resources on retirement and generation close', host.connection, deps(ipc, { browserTools: [nativeTool()], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession: async (sessionId) => { browserReleased.push(sessionId); }, @@ -554,6 +558,7 @@ test('closes the claimed Host connection when native capability construction fai host.connection, deps(ipc, { browserTools: [invalidTool], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), releaseComputerUseSession() {}, @@ -575,6 +580,7 @@ test('does not release or report a Revision the Host retained during cleanup', a const candidate = await createDesktopRuntimeHostCandidate(host.connection, { ...deps(ipc, { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession: (sessionId) => { released.push(`browser:${sessionId}`); }, @@ -950,6 +956,7 @@ function deps( ipcMain: ReturnType, nativeCapabilities: DesktopRuntimeHostCandidateDeps['nativeCapabilities'] = { browserTools: [nativeTool()], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), releaseComputerUseSession() {}, diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 07fb18e7c1..c2df79d23b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -25,6 +25,7 @@ import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime' import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { decodeClientCapabilityReplaceInput, + type ClientCapabilityAdmissionEvidence, type ClientCapabilityCallFrame, type ClientCapabilityServiceCallFrame, } from '@maka/runtime-host/protocol'; @@ -36,6 +37,7 @@ import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-ca test('publishes self-described session-affine Browser and Computer Use offers', () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [tool('browser_snapshot', z.object({ includeHidden: z.boolean().optional() }), async () => 'ok')], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(async () => ({ text: 'ok' })), releaseComputerUseSession() {}, @@ -91,6 +93,7 @@ test('remote providers do not request Host paths and use a Client-owned cwd', as return 'ok'; }), ], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -111,6 +114,7 @@ test('publishes the real Computer Use schema through the Client Capability proto const computerUseTools = buildComputerUseTools({ backend: computerBackend() }); const provider = createDesktopNativeCapabilityProvider({ browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools, releaseComputerUseSession: (sessionId) => computerUseTools.clearSession(sessionId), @@ -142,6 +146,7 @@ test('publishes every production Desktop-owned tool schema through the protocol' }); const provider = createDesktopNativeCapabilityProvider({ browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -174,6 +179,7 @@ test('publishes and admits additional Desktop native-effect services', async () const provider = createDesktopNativeCapabilityProvider( { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -206,6 +212,8 @@ test('publishes and admits additional Desktop native-effect services', async () test('validates before admission and invokes the exact offered tool with Host context', async () => { let admitted = false; let invoked = false; + const resolvedUrls: string[] = []; + let acceptedEvidence: ClientCapabilityAdmissionEvidence | undefined; let received: | { args: unknown; @@ -230,6 +238,13 @@ test('validates before admission and invokes the exact offered tool with Host co return 'Loaded'; }), ], + resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => { + assert.equal(sessionId, 'host-a:session-1'); + assert.equal(toolName, 'browser_navigate'); + const url = String(args.url); + resolvedUrls.push(url); + return url; + }, releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -243,13 +258,19 @@ test('validates before admission and invokes the exact offered tool with Host co ); assert.equal(invoked, false); assert.equal(admitted, false); + assert.deepEqual(resolvedUrls, []); - const result = await call(provider, capabilityFrame({ arguments: { url: 'https://example.com' } }), () => { - admitted = true; - }); + const result = await call( + provider, + capabilityFrame({ arguments: { url: 'https://example.com/path' } }), + (evidence) => { + acceptedEvidence = evidence; + admitted = true; + }, + ); assert.deepEqual(result, { content: [{ type: 'text', text: 'Loaded' }] }); assert.deepEqual(received, { - args: { url: 'https://example.com' }, + args: { url: 'https://example.com/path' }, context: { sessionId: 'host-a:session-1', turnId: 'turn-1', @@ -257,6 +278,42 @@ test('validates before admission and invokes the exact offered tool with Host co toolCallId: 'tool-call-1', }, }); + assert.deepEqual(acceptedEvidence, { + kind: 'browser_url', + url: 'https://example.com/path', + }); + assert.deepEqual(resolvedUrls, [ + 'https://example.com/path', + 'https://example.com/path', + ]); +}); + +test('does not execute Browser work when its Origin changes while admission is pending', async () => { + let resolveCount = 0; + let invoked = false; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [ + tool('browser_snapshot', z.object({}), async () => { + invoked = true; + return 'snapshot'; + }), + ], + resolveBrowserUrl: () => + resolveCount++ === 0 ? 'https://first.example/page' : 'https://second.example/page', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + }); + + await assert.rejects( + () => + call( + provider, + capabilityFrame({ toolName: 'browser_snapshot', arguments: {} }), + ), + /Browser origin changed while admission was pending/u, + ); + assert.equal(invoked, false); }); test('watches Computer Use turns without widening Browser lifecycle', async () => { @@ -266,6 +323,7 @@ test('watches Computer Use turns without widening Browser lifecycle', async () = const provider = createDesktopNativeCapabilityProvider( { browserTools: [tool('browser_snapshot', z.object({}), async () => 'snapshot')], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(async (_args, context) => { computerUseSessionId = context.sessionId; @@ -323,6 +381,7 @@ test('projects Computer Use screenshots and releases all native resources for a ); const provider = createDesktopNativeCapabilityProvider({ browserTools: [tool('browser_snapshot', z.object({}), async () => 'snapshot')], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession: (sessionId) => { browserReleased.push(sessionId); }, @@ -380,6 +439,7 @@ test('projects Computer Use screenshots and releases all native resources for a test('does not advertise unavailable capability groups or dispatch unknown identities', async () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -411,6 +471,7 @@ test('dispatches through the same immutable tool snapshot it advertised', async ]; const provider = createDesktopNativeCapabilityProvider({ browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -458,6 +519,7 @@ test('reports provider retirement once after its registration is released', asyn const provider = createDesktopNativeCapabilityProvider( { browserTools: [tool('snapshot', z.object({}), async () => 'snapshot')], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -483,6 +545,7 @@ test('settles every native Session cleanup before reporting a release failure', let computerReleased = false; const provider = createDesktopNativeCapabilityProvider({ browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() { throw new Error('browser release failed'); }, @@ -519,6 +582,7 @@ test('forwards Host cancellation to an admitted Desktop invocation', async () => }); }), ], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -643,11 +707,11 @@ function computerFrame(overrides: Partial = {}): Clie async function call( provider: ClientCapabilityProvider, frame: ClientCapabilityCallFrame, - accept: () => void = () => undefined, + accept: (evidence: ClientCapabilityAdmissionEvidence) => void = () => undefined, ) { if (!provider.call) throw new Error('Expected a callable provider'); return provider.call(frame, { signal: new AbortController().signal, - accept: async () => accept(), + accept: async (evidence) => accept(evidence), }); } diff --git a/apps/desktop/src/main/browser/automation-host.ts b/apps/desktop/src/main/browser/automation-host.ts index ca296f1360..75d93670fd 100644 --- a/apps/desktop/src/main/browser/automation-host.ts +++ b/apps/desktop/src/main/browser/automation-host.ts @@ -47,6 +47,9 @@ export function createBrowserViewHost( shownSessionId: () => string | null, ): BrowserViewHost { return { + currentUrl(sessionId) { + return manager.get(sessionId)?.state().url ?? ''; + }, canDrive(sessionId, kind, opts) { const shown = sessionId === shownSessionId(); const controller = manager.get(sessionId); diff --git a/apps/desktop/src/main/browser/browser-host.ts b/apps/desktop/src/main/browser/browser-host.ts index 3c48ae8a27..eef5eac0b3 100644 --- a/apps/desktop/src/main/browser/browser-host.ts +++ b/apps/desktop/src/main/browser/browser-host.ts @@ -34,6 +34,8 @@ import type { BrowserActionKind } from './logic.js'; export interface BrowserViewHost { + /** Read the current real URL without creating or attaching an automation endpoint. */ + currentUrl(sessionId: string): string; /** * The visible-lease gate (see browserActionAllowed): may `sessionId` run a * `kind` action right now? EVERY kind — read, navigate, mutate — requires the diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a48483b34a..aa768055e5 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -79,6 +79,7 @@ import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment- import { registerAttachmentPreviewIpc } from "./attachment-preview.js"; import { readFileCapped } from "./attachment-ingest.js"; import { registerBrowserIpc } from "./browser-ipc-main.js"; +import { browserViewHost } from "./browser/browser-host.js"; import { releaseBrowserSession } from "./browser/session.js"; import { createE2eFixtureBotOnboardingAdapters } from "./bot-onboarding-e2e-fixture.js"; import { resolveBuildInfo } from "./build-info.js"; @@ -875,6 +876,17 @@ runtimeHostManager = await startRuntimeHostDesktopManager( resizeImage: resizeImageForAttachment, nativeCapabilities: { browserTools: native.browserTools, + resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => { + if (toolName === "browser_navigate") { + if (typeof args.url !== "string") { + throw new Error("Browser navigation URL is unavailable"); + } + return args.url; + } + const url = browserViewHost().currentUrl(sessionId); + if (!url) throw new Error("Browser session has no current URL"); + return url; + }, releaseBrowserSession, computerUseTools: native.computerUseTools, additionalGroups: () => { diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 27a84e3843..95121de388 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -62,6 +62,12 @@ type DesktopToolContentPart = Extract< export interface DesktopNativeCapabilityProviderInput { readonly browserTools: readonly MakaTool[]; + readonly resolveBrowserUrl: (input: { + readonly sessionId: string; + readonly toolName: string; + readonly arguments: Record; + readonly signal: AbortSignal; + }) => string | Promise; readonly releaseBrowserSession: (sessionId: string) => void | Promise; readonly computerUseTools: ComputerUseToolSet; readonly releaseComputerUseSession: ( @@ -156,6 +162,7 @@ export function createDesktopNativeCapabilityProvider( const invocation = new AbortController(); const task = invokeNativeTool( + input, binding, frame, options, @@ -309,6 +316,7 @@ function capabilityGroups( } async function invokeNativeTool( + input: DesktopNativeCapabilityProviderInput, binding: NativeToolBinding, frame: ClientCapabilityCallFrame, options: Parameters>[1], @@ -331,17 +339,42 @@ async function invokeNativeTool( const parameters = requireZodSchema(binding.tool); const args = await parameters.parseAsync(frame.arguments); signal.throwIfAborted(); - await options.accept({ kind: "none" }); + const sessionId = + frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID + ? providerOptions.nativeSessionId?.(frame.sessionId) ?? frame.sessionId + : frame.sessionId; + const admissionEvidence = + frame.offerId === BROWSER_OFFER_ID + ? { + kind: "browser_url" as const, + url: await input.resolveBrowserUrl({ + sessionId, + toolName: frame.toolName, + arguments: frame.arguments, + signal, + }), + } + : { kind: "none" as const }; + signal.throwIfAborted(); + await options.accept(admissionEvidence); + signal.throwIfAborted(); + if (admissionEvidence.kind === "browser_url") { + const currentUrl = await input.resolveBrowserUrl({ + sessionId, + toolName: frame.toolName, + arguments: frame.arguments, + signal, + }); + if (browserOrigin(currentUrl) !== browserOrigin(admissionEvidence.url)) { + throw new Error("Browser origin changed while admission was pending"); + } + } signal.throwIfAborted(); usedSessionIds.add(frame.sessionId); providerOptions.onSessionUsed?.(frame.sessionId); if (frame.offerId === COMPUTER_USE_OFFER_ID) { providerOptions.onComputerUseTurnUsed?.(frame.sessionId, frame.turnId); } - const sessionId = - frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID - ? providerOptions.nativeSessionId?.(frame.sessionId) ?? frame.sessionId - : frame.sessionId; const output = await binding.tool.impl(args, { sessionId, turnId: frame.turnId, @@ -354,6 +387,14 @@ async function invokeNativeTool( return projectToolResult(binding.tool, frame.toolCallId, args, output); } +function browserOrigin(value: string): string { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Browser admission requires an HTTP origin"); + } + return url.origin; +} + function abortInvocations( activeInvocations: ReadonlyMap< AbortController, From 73e0d506af0c37c38084770a49ef3749e2dc3004 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:46:00 +0800 Subject: [PATCH 12/30] fix(desktop): limit cross-origin browser results Generated-by: OpenAI Codex --- .../src/main/__tests__/browser-tools.test.ts | 32 +++++++++++++++++-- .../desktop/src/main/browser/browser-tools.ts | 31 +++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index d121388874..6f34ad1d64 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -43,6 +43,7 @@ import { type BrowserViewHost, provideBrowserViewHost } from '../browser/browser type FakePageConfig = { url?: string; + afterClickUrl?: string; title?: string; click?: { matches_n: number; match_level: 'exact' | 'stable' | 'reidentified' }; fill?: { verified: boolean; actual: string; match_level: 'exact' | 'stable' | 'reidentified' }; @@ -52,11 +53,12 @@ type FakePageConfig = { }; function makeFakePage(cfg: FakePageConfig): IPage { + let currentUrl = cfg.url ?? ''; return { - getCurrentUrl: async () => cfg.url ?? null, + getCurrentUrl: async () => currentUrl || null, goto: async () => {}, evaluate: async (js: string) => { - if (js.includes('location.href')) return (cfg.url ?? '') as never; + if (js.includes('location.href')) return currentUrl as never; if (js.includes('document.title')) return (cfg.title ?? '') as never; if (js.includes('outerHTML')) { return (cfg.extractHtml === undefined ? null : { html: cfg.extractHtml, truncated: false }) as never; @@ -64,7 +66,10 @@ function makeFakePage(cfg: FakePageConfig): IPage { return '' as never; }, snapshot: async () => cfg.snapshot ?? '[1] link "Home"', - click: async () => cfg.click ?? { matches_n: 1, match_level: 'exact' }, + click: async () => { + if (cfg.afterClickUrl) currentUrl = cfg.afterClickUrl; + return cfg.click ?? { matches_n: 1, match_level: 'exact' }; + }, fillText: async () => cfg.fill ? { filled: true, verified: cfg.fill.verified, expected: '', actual: cfg.fill.actual, length: 0, matches_n: 1, match_level: cfg.fill.match_level } @@ -92,6 +97,7 @@ class FakeBridge implements BridgeLike { function install(cfg: FakePageConfig): void { const host: BrowserViewHost = { + currentUrl: () => cfg.url ?? "https://example.com/", canDrive: () => true, resolveEndpoint: async (id) => ({ cdpEndpoint: `ws://127.0.0.1:1/${id}` }), releaseSession: async () => {}, @@ -145,6 +151,26 @@ describe('browser tool execution', () => { await assert.rejects(run(buildBrowserNavigateTool(), { url: 'file:///etc/passwd' }), /Not a navigable URL/); }); + it('navigate suppresses the destination title after a cross-Origin redirect', async () => { + install({ url: 'https://other.example/landing', title: 'Private destination title' }); + const out = await run(buildBrowserNavigateTool(), { url: 'https://example.com/start' }); + assert.match(out, /Navigated to https:\/\/other\.example\/landing/); + assert.doesNotMatch(out, /Private destination title/); + }); + + it('click returns only the new URL after cross-Origin navigation', async () => { + install({ + url: 'https://example.com/start', + afterClickUrl: 'https://other.example/landing', + }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal( + out, + 'Navigated to https://other.example/landing. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /Clicked|matched/); + }); + it('type reports verification failure with the actual content', async () => { diff --git a/apps/desktop/src/main/browser/browser-tools.ts b/apps/desktop/src/main/browser/browser-tools.ts index bf6947b65b..f5c303a3dc 100644 --- a/apps/desktop/src/main/browser/browser-tools.ts +++ b/apps/desktop/src/main/browser/browser-tools.ts @@ -137,6 +137,9 @@ export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> { return { landed: typeof landed === 'string' && landed ? landed : url, title, info }; }, }); + if (crossOrigin(url, result.landed)) { + return crossOriginNavigationResult(result.landed); + } return ( [`Loaded ${result.landed}`, result.title ? `Title: ${result.title}` : undefined].filter(Boolean).join('\n') + takeoverNote(result.info) @@ -190,8 +193,16 @@ export function buildBrowserClickTool(): MakaTool<{ ref: string }, string> { abortSignal, // A mutating action: harden a taken-over page (reload once) before clicking. takeover: 'mutate', - run: async (page, info) => ({ outcome: await page.click(normalizeElementRef(ref)), info }), + run: async (page, info) => { + const before = await currentPageUrl(page); + const outcome = await page.click(normalizeElementRef(ref)); + const after = await currentPageUrl(page); + return { outcome, before, after, info }; + }, }); + if (crossOrigin(result.before, result.after)) { + return crossOriginNavigationResult(result.after); + } const { matches_n, match_level } = result.outcome; return ( `Clicked ${ref} (matched ${matches_n} element${matches_n === 1 ? '' : 's'}, ${match_level} match).` + @@ -204,6 +215,24 @@ export function buildBrowserClickTool(): MakaTool<{ ref: string }, string> { }; } +async function currentPageUrl(page: Parameters>[0]): Promise { + const evaluated = await page.evaluate('window.location.href').catch(() => ''); + if (typeof evaluated === 'string' && evaluated) return evaluated; + return (await page.getCurrentUrl?.()) ?? ''; +} + +function crossOrigin(before: string, after: string): boolean { + try { + return new URL(before).origin !== new URL(after).origin; + } catch { + return false; + } +} + +function crossOriginNavigationResult(url: string): string { + return `Navigated to ${url}. Access to the new site requires approval on the next Browser call.`; +} + export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; submit?: boolean }, string> { return { name: 'browser_type', From 86135c8088021ec5e20d7b14c7872e5034f9b1d1 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:51:14 +0800 Subject: [PATCH 13/30] fix(runtime-host): close approvals when providers disconnect Generated-by: OpenAI Codex --- packages/core/src/interaction.ts | 1 + .../__tests__/interaction-coordinator.test.ts | 44 +++++++++++++++++++ .../server/client-capability-coordinator.ts | 3 ++ .../client-capability-invocation-broker.ts | 14 ++++++ .../src/server/interaction-coordinator.ts | 29 +++++++++++- 5 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index efc34d1713..55f6efa8ee 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -65,6 +65,7 @@ export const INTERACTION_CLOSURE_REASONS = [ 'turn_terminal', 'timed_out', 'host_restarted', + 'provider_disconnected', ] as const; const UTF8 = new TextEncoder(); diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index 8aa44302e4..ef3475443b 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -49,6 +49,7 @@ import { import type { SessionInteractionProjection } from '../protocol/index.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { + ClientCapabilityApprovalClosedError, HostInteractionCoordinator, type HostInteractionCoordinatorOptions, } from '../server/interaction-coordinator.js'; @@ -165,6 +166,49 @@ describe('HostInteractionCoordinator', () => { }); }); + test('closes a pending Client Capability approval when its provider disconnects', async () => { + await withStore(async ({ store }) => { + const coordinator = createCoordinator(store); + const owner = coordinator.bindRun(RUN); + const provider = new AbortController(); + const approval = coordinator.requestClientCapabilityApproval({ + ...RUN, + toolCallId: 'browser-call-disconnected', + providerSignal: provider.signal, + target: { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }, + }); + let pending = await store.listSessionPending(RUN.sessionId); + while (pending.length === 0) { + await new Promise((resolve) => setImmediate(resolve)); + pending = await store.listSessionPending(RUN.sessionId); + } + + provider.abort(); + await assert.rejects( + approval, + (error: unknown) => + error instanceof ClientCapabilityApprovalClosedError && + error.reason === 'provider_disconnected', + ); + const record = await store.readInteraction(pending[0]?.requestId ?? 'missing'); + assert.equal(record?.outcome?.outcome.kind, 'closure'); + if (record?.outcome?.outcome.kind === 'closure') { + assert.equal(record.outcome.outcome.reason, 'provider_disconnected'); + } + + await owner.close('turn_terminal'); + owner.release(); + await coordinator.close(); + }); + }); + test('settles a sandbox boundary once through the canonical boundary Store and wakes its graph', async () => { await withStore(async ({ owner, store, stores }) => { const workspace = join(owner.capability.canonicalPath, 'workspace'); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 47c46ca078..2c51017b91 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -1077,6 +1077,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService turnId: options.context.turnId, runId: options.context.runId, toolCallId: options.context.toolCallId, + providerSignal: prepared.providerSignal, }); if (decision !== 'allow') throw new Error('Client Capability request was denied'); if (!(await this.#grants.readClientCapabilitySessionGrant(key))) { @@ -1119,6 +1120,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService readonly toolName: string; readonly capability: ClientCapabilityGrantTarget['capability']; readonly scope: ClientCapabilityGrantTarget['scope']; + readonly providerSignal: AbortSignal; }): Promise<'allow' | 'deny'> { const target: ClientCapabilityGrantTarget = { providerId: input.providerId, @@ -1144,6 +1146,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService runId: input.runId, toolCallId: input.toolCallId, target, + providerSignal: input.providerSignal, }) .finally(() => { if (this.#pendingApprovals.get(key) === pending) this.#pendingApprovals.delete(key); diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts index 901ecf1e45..a4c7db15b3 100644 --- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts +++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts @@ -82,6 +82,7 @@ interface InvocationState void; onProgress?: (current: number, total: number) => void; readonly timeoutMs: number; + readonly providerAvailability: AbortController; timer: NodeJS.Timeout | undefined; acceptedSettled: boolean; phase: 'dispatched' | 'accepted' | 'admitted' | 'chunks'; @@ -102,6 +103,8 @@ export interface PreparedClientCapabilityInvocation { admit(onProgress?: (current: number, total: number) => void): Promise; /** Cancels an accepted call that will not cross the admission cut. */ cancel(): void; + /** Aborts when the provider connection disappears before this call is admitted. */ + readonly providerSignal: AbortSignal; } export interface ClientCapabilityInvocationBrokerOptions< @@ -273,6 +276,7 @@ export class ClientCapabilityInvocationBroker< onAbort, onProgress, timeoutMs, + providerAvailability: new AbortController(), timer: undefined, acceptedSettled: false, phase: 'dispatched', @@ -300,6 +304,8 @@ export class ClientCapabilityInvocationBroker< void result.catch(() => {}); return { invocationId, + providerSignal: + this.#invocations.get(invocationId)?.providerAvailability.signal ?? AbortSignal.abort(), waitUntilAccepted: () => accepted, admit: async (onProgress) => { await accepted; @@ -442,6 +448,14 @@ export class ClientCapabilityInvocationBroker< releaseConnection(connectionId: string): void { for (const invocation of [...this.#invocations.values()]) { if (invocation.registration.connectionId !== connectionId) continue; + if (invocation.phase === 'dispatched' || invocation.phase === 'accepted') { + invocation.providerAvailability.abort( + new ClientCapabilityInvocationError( + 'capability_lost', + 'Client Capability provider disconnected before admission', + ), + ); + } this.#settle( invocation, undefined, diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index c0911419dd..2bdcfd9e96 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -164,6 +164,7 @@ export interface ClientCapabilityApprovalInput { readonly runId: string; readonly toolCallId: string; readonly target: ClientCapabilityGrantTarget; + readonly providerSignal?: AbortSignal; } export class ClientCapabilityApprovalClosedError extends Error { @@ -274,7 +275,33 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { resolve: resolveDecision, reject: rejectDecision, }); - return decision; + const onProviderDisconnect = () => { + void this.#closeClientCapabilityApproval(requestId).catch(rejectDecision); + }; + if (input.providerSignal?.aborted) onProviderDisconnect(); + else input.providerSignal?.addEventListener('abort', onProviderDisconnect, { once: true }); + try { + return await decision; + } finally { + input.providerSignal?.removeEventListener('abort', onProviderDisconnect); + } + } + + async #closeClientCapabilityApproval(requestId: string): Promise { + const candidate = this.#live.get(requestId); + if (!candidate || candidate.kind !== 'client_capability') return; + await this.#sessionAdmission.run(candidate.request.sessionId, async (admission) => { + const current = this.#live.get(requestId); + if (!current || current !== candidate || current.kind !== 'client_capability') return; + const outcome = await this.#commitClientCapabilityOutcome(candidate.request, { + kind: 'closure', + reason: 'provider_disconnected', + committedAt: this.#now(), + }); + await this.#refreshCanonicalContinuity(candidate.request.sessionId, admission); + this.#throwIfPoisoned(); + await this.#applyAndDelete(candidate, outcome); + }); } beginDrain(): void { From b1477c39708f39ad59635498f0591740d66b36ac Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:51:58 +0800 Subject: [PATCH 14/30] feat(desktop): present client capability approvals Generated-by: OpenAI Codex --- ...chat-composer-region-draft-handoff.test.ts | 2 + .../permission-response-ipc-boundary.test.ts | 15 +++ ...me-host-session-execution-ipc-main.test.ts | 84 +++++++++++++ .../workbar-services-adapter.test.ts | 2 + .../src/main/permission-response-guard.ts | 19 +++ ...runtime-host-session-execution-ipc-main.ts | 17 +++ .../src/main/runtime-host-session-observer.ts | 6 + apps/desktop/src/preload/bridge-contract.d.ts | 5 + apps/desktop/src/preload/preload.ts | 11 ++ .../src/renderer/app-shell-chat-actions.ts | 27 +++++ .../src/renderer/app-shell-session-events.ts | 7 ++ apps/desktop/src/renderer/app-shell.tsx | 5 + .../src/renderer/chat-composer-region.tsx | 19 ++- .../src/renderer/features/workbar/ports.ts | 5 + .../src/renderer/features/workbar/testing.ts | 1 + .../tools/side-chat/quote-companion-core.ts | 2 + .../tools/side-chat/quote-companion-panel.tsx | 16 ++- .../tools/side-chat/use-quote-companion.ts | 21 ++++ .../desktop/create-workbar-services.ts | 2 + packages/core/src/client-capability-grant.ts | 5 + packages/core/src/events.ts | 26 +++- .../src/__tests__/session-projector.test.ts | 43 +++++++ .../src/adapter/session-projector.ts | 10 ++ packages/ui/src/client-capability-prompt.tsx | 113 ++++++++++++++++++ packages/ui/src/components.tsx | 1 + packages/ui/src/conversation-copy.ts | 27 +++++ packages/ui/src/index.ts | 1 + 27 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/client-capability-prompt.tsx diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index b2fe5b61f4..dad7844a6a 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -122,6 +122,8 @@ async function mountRegion(): Promise<{ stopPendingBySession: {}, respondToSandboxBoundary: () => {}, activeSandboxBoundary: undefined, + respondToClientCapability: () => {}, + activeClientCapability: undefined, activeQuestion: undefined, respondToUserQuestion: () => {}, stop: () => {}, diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index e5c6a2585f..86b0bac861 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -22,6 +22,7 @@ import { describe, it } from 'node:test'; import { normalizeBranchFromTurnInput, + normalizeClientCapabilityResponse, normalizeRegenerateTurnInput, normalizeReviseBeforeTurnInput, normalizeRuntimeHostBranchFromTurnInput, @@ -50,6 +51,14 @@ describe('permission response IPC boundary', () => { }), { requestId: 'question-1', answers: ['Option A', null] }, ); + assert.deepEqual( + normalizeClientCapabilityResponse({ + requestId: 'capability-1', + decision: 'allow', + ignored: true, + }), + { requestId: 'capability-1', decision: 'allow' }, + ); const invalidPermissionResponses = [ null, @@ -60,6 +69,12 @@ describe('permission response IPC boundary', () => { for (const response of invalidPermissionResponses) { assert.throws(() => normalizeSandboxBoundaryResponse(response), /sandbox boundary response/); } + for (const response of invalidPermissionResponses) { + assert.throws( + () => normalizeClientCapabilityResponse(response), + /Client Capability response/, + ); + } const invalidQuestionResponses = [ { requestId: '', answers: ['A'] }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 65c8e81cd1..504b2ed2e7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -123,6 +123,90 @@ test("keeps synthetic E2E interactions visible through Host hydration and retire await observer.close(); }); +test('answers a Client Capability approval through the existing Interaction authority', async () => { + const observer = observerWithSnapshot({ + interactions: { + pending: [ + { + schemaVersion: 1, + interactionId: 'capability-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1, + request: { + kind: 'client_capability', + toolUseId: 'tool-1', + target: { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }, + }, + status: 'pending', + outcome: null, + }, + ], + }, + }); + const answers: unknown[] = []; + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + answerInteraction: async (input) => { + answers.push(input); + return { + schemaVersion: 1, + interactionId: 'capability-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 2, + request: { + kind: 'client_capability', + toolUseId: 'tool-1', + target: { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }, + }, + status: 'answered', + outcome: { + kind: 'client_capability_decision', + decision: 'allow', + committedAt: 2, + }, + }; + }, + }), + observer, + }, + ipc, + ); + + await ipc.invoke('sessions:listActiveInteractions', 'session-1'); + await ipc.invoke('sessions:respondToClientCapability', 'session-1', { + requestId: 'capability-1', + decision: 'allow', + }); + assert.deepEqual(answers, [ + { + sessionId: 'session-1', + interactionId: 'capability-1', + answer: { kind: 'client_capability', decision: 'allow' }, + }, + ]); + await observer.close(); +}); + test("retries committed Branch and Revision copies with the renderer-owned identity", async () => { const committed = new Map(); const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]); diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 88a3d42449..43d806fa21 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -179,6 +179,7 @@ describe('createDesktopWorkbarServices', () => { turnId: 'turn-3', }); await services.sideChat.respondToSandboxBoundary('fork', {} as never); + await services.sideChat.respondToClientCapability('fork', {} as never); await services.sideChat.respondToUserQuestion('fork', {} as never); services.sideChat.subscribeEvents('fork', eventHandler)(); @@ -232,6 +233,7 @@ describe('createDesktopWorkbarServices', () => { 'sessions.setPermissionMode', 'sessions.regenerateTurn', 'sessions.respondToSandboxBoundary', + 'sessions.respondToClientCapability', 'sessions.respondToUserQuestion', 'sessions.subscribeEvents', ], diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index b3f9ec0861..e29fe05aab 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -31,6 +31,7 @@ import { } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { isAttachmentRef, isCanonicalStorageRef, type AttachmentRef } from '@maka/core/events'; @@ -96,6 +97,24 @@ export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundar }; } +export function normalizeClientCapabilityResponse(input: unknown): ClientCapabilityResponse { + if (!input || typeof input !== 'object') { + throw new Error('Invalid Client Capability response'); + } + const value = input as Record; + if ( + typeof value.requestId !== 'string' || + value.requestId.length === 0 || + value.requestId.length > MAX_PERMISSION_REQUEST_ID_LENGTH + ) { + throw new Error('Invalid Client Capability response requestId'); + } + if (value.decision !== 'allow' && value.decision !== 'deny') { + throw new Error('Invalid Client Capability response decision'); + } + return { requestId: value.requestId, decision: value.decision }; +} + export function normalizeUserQuestionResponse(input: unknown): UserQuestionResponse { const value = requireObject(input, 'Invalid user question response'); const requestId = normalizeRequiredString( diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 2d7f849e3b..5699b58242 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -42,6 +42,7 @@ import { normalizeRegenerateTurnInput, normalizeRuntimeHostReviseBeforeTurnInput, normalizeSandboxBoundaryResponse, + normalizeClientCapabilityResponse, normalizeSessionSendCommand, normalizeStopSessionInput, normalizeUserQuestionResponse, @@ -639,6 +640,22 @@ export function registerRuntimeHostSessionExecutionIpc( deps.observer.publishInteractionAnswer(answered, pending); }, ); + ipcMain.handle( + "sessions:respondToClientCapability", + async (_event, sessionId: string, input: unknown) => { + const response = normalizeClientCapabilityResponse(input); + const pending = await requireInteraction(deps.observer, sessionId, response.requestId); + if (pending.request.kind !== "client_capability") { + throw new Error("Interaction is not a Client Capability request"); + } + const answered = await deps.client.answerInteraction({ + sessionId, + interactionId: response.requestId, + answer: { kind: "client_capability", decision: response.decision }, + }); + deps.observer.publishInteractionAnswer(answered, pending); + }, + ); ipcMain.handle("sessions:compact", async (_event, sessionId: string) => { const turnId = newId(); diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 5eef5d7571..acf5e12377 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -562,6 +562,12 @@ export class RuntimeHostSessionObserver { status: answered.outcome.status, revision: answered.revision, }); + } else if (answered.outcome.kind === "client_capability_decision") { + this.#broadcast(answered.sessionId, { + type: "client_capability_decision_ack", + ...base, + decision: answered.outcome.decision, + }); } } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 05138dcb67..998aed97b5 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -43,6 +43,7 @@ import type { BotProvider } from '@maka/core/bot-chat-settings'; import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding'; import type { HealthSnapshot } from '@maka/core/health'; import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { ActiveInteractionRequestEvent, MessageContent, @@ -1176,6 +1177,10 @@ export interface MakaBridge { ): Promise; reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise; respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; + respondToClientCapability( + sessionId: string, + response: ClientCapabilityResponse, + ): Promise; respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; saveConversationToFile(input: { markdown: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c6eaf64a81..0afdf81c99 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -125,6 +125,7 @@ import { collectRuntimeHostSessionCatalogsWithCoverage, } from './runtime-host-session-catalog.js'; import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { ActiveInteractionRequestEvent, MessageContent, @@ -2061,6 +2062,16 @@ const makaBridge = { respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise { return invokeSessionRuntimeHost('sessions:respondToSandboxBoundary', sessionId, response); }, + respondToClientCapability( + sessionId: string, + response: ClientCapabilityResponse, + ): Promise { + return invokeSessionRuntimeHost( + 'sessions:respondToClientCapability', + sessionId, + response, + ); + }, respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response); }, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 275740b9c4..3ae10475e2 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -23,6 +23,7 @@ import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -141,6 +142,7 @@ export interface AppShellChatActions { options?: MessageContextOptions, ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; + respondToClientCapability(response: ClientCapabilityResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; retryMessages(sessionId: string): Promise; @@ -751,6 +753,30 @@ export function createAppShellChatActions(deps: { } } + async function respondToClientCapability(response: ClientCapabilityResponse) { + const sessionId = activeIdRef.current; + if (!sessionId) return; + try { + await window.maka.sessions.respondToClientCapability(sessionId, response); + onInteractionChanged?.(sessionId); + setInteractionBySession((current) => + dequeueInteractionByRequestId(current, sessionId, response.requestId), + ); + } catch (error) { + if (activeIdRef.current !== sessionId) return; + if (isSessionWorkspaceUnavailableError(error)) { + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); + } else { + toastApi.error( + copy.responseFailedTitle, + localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale), + undefined, + { sessionId }, + ); + } + } + } + async function respondToUserQuestion(response: UserQuestionResponse) { const sessionId = activeIdRef.current; if (!sessionId) return; @@ -837,6 +863,7 @@ export function createAppShellChatActions(deps: { send, enqueueMessage, respondToSandboxBoundary, + respondToClientCapability, respondToUserQuestion, refreshMessages, retryMessages, diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 09e752c618..1223c0e2ed 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -390,6 +390,7 @@ export function createAppShellSessionEventHandlers(options: { void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false); break; case 'sandbox_boundary_request': + case 'client_capability_request': case 'user_question_request': onInteractionChanged?.(sessionId); setInteractionBySession((current) => enqueueInteraction(current, sessionId, event)); @@ -414,6 +415,12 @@ export function createAppShellSessionEventHandlers(options: { dequeueInteractionByRequestId(current, sessionId, event.requestId), ); break; + case 'client_capability_decision_ack': + onInteractionChanged?.(sessionId); + setInteractionBySession((current) => + dequeueInteractionByRequestId(current, sessionId, event.requestId), + ); + break; case 'tool_result': setInteractionBySession((current) => dequeueInteractionByToolUseId(current, sessionId, event.toolUseId)); void refreshMessages(sessionId); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c96cccdc8f..749774aa3b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -832,6 +832,8 @@ function AppShellContent({ const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); const activeSandboxBoundary = activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; + const activeClientCapability = + activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeSession = activeCatalogSession; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; @@ -1829,6 +1831,7 @@ function AppShellContent({ send, enqueueMessage, respondToSandboxBoundary, + respondToClientCapability, respondToUserQuestion, refreshMessages, retryMessages, @@ -2970,6 +2973,8 @@ function AppShellContent({ stopPendingBySession={stopPendingBySession} activeSandboxBoundary={activeSandboxBoundary} respondToSandboxBoundary={respondToSandboxBoundary} + activeClientCapability={activeClientCapability} + respondToClientCapability={respondToClientCapability} activeQuestion={activeQuestion} respondToUserQuestion={respondToUserQuestion} stop={stop} diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 0ac9850d59..93f24b3871 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -18,7 +18,14 @@ */ import { useLayoutEffect, useRef, type ComponentProps, type RefObject } from 'react'; -import { Button, Composer, SandboxBoundaryPrompt, UserQuestionPrompt, Banner } from '@maka/ui'; +import { + Banner, + Button, + ClientCapabilityPrompt, + Composer, + SandboxBoundaryPrompt, + UserQuestionPrompt, +} from '@maka/ui'; import type { ComposerHandle, ComposerInteraction } from '@maka/ui'; import { useComposerMentionsContext } from './composer-mentions.js'; import { @@ -96,6 +103,8 @@ interface ChatComposerRegionProps stopPendingBySession: Record; respondToSandboxBoundary: ComponentProps['onRespond']; activeSandboxBoundary: ComponentProps['request'] | undefined; + activeClientCapability: ComponentProps['request'] | undefined; + respondToClientCapability: ComponentProps['onRespond']; activeQuestion: ComponentProps['request'] | undefined; respondToUserQuestion: ComponentProps['onRespond']; stop: ComponentProps['onStop']; @@ -118,6 +127,8 @@ export function ChatComposerRegion({ stopPendingBySession, respondToSandboxBoundary, activeSandboxBoundary, + activeClientCapability, + respondToClientCapability, activeQuestion, respondToUserQuestion, stop, @@ -216,6 +227,12 @@ export function ChatComposerRegion({ onRespond={respondToSandboxBoundary} /> )} + {activeClientCapability && ( + + )} {activeQuestion && ( ; + respondToClientCapability( + sessionId: string, + response: ClientCapabilityResponse, + ): Promise; respondToUserQuestion( sessionId: string, response: UserQuestionResponse, diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 29284f6050..8bf5c6bb0a 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -136,6 +136,7 @@ export function createFakeWorkbarServices( }, regenerateTurn: async () => undefined, respondToSandboxBoundary: async () => undefined, + respondToClientCapability: async () => undefined, respondToUserQuestion: async () => undefined, subscribeEvents: (_sessionId, _handler, onSeeded) => { onSeeded?.(); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index 8616c95d58..a12512b0fc 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -438,9 +438,11 @@ export function applyCompanionInteractionEvent( ): InteractionQueues { switch (event.type) { case 'sandbox_boundary_request': + case 'client_capability_request': case 'user_question_request': return enqueueInteraction(queues, sessionId, event); case 'sandbox_boundary_decision_ack': + case 'client_capability_decision_ack': return dequeueInteractionByRequestId(queues, sessionId, event.requestId); case 'tool_result': return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 93d4235c3c..8f3f0dfbb3 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -24,6 +24,7 @@ import { ChatView, ChatSurfaceLayout, Composer, + ClientCapabilityPrompt, SandboxBoundaryPrompt, UserQuestionPrompt, useToast, @@ -176,7 +177,10 @@ export function QuoteCompanionPanel(props: { )?.label : undefined) ?? activeModel?.model; - const activeInteraction = companion.activeSandboxBoundary ?? companion.activeQuestion; + const activeInteraction = + companion.activeSandboxBoundary ?? + companion.activeClientCapability ?? + companion.activeQuestion; const deriveTurnPresentation = useCallback< NonNullable['deriveTurnPresentation']> >( @@ -215,7 +219,9 @@ export function QuoteCompanionPanel(props: { {companion.error && ( )} - {(companion.activeSandboxBoundary || companion.activeQuestion) && ( + {(companion.activeSandboxBoundary || + companion.activeClientCapability || + companion.activeQuestion) && (

{companion.activeSandboxBoundary && ( )} + {companion.activeClientCapability && ( + + )} {companion.activeQuestion && ( Promise; stop: () => Promise; respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise; + respondToClientCapability: (response: ClientCapabilityResponse) => Promise; respondToUserQuestion: (response: UserQuestionResponse) => Promise; } @@ -1025,6 +1029,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan [mountedRef, sideChat], ); + const respondToClientCapability = useCallback( + async (response: ClientCapabilityResponse): Promise => { + const id = companionIdRef.current; + if (!mountedRef.current || !id) return; + try { + await sideChat.respondToClientCapability(id, response); + } catch { + if (mountedRef.current) setError(copyRef.current.errors.respondFailed); + } + }, + [mountedRef, sideChat], + ); + // Only the companion's own turns render; the forked parent history stays as // hidden model context. const messages = allMessages.filter( @@ -1043,6 +1060,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan : undefined; const activeSandboxBoundary = activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; + const activeClientCapability = + activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; @@ -1061,6 +1080,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan error, activeModel, activeSandboxBoundary, + activeClientCapability, activeQuestion, send, steer, @@ -1068,6 +1088,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan regenerate, stop, respondToSandboxBoundary, + respondToClientCapability, respondToUserQuestion, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 78cf43f8cb..31fdc82832 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -160,6 +160,8 @@ export function createDesktopWorkbarServices( bridge.sessions.regenerateTurn(sessionId, input), respondToSandboxBoundary: (sessionId, response) => bridge.sessions.respondToSandboxBoundary(sessionId, response), + respondToClientCapability: (sessionId, response) => + bridge.sessions.respondToClientCapability(sessionId, response), respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), subscribeEvents: (sessionId, handler, onSeeded, onSeedError) => diff --git a/packages/core/src/client-capability-grant.ts b/packages/core/src/client-capability-grant.ts index 502ee25ea0..d2ffca1f0f 100644 --- a/packages/core/src/client-capability-grant.ts +++ b/packages/core/src/client-capability-grant.ts @@ -46,6 +46,11 @@ export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGra readonly grantedAt: number; } +export interface ClientCapabilityResponse { + readonly requestId: string; + readonly decision: 'allow' | 'deny'; +} + const GRANT_SHAPE = defineObjectShape()( [ 'version', diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index e6f4534ee1..5e2c3bb740 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -37,6 +37,10 @@ import type { } from './permission.js'; import type { SandboxBoundaryExpansion, SandboxBoundaryRequestStatus } from './sandbox-boundary.js'; import type { UserQuestionRequest } from './user-question.js'; +import type { + ClientCapabilityGrantCapability, + ClientCapabilityGrantScope, +} from './client-capability-grant.js'; import type { PipeShellOutput, PtyShellOutput, @@ -557,6 +561,8 @@ export type SessionEvent = | AnyPermissionRequestEvent | SandboxBoundaryRequestEvent | SandboxBoundaryDecisionAckEvent + | ClientCapabilityRequestEvent + | ClientCapabilityDecisionAckEvent | PermissionAnswerAckEvent | PermissionClosureAckEvent | PermissionDecisionAckEvent @@ -1016,12 +1022,23 @@ export interface SandboxBoundaryRequestEvent extends BaseEvent { expansion: SandboxBoundaryExpansion; } +export interface ClientCapabilityRequestEvent extends BaseEvent { + type: 'client_capability_request'; + requestId: string; + toolUseId: string; + capability: ClientCapabilityGrantCapability; + scope: ClientCapabilityGrantScope; +} + /** * The requests a session can park on while it waits for the user. Both are * registered by RuntimeKernel while unanswered, so a surface that missed the * live event can rehydrate the prompt instead of stranding the run. */ -export type ActiveInteractionRequestEvent = SandboxBoundaryRequestEvent | UserQuestionRequestEvent; +export type ActiveInteractionRequestEvent = + | SandboxBoundaryRequestEvent + | UserQuestionRequestEvent + | ClientCapabilityRequestEvent; export interface SandboxBoundaryDecisionAckEvent extends BaseEvent { type: 'sandbox_boundary_decision_ack'; @@ -1032,6 +1049,13 @@ export interface SandboxBoundaryDecisionAckEvent extends BaseEvent { revision: number; } +export interface ClientCapabilityDecisionAckEvent extends BaseEvent { + type: 'client_capability_decision_ack'; + requestId: string; + toolUseId: string; + decision: 'allow' | 'deny'; +} + /** * Echo that the backend accepted a user-question answer. * The canonical answer remains owned by InteractionStore. diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 5fbcce8395..e0087c4efd 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -24,6 +24,7 @@ import type { StoredMessage } from '@maka/core/session'; import type { SteeringMessageSnapshot } from '../protocol/message.js'; import { createRuntimeHostSessionProjectionSeed, + projectRuntimeHostInteractionRequest, RuntimeHostSessionProjector, } from '../adapter/session-projector.js'; import { @@ -32,6 +33,48 @@ import { type SubscriptionFrame, } from '../protocol/index.js'; +test('projects Client Capability approvals without exposing provider identities', () => { + assert.deepEqual( + projectRuntimeHostInteractionRequest( + { + schemaVersion: 1, + interactionId: 'approval-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1, + request: { + kind: 'client_capability', + toolUseId: 'tool-1', + target: { + providerId: 'provider-secret', + contractId: 'contract-secret', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }, + }, + status: 'pending', + outcome: null, + }, + 10, + ), + [ + { + type: 'client_capability_request', + id: 'host-interaction:approval-1:1', + turnId: 'turn-1', + ts: 10, + requestId: 'approval-1', + toolUseId: 'tool-1', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }, + ], + ); +}); + test('applies authoritative replacement once and does not complete it again at Turn terminal', () => { const projector = new RuntimeHostSessionProjector( snapshot(), diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 0288be35ef..12478ef636 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -577,6 +577,16 @@ export function projectRuntimeHostInteractionRequest( }, ]; } + if (interaction.request.kind === 'client_capability') { + return [ + { + type: 'client_capability_request', + ...base, + capability: interaction.request.target.capability, + scope: structuredClone(interaction.request.target.scope), + }, + ]; + } return []; } diff --git a/packages/ui/src/client-capability-prompt.tsx b/packages/ui/src/client-capability-prompt.tsx new file mode 100644 index 0000000000..0e54cc1603 --- /dev/null +++ b/packages/ui/src/client-capability-prompt.tsx @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Button } from '@astryxdesign/core'; +import type { ClientCapabilityRequestEvent } from '@maka/core/events'; +import { useEffect, useId, useRef, useState } from 'react'; +import { getConversationCopy } from './conversation-copy.js'; +import { useUiLocale } from './locale-context.js'; +import { useMountedRef } from './use-mounted-ref.js'; + +export interface ClientCapabilityPromptProps { + request: ClientCapabilityRequestEvent; + onRespond(response: { requestId: string; decision: 'allow' | 'deny' }): void | Promise; +} + +export function ClientCapabilityPrompt({ + request, + onRespond, +}: ClientCapabilityPromptProps) { + const copy = getConversationCopy(useUiLocale()).clientCapability; + const titleId = useId(); + const [responsePending, setResponsePending] = useState(false); + const responsePendingRef = useRef(false); + const activeRequestIdRef = useRef(request.requestId); + const rejectButtonRef = useRef(null); + const mountedRef = useMountedRef(); + + useEffect(() => { + activeRequestIdRef.current = request.requestId; + responsePendingRef.current = false; + setResponsePending(false); + const frame = window.requestAnimationFrame(() => rejectButtonRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [request.requestId]); + + async function respond(decision: 'allow' | 'deny'): Promise { + if (responsePendingRef.current) return; + const requestId = request.requestId; + responsePendingRef.current = true; + setResponsePending(true); + try { + await onRespond({ requestId, decision }); + } finally { + if (activeRequestIdRef.current === requestId) { + responsePendingRef.current = false; + if (mountedRef.current) setResponsePending(false); + } + } + } + + return ( +
+
+
+

{copy.title}

+

{clientCapabilityLabel(request, copy)}

+

{copy.sessionNotice}

+
+
+
+
+
+ ); +} + +function clientCapabilityLabel( + request: ClientCapabilityRequestEvent, + copy: ReturnType['clientCapability'], +): string { + switch (request.capability) { + case 'browser': + if (request.scope.kind !== 'browser_origin') break; + return copy.browser(request.scope.origin); + case 'computer_use': + return copy.computerUse; + case 'desktop_mcp': + if (request.scope.kind !== 'mcp_tool') break; + return copy.desktopMcp(request.scope.serverId, request.scope.toolName); + } + throw new Error('Client Capability request has an invalid scope'); +} diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..dee50f95cf 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -40,6 +40,7 @@ export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolD export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js'; export { ToolResultPreview } from './tool-activity/tool-result-preview.js'; export { SandboxBoundaryPrompt } from './sandbox-boundary-prompt.js'; +export { ClientCapabilityPrompt } from './client-capability-prompt.js'; export { ChatSurfaceLayout } from './chat-surface-layout.js'; export type { ChatSurfaceLayoutProps } from './chat-surface-layout.js'; export { diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 39e5d395f2..59aca4bac2 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -216,6 +216,15 @@ export interface ConversationCopy { reject: string; allowSession: string; }; + clientCapability: { + title: string; + browser: (origin: string) => string; + computerUse: string; + desktopMcp: (serverId: string, toolName: string) => string; + sessionNotice: string; + reject: string; + allowSession: string; + }; questions: { other: string; otherDescription: string; @@ -492,6 +501,15 @@ const CONVERSATION_COPY = { reject: '拒绝', allowSession: '本任务允许', }, + clientCapability: { + title: '允许使用客户端能力?', + browser: (origin) => `允许 Browser 操作 ${origin}`, + computerUse: '允许 Computer Use 操作这台 Mac', + desktopMcp: (serverId, toolName) => `允许调用 ${serverId} 的 ${toolName} 工具`, + sessionNotice: '允许后,本任务中相同范围的后续操作将不再询问。', + reject: '拒绝', + allowSession: '本任务允许', + }, questions: { other: '其他', otherDescription: '输入一个不同的答案。', otherAriaLabel: '其他答案', otherPlaceholder: '输入你的答案', stop: '停止', stopping: '停止中…', previous: '上一题', submitting: '正在提交…', submit: '提交答案', next: '下一题' }, mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' }, workspace: { @@ -641,6 +659,15 @@ const CONVERSATION_COPY = { reject: 'Reject', allowSession: 'Allow for this task', }, + clientCapability: { + title: 'Allow this client capability?', + browser: (origin) => `Allow Browser to operate ${origin}`, + computerUse: 'Allow Computer Use to operate this Mac', + desktopMcp: (serverId, toolName) => `Allow ${toolName} from ${serverId}`, + sessionNotice: 'Matching operations will be allowed for the rest of this task.', + reject: 'Reject', + allowSession: 'Allow for this task', + }, questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' }, mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' }, workspace: { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d9bf1e4f0d..b6a6757a5d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -25,6 +25,7 @@ export * from './use-mounted-ref.js'; export * from './components.js'; export type { ComposerProps } from './components.js'; export type { SandboxBoundaryPromptProps } from './sandbox-boundary-prompt.js'; +export type { ClientCapabilityPromptProps } from './client-capability-prompt.js'; export type { ProjectRowActions, SessionHistoryGroup, From ae51fedc150d0c7e2b26ea662243bbf358c48cd1 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:41:07 +0800 Subject: [PATCH 15/30] fix(runtime): classify client capability interaction events Generated-by: OpenAI Codex --- packages/core/src/backend-types.ts | 9 ++++-- .../session-event-runtime-mapper.test.ts | 31 +++++++++++++++++++ .../src/session-event-runtime-mapper.ts | 22 ++++++++++--- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index e76e17fe63..9f326ca20f 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -190,11 +190,12 @@ export type BackendStopMode = 'immediate' | 'after_step'; /** * The live session-event vocabulary accepted from a backend. `queue_update` - * belongs to the runtime kernel, while legacy permission requests and + * belongs to the runtime kernel, Client Capability approval events belong to + * the Host-owned Interaction projection, and legacy permission requests and * acknowledgements were replaced by sandbox-boundary events. `send` stays * typed as `SessionEvent` for implementation ergonomics; the flow drops these - * retired variants at ingress so they are never mapped, observed, or persisted - * by a new run. + * non-backend variants at ingress so they are never mapped or persisted by a + * new run. */ export type BackendSessionEvent = Exclude< SessionEvent, @@ -204,6 +205,8 @@ export type BackendSessionEvent = Exclude< type: | 'queue_update' | 'message_admission' + | 'client_capability_request' + | 'client_capability_decision_ack' | 'permission_request' | 'permission_answer_ack' | 'permission_closure_ack' diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 045f94d21b..d8e4c985fe 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -34,6 +34,7 @@ import { mapCompleteStopReason, mapSessionEventToRuntimeEvent, createSessionEventMapMemory, + isLiveBackendSessionEvent, } from '../session-event-runtime-mapper.js'; import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js'; import { @@ -642,6 +643,36 @@ describe('SessionEvent projection coverage', () => { ); }); + test('keeps Host-owned Client Capability interactions out of backend projection', () => { + const request: SessionEvent = { + type: 'client_capability_request', + id: 'client-capability-request-1', + turnId: 'turn-1', + ts: 1, + requestId: 'request-1', + toolUseId: 'tool-1', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }; + const ack: SessionEvent = { + type: 'client_capability_decision_ack', + id: 'client-capability-ack-1', + turnId: 'turn-1', + ts: 2, + requestId: 'request-1', + toolUseId: 'tool-1', + decision: 'allow', + }; + + for (const event of [request, ack]) { + assert.equal(isLiveBackendSessionEvent(event), false); + assert.throws( + () => mapSessionEventToRuntimeEvent(event, ctx), + new RegExp(`${event.type} is not a backend event`, 'u'), + ); + } + }); + // The contract is over what a reader can actually meet: every mapped event // AgentRun admits to the ledger has to project. It asserts on the unclaimed // codes at either severity, not on the hard one alone — a control fact whose diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 63e251c84e..3f8240dc92 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -130,7 +130,7 @@ export function mapSessionEventToRuntimeEvent( ctx: RuntimeEventMapContext, memory: SessionEventMapMemory = createSessionEventMapMemory(), ): RuntimeEvent { - if (event.type === 'queue_update' || event.type === 'message_admission') { + if (isHostProjectionSessionEvent(event)) { // These are Host/kernel projection facts, not backend events. The live // ingress drops them, so reaching this line bypassed that authority boundary. throw new Error(`${event.type} is not a backend event`); @@ -143,10 +143,24 @@ export function mapSessionEventToRuntimeEvent( } export function isLiveBackendSessionEvent(event: SessionEvent): event is BackendSessionEvent { + return !isHostProjectionSessionEvent(event) && !isLegacyPermissionSessionEvent(event); +} + +function isHostProjectionSessionEvent(event: SessionEvent): event is Extract< + SessionEvent, + { + type: + | 'queue_update' + | 'message_admission' + | 'client_capability_request' + | 'client_capability_decision_ack'; + } +> { return ( - event.type !== 'queue_update' && - event.type !== 'message_admission' && - !isLegacyPermissionSessionEvent(event) + event.type === 'queue_update' || + event.type === 'message_admission' || + event.type === 'client_capability_request' || + event.type === 'client_capability_decision_ack' ); } From 87a938a9ba16101c4f44ca4c34782a1a3e87283e Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:46:11 +0800 Subject: [PATCH 16/30] fix(runtime-host): canonicalize client capability provider ids Generated-by: OpenAI Codex --- ...t-capability-admission-integration.test.ts | 247 ++++++++++++++++++ .../client-capability-provider-id.test.ts | 65 +++++ .../server/client-capability-coordinator.ts | 7 +- .../server/client-capability-provider-id.ts | 42 +++ 4 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts create mode 100644 packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts create mode 100644 packages/runtime-host/src/server/client-capability-provider-id.ts diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts new file mode 100644 index 0000000000..d753a3e1cc --- /dev/null +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -0,0 +1,247 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { + openInteractiveExecutionStoresForWrite, + type ExecutionStoresWriter, +} from '@maka/storage/execution-stores'; +import type { InteractiveInteractionStoreWriterFacade } from '@maka/storage/interaction-store'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + type InteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + HostClientCapabilityCoordinator, + type ClientCapabilitySnapshot, +} from '../server/client-capability-coordinator.js'; +import type { ClientCapabilityConnection } from '../server/client-capability-service.js'; +import { clientCapabilityProviderId } from '../server/client-capability-provider-id.js'; +import { + HostInteractionCoordinator, + type HostInteractionCoordinatorOptions, +} from '../server/interaction-coordinator.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; +import { SessionAdmissionGate } from '../server/session-admission-gate.js'; + +const RUN = Object.freeze({ + sessionId: 'session_1', + turnId: 'turn_1', + runId: 'run_1', +}); +const IDENTITY = Object.freeze({ + connectionId: 'desktop-connection-1', + principalKind: 'capability_provider' as const, + principalId: 'desktop:installation-secret', + clientInstanceId: 'desktop-client-secret', +}); + +test('persists the canonical authenticated provider identity through managed admission', async () => { + await withStore(async ({ store }) => { + const interactions = createInteractionCoordinator(store); + const runOwner = interactions.bindRun(RUN); + const capabilities = new HostClientCapabilityCoordinator({ + activation: new RuntimePolicyActivationGate(), + onModelToolsChanged: () => undefined, + interactions, + grants: store, + }); + let connection!: ClientCapabilityConnection; + connection = capabilities.attachConnection(IDENTITY, { + send: async (frame) => { + if (frame.kind === 'client.capability.call') { + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { + kind: 'browser_url', + url: 'https://example.com/private?token=secret', + }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { content: [{ type: 'text', text: 'snapshot' }] }, + }); + } + }, + }); + let snapshot: ClientCapabilitySnapshot | undefined; + try { + const replaced = await capabilities.handlers['client.capability.replace']( + { + registrationId: 'desktop-native-registration', + offers: [ + { + offerId: 'desktop_browser', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Desktop Browser', + tools: [ + { + serverId: 'desktop_browser', + name: 'browser_snapshot', + inputSchema: { type: 'object', additionalProperties: false }, + }, + ], + }, + ], + }, + connectionContext(IDENTITY.connectionId), + ); + assert.equal(replaced.ok, true, JSON.stringify(replaced)); + assert.deepEqual(await capabilities.bindSession(RUN.sessionId, IDENTITY.connectionId), { + ok: true, + }); + snapshot = capabilities.snapshotForSession(RUN.sessionId); + assert.ok(snapshot); + const tool = snapshot.tools[0]; + assert.ok(tool?.prepareExecution); + const context = managedContext(); + const preparing = tool.prepareExecution({}, context); + const request = await waitForPending(store); + assert.equal(request.request.kind, 'client_capability'); + if (request.request.kind !== 'client_capability') return; + + const providerId = clientCapabilityProviderId(IDENTITY); + assert.equal(request.request.target.providerId, providerId); + assert.equal(JSON.stringify(request).includes(IDENTITY.principalId), false); + assert.equal(JSON.stringify(request).includes(IDENTITY.clientInstanceId), false); + + const answered = await interactions.handlers['interaction.answer']( + { + sessionId: RUN.sessionId, + interactionId: request.requestId, + answer: { kind: 'client_capability', decision: 'allow' }, + }, + connectionContext('desktop-ui'), + ); + assert.equal(answered.ok, true); + const prepared = await preparing; + const grant = await store.readClientCapabilitySessionGrant({ + sessionId: RUN.sessionId, + ...request.request.target, + }); + assert.equal(grant?.providerId, providerId); + assert.deepEqual(await prepared.execute(context), { + content: [{ type: 'text', text: 'snapshot' }], + }); + } finally { + snapshot?.release(); + await connection.close(); + await capabilities.close(); + await runOwner.close('turn_terminal'); + runOwner.release(); + await interactions.close(); + } + }); +}); + +function managedContext() { + return { + ...RUN, + cwd: '/tmp', + toolCallId: 'browser-call-1', + permissionMode: 'ask' as const, + executionBoundary: createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; +} + +function connectionContext(connectionId: string): ConnectionContext { + return { + hostEpoch: 'host_epoch_1', + connectionId, + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), + }; +} + +async function waitForPending(store: InteractiveInteractionStoreWriterFacade) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const pending = await store.listSessionPending(RUN.sessionId); + if (pending[0]) return pending[0]; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error('Client Capability approval was not published'); +} + +function createInteractionCoordinator( + store: InteractiveInteractionStoreWriterFacade, +): HostInteractionCoordinator { + let now = 100; + const options: HostInteractionCoordinatorOptions = { + store, + sandboxBoundaries: { + createSandboxBoundaryRequest: async () => { + throw new Error('Unexpected sandbox boundary publication'); + }, + readSandboxBoundaryRequest: async () => undefined, + listPendingSandboxBoundaryRequests: async () => [], + settleSandboxBoundaryRequest: async () => { + throw new Error('Unexpected sandbox boundary settlement'); + }, + listHeaders: async () => [], + }, + sessionAdmission: new SessionAdmissionGate(), + sessions: { probeSessionRemoval: async () => ({ kind: 'present' }) }, + now: () => ++now, + preflightSessionSnapshot: () => true, + refreshCanonicalContinuity: async () => undefined, + onPoison: () => undefined, + onSandboxBoundarySettled: async () => undefined, + }; + return new HostInteractionCoordinator(options); +} + +interface StoreContext { + readonly owner: InteractiveRootOwner; + readonly store: InteractiveInteractionStoreWriterFacade; + readonly stores: ExecutionStoresWriter<'interactive'>; +} + +async function withStore(run: (context: StoreContext) => Promise): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-client-capability-admission-')); + const root = join(base, 'root'); + await mkdir(root); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + try { + await run({ owner, store: stores.interactionStore, stores }); + } finally { + if (!owner.closed) await owner.close(); + await rm(owner.controlDirectory, { recursive: true, force: true }); + await rm(base, { recursive: true, force: true }); + } +} diff --git a/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts new file mode 100644 index 0000000000..2aec7fb35f --- /dev/null +++ b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { decodeClientCapabilityGrantTarget } from '@maka/core/client-capability-grant'; +import { clientCapabilityProviderId } from '../server/client-capability-provider-id.js'; + +const IDENTITY = { + principalKind: 'capability_provider' as const, + principalId: 'desktop:installation-a', + clientInstanceId: 'client-instance-a', +}; + +describe('clientCapabilityProviderId', () => { + test('is stable, serializable, bounded, and does not disclose authenticated identity fields', () => { + const first = clientCapabilityProviderId(IDENTITY); + const second = clientCapabilityProviderId({ ...IDENTITY }); + + assert.equal(first, second); + assert.match(first, /^[A-Za-z0-9_-]{1,128}$/u); + assert.equal(first.includes(IDENTITY.principalId), false); + assert.equal(first.includes(IDENTITY.clientInstanceId), false); + assert.doesNotThrow(() => + decodeClientCapabilityGrantTarget({ + providerId: first, + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }), + ); + }); + + test('changes when any authenticated identity field changes', () => { + const original = clientCapabilityProviderId(IDENTITY); + const variants = [ + { ...IDENTITY, principalKind: 'local_owner' as const }, + { ...IDENTITY, principalId: 'desktop:installation-b' }, + { ...IDENTITY, clientInstanceId: 'client-instance-b' }, + ]; + + for (const variant of variants) { + assert.notEqual(clientCapabilityProviderId(variant), original); + } + }); +}); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 2c51017b91..b8dcf07a8a 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -59,6 +59,7 @@ import type { ClientCapabilityService, } from './client-capability-service.js'; import type { HostInteractionCoordinator } from './interaction-coordinator.js'; +import { clientCapabilityProviderId } from './client-capability-provider-id.js'; // Leave the Host deadline outside the provider's bounded action deadline so an // accepted call can return its real terminal result instead of outcome_unknown. @@ -1156,7 +1157,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } #provider(identity: ClientCapabilityConnectionIdentity): ClientProviderState { - const providerId = clientProviderId(identity.principalId, identity.clientInstanceId); + const providerId = clientCapabilityProviderId(identity); const trustedProvider = identity.principalKind === 'capability_provider'; if (identity.capabilityOwner && !trustedProvider) { throw new Error('Only a capability provider may declare a Client Capability owner'); @@ -1518,10 +1519,6 @@ function managedClientCapabilityGrantTarget( }); } -function clientProviderId(principalId: string, clientInstanceId: string): string { - return `${principalId}\0${clientInstanceId}`; -} - function serviceContract(serviceId: string, version: string): string { return `${serviceId}\0${version}`; } diff --git a/packages/runtime-host/src/server/client-capability-provider-id.ts b/packages/runtime-host/src/server/client-capability-provider-id.ts new file mode 100644 index 0000000000..501513ae13 --- /dev/null +++ b/packages/runtime-host/src/server/client-capability-provider-id.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import type { ClientCapabilityConnectionIdentity } from './client-capability-service.js'; + +const PROVIDER_ID_DOMAIN = 'maka.client-capability-provider.v1'; + +export function clientCapabilityProviderId( + identity: Pick< + ClientCapabilityConnectionIdentity, + 'principalKind' | 'principalId' | 'clientInstanceId' + >, +): string { + const digest = createHash('sha256') + .update( + JSON.stringify([ + PROVIDER_ID_DOMAIN, + identity.principalKind, + identity.principalId, + identity.clientInstanceId, + ]), + ) + .digest('base64url'); + return `cc_${digest}`; +} From b96a714e27238f50572fbbd8a0a012878aa6188f Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:55:44 +0800 Subject: [PATCH 17/30] fix(runtime): route client capability host admission Generated-by: OpenAI Codex --- ...t-capability-admission-integration.test.ts | 101 +++++++++++-- .../client-capability-coordinator.test.ts | 142 +++++++++++++++--- .../server/client-capability-coordinator.ts | 18 ++- .../runtime/src/__tests__/mcp-tools.test.ts | 6 +- .../pre-dispatch-refusal-ledger.test.ts | 42 +++--- .../__tests__/tool-runtime-settlement.test.ts | 122 ++++++++++++++- .../tool-runtime-sqlite-boundary.test.ts | 8 +- packages/runtime/src/mcp-tools.ts | 2 + packages/runtime/src/tool-runtime.ts | 77 +++++----- 9 files changed, 411 insertions(+), 107 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index d753a3e1cc..dfee23244f 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -25,6 +25,9 @@ import { test } from 'node:test'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { SessionHeader } from '@maka/core/session'; +import { ToolRuntime } from '@maka/runtime/tool-runtime'; import { openInteractiveExecutionStoresForWrite, type ExecutionStoresWriter, @@ -63,6 +66,7 @@ const IDENTITY = Object.freeze({ test('persists the canonical authenticated provider identity through managed admission', async () => { await withStore(async ({ store }) => { + const order: string[] = []; const interactions = createInteractionCoordinator(store); const runOwner = interactions.bindRun(RUN); const capabilities = new HostClientCapabilityCoordinator({ @@ -75,6 +79,7 @@ test('persists the canonical authenticated provider identity through managed adm connection = capabilities.attachConnection(IDENTITY, { send: async (frame) => { if (frame.kind === 'client.capability.call') { + order.push('accepted'); connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, @@ -84,6 +89,7 @@ test('persists the canonical authenticated provider identity through managed adm }, }); } else if (frame.kind === 'client.capability.admitted') { + order.push('admitted'); connection.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, @@ -123,9 +129,43 @@ test('persists the canonical authenticated provider identity through managed adm snapshot = capabilities.snapshotForSession(RUN.sessionId); assert.ok(snapshot); const tool = snapshot.tools[0]; - assert.ok(tool?.prepareExecution); - const context = managedContext(); - const preparing = tool.prepareExecution({}, context); + assert.ok(tool); + assert.equal(tool.hostAdmission, 'client_capability'); + assert.equal(tool.categoryHint, 'custom_tool'); + const runtime = new ToolRuntime({ + sessionId: RUN.sessionId, + header: sessionHeader(), + connection: llmConnection(), + modelId: 'model-1', + appendMessage: async () => undefined, + readExecutionBoundary: async () => + createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + newId: nextId(), + now: nextNow(), + getPermissionPauseTarget: () => null, + turnId: RUN.turnId, + runId: RUN.runId, + invocationId: 'invocation-1', + runtimeCommitSink: { + commitToolPrepared: async () => { + order.push('T1'); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + }); + const settling = runtime.settleToolCall({ + tool, + turnId: RUN.turnId, + stepId: 'step-1', + toolCallId: 'browser-call-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); const request = await waitForPending(store); assert.equal(request.request.kind, 'client_capability'); if (request.request.kind !== 'client_capability') return; @@ -144,15 +184,15 @@ test('persists the canonical authenticated provider identity through managed adm connectionContext('desktop-ui'), ); assert.equal(answered.ok, true); - const prepared = await preparing; + order.push('approved'); + const settlement = await settling; const grant = await store.readClientCapabilitySessionGrant({ sessionId: RUN.sessionId, ...request.request.target, }); assert.equal(grant?.providerId, providerId); - assert.deepEqual(await prepared.execute(context), { - content: [{ type: 'text', text: 'snapshot' }], - }); + assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] }); + assert.deepEqual(order, ['accepted', 'approved', 'T1', 'admitted']); } finally { snapshot?.release(); await connection.close(); @@ -164,18 +204,51 @@ test('persists the canonical authenticated provider identity through managed adm }); }); -function managedContext() { +function sessionHeader(): SessionHeader { return { - ...RUN, + id: RUN.sessionId, + workspaceRoot: '/tmp', cwd: '/tmp', - toolCallId: 'browser-call-1', - permissionMode: 'ask' as const, - executionBoundary: createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), - abortSignal: new AbortController().signal, - emitOutput: () => undefined, + createdAt: 1, + name: 'test', + titleIsManual: false, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'connection-1', + connectionLocked: true, + model: 'model-1', + permissionMode: 'ask', + schemaVersion: 1, + }; +} + +function llmConnection(): LlmConnection { + return { + slug: 'connection-1', + name: 'test', + providerType: 'openai', + defaultModel: 'model-1', + enabled: true, + createdAt: 1, + updatedAt: 1, }; } +function nextId(): () => string { + let value = 0; + return () => `event-${++value}`; +} + +function nextNow(): () => number { + let value = 100; + return () => ++value; +} + function connectionContext(connectionId: string): ConnectionContext { return { hostEpoch: 'host_epoch_1', diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index 7c2b5de07c..20f9e9a0df 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -135,24 +135,32 @@ describe('Host Client Capability coordinator', () => { const coordinator = createCoordinator(); let observedCall: unknown; let connection!: ClientCapabilityConnection; - connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { - send: async (frame) => { - if (frame.kind === 'client.capability.call') { - observedCall = frame; - connection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - } else if (frame.kind === 'client.capability.admitted') { - connection.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('path independent'), - }); - } + connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'connection-a', + 'connection-a', + 'remote-owner', + 'remote_owner', + ), + { + send: async (frame) => { + if (frame.kind === 'client.capability.call') { + observedCall = frame; + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult('path independent'), + }); + } + }, }, - }); + ); const replaced = await coordinator.handlers['client.capability.replace']( { registrationId: 'registration-a', @@ -284,6 +292,106 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); + test('trusts capability-provider Desktop bindings but not a remote owner spoofing their names', async () => { + const local = createCoordinator(); + const localConnection = local.attachConnection( + clientCapabilityConnectionIdentity( + 'local-connection', + 'desktop-local', + 'local_os_user', + 'capability_provider', + ), + { send: async () => undefined }, + ); + const localReplace = await local.handlers['client.capability.replace']( + { + registrationId: 'local-native-registration', + offers: [ + { + offerId: 'desktop_browser', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Desktop Browser', + tools: [ + { + serverId: 'desktop_browser', + name: 'browser_snapshot', + inputSchema: { type: 'object', additionalProperties: false }, + }, + ], + }, + ], + }, + connectionContext('local-connection'), + ); + assert.equal(localReplace.ok, true); + assert.deepEqual(await local.bindSession('local-session', 'local-connection'), { ok: true }); + const localSnapshot = local.snapshotForSession('local-session'); + assert.ok(localSnapshot); + assert.equal(localSnapshot.tools[0]?.categoryHint, 'custom_tool'); + assert.equal(localSnapshot.tools[0]?.hostAdmission, 'client_capability'); + localSnapshot.release(); + await localConnection.close(); + await local.close(); + + const remote = createCoordinator(); + let remoteConnection!: ClientCapabilityConnection; + remoteConnection = remote.attachConnection( + clientCapabilityConnectionIdentity( + 'remote-connection', + 'desktop-remote', + 'remote-owner', + 'remote_owner', + ), + { + send: async (frame) => { + if (frame.kind !== 'client.capability.call') return; + remoteConnection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'browser_url', url: 'https://example.com/' }, + }); + }, + }, + ); + const remoteReplace = await remote.handlers['client.capability.replace']( + { + registrationId: 'spoofed-native-registration', + offers: [ + { + offerId: 'desktop_browser', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Spoofed Desktop Browser', + tools: [ + { + serverId: 'desktop_browser', + name: 'browser_snapshot', + inputSchema: { type: 'object', additionalProperties: false }, + }, + ], + }, + ], + }, + connectionContext('remote-connection'), + ); + assert.equal(remoteReplace.ok, true); + assert.deepEqual(await remote.bindSession('remote-session', 'remote-connection'), { ok: true }); + const remoteSnapshot = remote.snapshotForSession('remote-session'); + assert.ok(remoteSnapshot); + assert.equal(remoteSnapshot.tools[0]?.categoryHint, 'client_capability'); + assert.equal(remoteSnapshot.tools[0]?.hostAdmission, 'client_capability'); + await assert.rejects( + () => prepare(remoteSnapshot.tools[0], {}, 'spoofed-browser-call'), + /requires a trusted Desktop provider/u, + ); + remoteSnapshot.release(); + await remoteConnection.close(); + await remote.close(); + }); + test('approves a trusted Browser origin once and reuses the Session Grant across tools', async () => { let approvedTarget: | Parameters< diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index b8dcf07a8a..b69f1fb3e3 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -600,12 +600,14 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, interactive), { callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, categoryHint: 'client_capability', + hostAdmission: 'client_capability', recoveryMode: 'outcome_unknown', executionLocation: 'remote', }), ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, trusted), { callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, categoryHint: 'custom_tool', + hostAdmission: 'client_capability', recoveryMode: 'outcome_unknown', executionLocation: 'remote', activityKindForDescriptor: (descriptor) => trustedClientToolActivityKind(descriptor), @@ -879,7 +881,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService let registration: CapabilityRegistration; try { if ( - provider.trustedProvider && + provider.principalKind === 'capability_provider' && (input.services?.length || input.offers.some( (offer) => offer.affinity !== 'session' || offer.hostPathAccess !== 'none', @@ -1165,13 +1167,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService let provider = this.#providers.get(providerId); if (!provider) { provider = { - providerId, - principalId: identity.principalId, - clientInstanceId: identity.clientInstanceId, - ...(identity.credentialBoundClientInstanceId - ? { credentialBoundClientInstanceId: identity.credentialBoundClientInstanceId } - : {}), - principalKind: identity.principalKind, + providerId, + principalId: identity.principalId, + clientInstanceId: identity.clientInstanceId, + ...(identity.credentialBoundClientInstanceId + ? { credentialBoundClientInstanceId: identity.credentialBoundClientInstanceId } + : {}), + principalKind: identity.principalKind, trustedProvider, ...(identity.capabilityOwner ? { capabilityOwner: Object.freeze({ ...identity.capabilityOwner }) } diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 0e38eacb7a..6633378905 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -265,8 +265,12 @@ test('MCP preparation keeps provider admission separate from execution', async ( }, callTool: async () => assert.fail('Prepared MCP tools must not call the direct path'), }; - const [tool] = buildMcpTools(provider, { categoryHint: 'client_capability' }); + const [tool] = buildMcpTools(provider, { + categoryHint: 'custom_tool', + hostAdmission: 'client_capability', + }); assert.ok(tool?.prepareExecution); + assert.equal(tool?.hostAdmission, 'client_capability'); const boundary = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); const prepared = await tool.prepareExecution( {}, diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index aa3eb75409..5207802f61 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -190,6 +190,20 @@ const swarmTool: MakaTool = { }, }; +function clientCapabilityTool(): MakaTool { + return { + name: 'browser_click', + description: 'test', + categoryHint: 'client_capability', + hostAdmission: 'client_capability', + parameters: z.object({}), + prepareExecution: async () => { + assert.fail('a pre-dispatch refusal must not prepare Client Capability work'); + }, + impl: async () => ({ ok: true }), + }; +} + /** * Every pre-dispatch refusal, not just the one that produced the bug report. * @@ -298,14 +312,7 @@ const REFUSAL_PATHS: Array<{ throw new Error('boundary read exploded'); }, }); - const tool: MakaTool = { - name: 'browser_click', - description: 'test', - categoryHint: 'client_capability', - parameters: z.object({}), - impl: async () => ({ ok: true }), - }; - return settle(h, tool, {}, { runtime, toolCallId: 'call_boundary_read' }); + return settle(h, clientCapabilityTool(), {}, { runtime, toolCallId: 'call_boundary_read' }); }, }, { @@ -313,14 +320,7 @@ const REFUSAL_PATHS: Array<{ expect: /require the Bypass execution boundary/, drive: (h) => { // The default test boundary is `external`, not `bypass`. - const tool: MakaTool = { - name: 'browser_click', - description: 'test', - categoryHint: 'client_capability', - parameters: z.object({}), - impl: async () => ({ ok: true }), - }; - return settle(h, tool, {}, { toolCallId: 'call_boundary_blocked' }); + return settle(h, clientCapabilityTool(), {}, { toolCallId: 'call_boundary_blocked' }); }, }, ]; @@ -349,15 +349,7 @@ for (const path of REFUSAL_PATHS) { test('client-capability refusal carries actionable bypass metadata', async () => { const h = harness(); - const tool: MakaTool = { - name: 'browser_click', - description: 'test', - categoryHint: 'client_capability', - parameters: z.object({}), - impl: async () => ({ ok: true }), - }; - - await settle(h, tool, {}, { toolCallId: 'call_boundary_metadata' }); + await settle(h, clientCapabilityTool(), {}, { toolCallId: 'call_boundary_metadata' }); const result = h.events.find( (event) => event.type === 'tool_result' && event.toolUseId === 'call_boundary_metadata', diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index ff053c2af1..5501aa7d98 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -32,13 +32,14 @@ import { buildForegroundBashTool, buildManagedBashTool } from '../shell-tools.js import { ToolRuntime, type MakaTool, type ToolRuntimeInput } from '../tool-runtime.js'; describe('ToolRuntime settlement', () => { - it('requires the bypass boundary before dispatching Client Capability tools', async () => { + it('rejects Client Capability Host admission without preparation in every boundary', async () => { let calls = 0; const clientTool: MakaTool = { name: 'client_browser', description: 'client browser', parameters: {}, categoryHint: 'client_capability', + hostAdmission: 'client_capability', impl: () => { calls += 1; return { ok: true }; @@ -67,7 +68,7 @@ describe('ToolRuntime settlement', () => { assert.equal(calls, 0); assert.match( String((blocked.result as { error?: unknown }).error), - /require the Bypass execution boundary/, + /missing its Host admission/u, ); const allowed = await settle( @@ -76,8 +77,11 @@ describe('ToolRuntime settlement', () => { }), 'call-bypass', ); - assert.equal(calls, 1); - assert.deepEqual(allowed.result, { ok: true }); + assert.equal(calls, 0); + assert.match( + String((allowed.result as { error?: unknown }).error), + /missing its Host admission/u, + ); }); it('admits a prepared Client Capability in Ask mode before executing it', async () => { @@ -87,6 +91,7 @@ describe('ToolRuntime settlement', () => { description: 'client browser', parameters: {}, categoryHint: 'client_capability', + hostAdmission: 'client_capability', prepareExecution: async () => { order.push('prepare'); return { @@ -120,6 +125,110 @@ describe('ToolRuntime settlement', () => { assert.deepEqual(settlement.result, { ok: true }); }); + it('prepares Bypass Client Capability work before T1 and admits only after T1', async () => { + const order: string[] = []; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + categoryHint: 'custom_tool', + hostAdmission: 'client_capability', + prepareExecution: async () => { + order.push('prepare'); + return { + execute: async () => { + order.push('admit'); + return { ok: true }; + }, + cancel: () => { + order.push('cancel'); + }, + }; + }, + impl: () => assert.fail('Bypass must not use the direct implementation'), + }; + const runtime = makeRuntime({ + readExecutionBoundary: async () => createBypassExecutionBoundary(0), + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: { + commitToolPrepared: async () => { + order.push('T1'); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + }); + + const settlement = await runtime.settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-bypass-prepared', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + + assert.deepEqual(order, ['prepare', 'T1', 'admit']); + assert.deepEqual(settlement.result, { ok: true }); + }); + + it('cancels prepared Client Capability work when T1 fails', async () => { + const order: string[] = []; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + hostAdmission: 'client_capability', + prepareExecution: async () => { + order.push('prepare'); + return { + execute: async () => { + order.push('admit'); + return { ok: true }; + }, + cancel: () => { + order.push('cancel'); + }, + }; + }, + impl: () => assert.fail('T1 failure must not use the direct implementation'), + }; + const runtime = makeRuntime({ + readExecutionBoundary: async () => createBypassExecutionBoundary(0), + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: { + commitToolPrepared: async () => { + order.push('T1'); + throw new Error('durable write failed'); + }, + commitToolOutcome: async () => assert.fail('T2 must not run'), + }, + }); + + await assert.rejects( + runtime.settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-bypass-t1-failure', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }), + /durable write failed/u, + ); + assert.deepEqual(order, ['prepare', 'T1', 'cancel']); + }); + it('keeps the durable Bash command while omitting it from the durable projection', async () => { const runtime = makeRuntime(); const events: SessionEvent[] = []; @@ -575,7 +684,10 @@ describe('ToolRuntime settlement', () => { function makeRuntime( overrides: Partial< - Pick + Pick< + ToolRuntimeInput, + 'readExecutionBoundary' | 'spawnChildSession' | 'runId' | 'invocationId' | 'runtimeCommitSink' + > > = {}, ): ToolRuntime { return createTestToolRuntime({ diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index a15bd68793..6146bae39b 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -150,7 +150,11 @@ describe('ToolRuntime with real SQLite boundary', () => { return { content: [{ type: 'text', text: 'ok' }] }; }, }, - { categoryHint: 'client_capability', recoveryMode: 'outcome_unknown' }, + { + categoryHint: 'custom_tool', + hostAdmission: 'client_capability', + recoveryMode: 'outcome_unknown', + }, ); assert.ok(clientTool); const runtime = createTestToolRuntime({ @@ -183,7 +187,7 @@ describe('ToolRuntime with real SQLite boundary', () => { }); assert.equal(implementationCalls, 0); - assert.match(JSON.stringify(result.result), /require the Bypass execution boundary/u); + assert.match(JSON.stringify(result.result), /missing its Host admission/u); const toolEvents = published.filter( (event) => event.type === 'tool_start' || event.type === 'tool_result', ); diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 478f29d1e9..a7c48941db 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -81,6 +81,7 @@ export interface McpToolInvocationContext { export interface BuildMcpToolsOptions { callTimeoutMs?: number; categoryHint?: ToolCategory; + hostAdmission?: MakaTool['hostAdmission']; recoveryMode?: ToolRecoveryMode; executionLocation?: 'host' | 'remote'; activityKindForDescriptor?: (descriptor: McpToolDescriptor) => ToolActivityKind | undefined; @@ -111,6 +112,7 @@ export function buildMcpTools( // The trusted composition may select a stricter open-world category; // ordinary MCP servers retain the side-effecting network default. categoryHint: options.categoryHint ?? 'network_send', + ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}), ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), parameters: jsonSchema(descriptor.inputSchema), ...(provider.prepareTool diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 656eb3f667..5214ffda49 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -181,6 +181,8 @@ export interface MakaTool

{ activityKind?: ToolActivityKind; /** Optional trusted category override for custom tools. */ categoryHint?: ToolCategory; + /** Host-owned admission contract, independent of model/UI tool categorization. */ + hostAdmission?: 'client_capability'; /** Optional trusted facts about the executor that runs this tool. */ executionFacts?: ToolExecutionFacts; /** @@ -333,6 +335,8 @@ const SUBAGENT_TOOL_LIMIT_MESSAGE = '子代理并发过多:同一轮最多 5 个子代理。请等待已有任务完成后再继续。'; const CLIENT_CAPABILITY_BOUNDARY_MESSAGE = 'Client Capability tools require the Bypass execution boundary because their client-side effects cannot be sandboxed by the Host. Switch this Session to Bypass and retry.'; +const CLIENT_CAPABILITY_PREPARATION_MESSAGE = + 'Client Capability tool is missing its Host admission preparation contract.'; function composeChildAbortSignal( invocationSignal: AbortSignal, @@ -1406,7 +1410,7 @@ export class ToolRuntime { let clientCapabilityBoundary: ExecutionBoundary | undefined; let preparedExecution: PreparedMakaToolExecution | undefined; - if (tool.categoryHint === 'client_capability') { + if (tool.hostAdmission === 'client_capability') { try { clientCapabilityBoundary = await this.readExecutionBoundary(); } catch (error) { @@ -1421,11 +1425,13 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(reason); } - if ( - clientCapabilityBoundary.kind !== 'bypass' && - (this.input.header.permissionMode !== 'ask' || !tool.prepareExecution) - ) { - await refuseBeforeDispatch(CLIENT_CAPABILITY_BOUNDARY_MESSAGE, { + const admissionFailure = !tool.prepareExecution + ? CLIENT_CAPABILITY_PREPARATION_MESSAGE + : clientCapabilityBoundary.kind !== 'bypass' && this.input.header.permissionMode !== 'ask' + ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE + : undefined; + if (admissionFailure) { + await refuseBeforeDispatch(admissionFailure, { reason: 'requires_bypass', source: 'client_capability', }); @@ -1436,36 +1442,37 @@ export class ToolRuntime { errorClass: 'ClientCapabilityBoundary', }); this.recordLoopGateOutcome(callSignature, true); - return this.errorReturn(CLIENT_CAPABILITY_BOUNDARY_MESSAGE); + return this.errorReturn(admissionFailure); } - if (tool.prepareExecution) { - const pauseTarget = this.input.getPermissionPauseTarget(); - pauseTarget?.pause(); - try { - preparedExecution = await tool.prepareExecution(structuredClone(executionArgs) as never, { - sessionId: this.input.sessionId, - turnId, - ...(runId ? { runId } : {}), - cwd: this.input.header.cwd, - executionBoundary: clientCapabilityBoundary, - permissionMode: this.input.header.permissionMode, - toolCallId: toolUseId, - abortSignal: ctx.abortSignal, - }); - } catch (error) { - const reason = formatSyntheticToolErrorText(error); - await refuseBeforeDispatch(reason); - trace?.emit('tool', 'tool_failed', 'Client Capability preparation failed', { - toolUseId, - toolName: tool.name, - status: 'error', - errorClass: 'ClientCapabilityPreparation', - }); - this.recordLoopGateOutcome(callSignature, true); - return this.errorReturn(reason); - } finally { - pauseTarget?.resume(); - } + // Narrowed by admissionFailure above; Bypass still prepares so the + // provider cannot regain a direct pre-T1 dispatch path. + const prepareExecution = tool.prepareExecution!; + const pauseTarget = this.input.getPermissionPauseTarget(); + pauseTarget?.pause(); + try { + preparedExecution = await prepareExecution(structuredClone(executionArgs) as never, { + sessionId: this.input.sessionId, + turnId, + ...(runId ? { runId } : {}), + cwd: this.input.header.cwd, + executionBoundary: clientCapabilityBoundary, + permissionMode: this.input.header.permissionMode, + toolCallId: toolUseId, + abortSignal: ctx.abortSignal, + }); + } catch (error) { + const reason = formatSyntheticToolErrorText(error); + await refuseBeforeDispatch(reason); + trace?.emit('tool', 'tool_failed', 'Client Capability preparation failed', { + toolUseId, + toolName: tool.name, + status: 'error', + errorClass: 'ClientCapabilityPreparation', + }); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(reason); + } finally { + pauseTarget?.resume(); } } From 94f33c2d766a5834ff088a1e8deb7987c377b8d4 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:11:33 +0800 Subject: [PATCH 18/30] fix(desktop): enforce browser origin leases Generated-by: OpenAI Codex --- .../__tests__/browser-origin-lease.test.ts | 57 ++++ .../main/__tests__/browser-session.test.ts | 6 + .../src/main/__tests__/browser-tools.test.ts | 274 ++++++++++++++++-- .../runtime-host-native-capabilities.test.ts | 5 + .../src/main/browser/automation-host.ts | 3 + apps/desktop/src/main/browser/browser-host.ts | 20 ++ .../main/browser/browser-origin-admission.ts | 41 +++ .../src/main/browser/browser-origin-lease.ts | 96 ++++++ .../desktop/src/main/browser/browser-tools.ts | 158 ++++++---- apps/desktop/src/main/browser/controller.ts | 20 +- apps/desktop/src/main/browser/session.ts | 28 +- .../main/runtime-host-native-capabilities.ts | 26 +- 12 files changed, 641 insertions(+), 93 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/browser-origin-lease.test.ts create mode 100644 apps/desktop/src/main/browser/browser-origin-admission.ts create mode 100644 apps/desktop/src/main/browser/browser-origin-lease.ts diff --git a/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts new file mode 100644 index 0000000000..f16136eb24 --- /dev/null +++ b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js'; + +test('an Origin lease remembers A→B→A instead of trusting the final URL', () => { + let url = 'https://a.example/start'; + const tracker = new BrowserOriginLeaseTracker(() => url); + const lease = tracker.open(url, 'observe'); + url = 'https://b.example/private?token=secret'; + tracker.recordNavigation(url); + url = 'https://a.example/back'; + tracker.recordNavigation(url); + + assert.deepEqual(lease.snapshot(), { + epoch: 2, + url: 'https://a.example/back', + violatedUrl: 'https://b.example/private?token=secret', + }); +}); + +test('a navigate lease allows the old page but only arms for its approved target', () => { + let url = 'https://old.example/'; + const tracker = new BrowserOriginLeaseTracker(() => url); + const lease = tracker.open('https://approved.example/path', 'navigate'); + assert.equal(lease.snapshot().violatedUrl, undefined); + assert.throws( + () => lease.startNavigation('https://other.example/path'), + /outside the approved Origin/u, + ); + + lease.startNavigation('https://approved.example/next'); + url = 'https://approved.example/next'; + tracker.recordNavigation(url); + assert.deepEqual(lease.snapshot(), { + epoch: 1, + url: 'https://approved.example/next', + }); +}); diff --git a/apps/desktop/src/main/__tests__/browser-session.test.ts b/apps/desktop/src/main/__tests__/browser-session.test.ts index 08e9a29cec..542172700c 100644 --- a/apps/desktop/src/main/__tests__/browser-session.test.ts +++ b/apps/desktop/src/main/__tests__/browser-session.test.ts @@ -93,6 +93,12 @@ function installHost(overrides: Partial = {}): HostSpy { const spy: HostSpy = { resolved: [], released: [], disposed: [], host: null as never }; const host: BrowserViewHost = { currentUrl: () => "https://example.com/", + openOriginLease: () => ({ + approvedOrigin: 'https://example.com', + startNavigation: () => {}, + snapshot: () => ({ epoch: 0, url: 'https://example.com/' }), + release: () => {}, + }), canDrive: () => true, resolveEndpoint: async (id) => { spy.resolved.push(id); diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index 6f34ad1d64..355cc82c08 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -26,7 +26,9 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import type { IPage } from '@jackwener/opencli/types'; +import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { withBrowserOriginAdmission } from '../browser/browser-origin-admission.js'; import { buildBrowserClickTool, buildBrowserExtractTool, @@ -39,55 +41,100 @@ import { takeoverNote, } from '../browser/browser-tools.js'; import { type BridgeLike, resetBrowserSessionsForTest, setBridgeFactoryForTest } from '../browser/session.js'; -import { type BrowserViewHost, provideBrowserViewHost } from '../browser/browser-host.js'; +import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; +import { + type BrowserViewHost, + provideBrowserViewHost, +} from '../browser/browser-host.js'; +import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js'; type FakePageConfig = { url?: string; + afterGotoUrl?: string; afterClickUrl?: string; + afterFillUrl?: string; title?: string; click?: { matches_n: number; match_level: 'exact' | 'stable' | 'reidentified' }; fill?: { verified: boolean; actual: string; match_level: 'exact' | 'stable' | 'reidentified' }; snapshot?: unknown; + snapshotImpl?: (browser: FakeBrowser) => unknown | Promise; extractHtml?: string; - waitImpl?: (options: unknown) => Promise; + extractImpl?: (browser: FakeBrowser) => void; + waitImpl?: (options: unknown, browser: FakeBrowser) => Promise; + onLeaseOpened?: (browser: FakeBrowser) => void; + takeoverReloadImpl?: (browser: FakeBrowser) => void; +}; + +type FakeBrowser = { + url: string; + onNavigate?: (url: string) => void; + navigate(url: string): void; + clicks: number; + fills: number; + presses: number; }; -function makeFakePage(cfg: FakePageConfig): IPage { - let currentUrl = cfg.url ?? ''; +function createFakeBrowser(url: string): FakeBrowser { + const browser: FakeBrowser = { + url, + navigate(nextUrl) { + browser.url = nextUrl; + browser.onNavigate?.(nextUrl); + }, + clicks: 0, + fills: 0, + presses: 0, + }; + return browser; +} + +function makeFakePage(cfg: FakePageConfig, browser: FakeBrowser): IPage { return { - getCurrentUrl: async () => currentUrl || null, - goto: async () => {}, + getCurrentUrl: async () => browser.url || null, + goto: async (url: string) => browser.navigate(cfg.afterGotoUrl ?? url), evaluate: async (js: string) => { - if (js.includes('location.href')) return currentUrl as never; + if (js.includes('location.href')) return browser.url as never; if (js.includes('document.title')) return (cfg.title ?? '') as never; if (js.includes('outerHTML')) { + cfg.extractImpl?.(browser); return (cfg.extractHtml === undefined ? null : { html: cfg.extractHtml, truncated: false }) as never; } return '' as never; }, - snapshot: async () => cfg.snapshot ?? '[1] link "Home"', + snapshot: async () => + cfg.snapshotImpl ? cfg.snapshotImpl(browser) : (cfg.snapshot ?? '[1] link "Home"'), click: async () => { - if (cfg.afterClickUrl) currentUrl = cfg.afterClickUrl; + browser.clicks += 1; + if (cfg.afterClickUrl) browser.navigate(cfg.afterClickUrl); return cfg.click ?? { matches_n: 1, match_level: 'exact' }; }, - fillText: async () => - cfg.fill + fillText: async () => { + browser.fills += 1; + if (cfg.afterFillUrl) browser.navigate(cfg.afterFillUrl); + return cfg.fill ? { filled: true, verified: cfg.fill.verified, expected: '', actual: cfg.fill.actual, length: 0, matches_n: 1, match_level: cfg.fill.match_level } - : { filled: true, verified: true, expected: '', actual: '', length: 0, matches_n: 1, match_level: 'exact' }, - pressKey: async () => {}, + : { filled: true, verified: true, expected: '', actual: '', length: 0, matches_n: 1, match_level: 'exact' }; + }, + pressKey: async () => { + browser.presses += 1; + }, wait: async (options: unknown) => { - if (cfg.waitImpl) return cfg.waitImpl(options); + if (cfg.waitImpl) return cfg.waitImpl(options, browser); }, } as unknown as IPage; } class FakeBridge implements BridgeLike { - constructor(private readonly page: IPage) {} + constructor( + private readonly page: IPage, + private readonly onReload?: () => void, + ) {} async connect(): Promise { return this.page; } async close(): Promise {} - async send(): Promise { + async send(method: string): Promise { + if (method === 'Page.reload') this.onReload?.(); return {}; } async waitForEvent(): Promise { @@ -95,16 +142,27 @@ class FakeBridge implements BridgeLike { } } -function install(cfg: FakePageConfig): void { +function install(cfg: FakePageConfig): FakeBrowser { + const browser = createFakeBrowser(cfg.url ?? 'https://example.com/'); + const originLeases = new BrowserOriginLeaseTracker(() => browser.url); + browser.onNavigate = (url) => originLeases.recordNavigation(url); const host: BrowserViewHost = { - currentUrl: () => cfg.url ?? "https://example.com/", + currentUrl: () => browser.url, + openOriginLease: (_sessionId, approvedUrl, kind) => { + const lease = originLeases.open(approvedUrl, kind); + cfg.onLeaseOpened?.(browser); + return lease; + }, canDrive: () => true, resolveEndpoint: async (id) => ({ cdpEndpoint: `ws://127.0.0.1:1/${id}` }), releaseSession: async () => {}, disposeSession: async () => {}, }; provideBrowserViewHost(host); - setBridgeFactoryForTest(() => new FakeBridge(makeFakePage(cfg))); + setBridgeFactoryForTest( + () => new FakeBridge(makeFakePage(cfg, browser), () => cfg.takeoverReloadImpl?.(browser)), + ); + return browser; } function ctx(): MakaToolContext { @@ -119,7 +177,10 @@ function ctx(): MakaToolContext { } function run

(tool: MakaTool, args: P): Promise { - return Promise.resolve(tool.impl(args, ctx())) as Promise; + return withBrowserOriginAdmission( + { sessionId: 's1', url: 'https://example.com/approved?secret=grant' }, + () => Promise.resolve(tool.impl(args, ctx())) as Promise, + ); } afterEach(() => { @@ -141,27 +202,32 @@ describe('browser tool helpers', () => { assert.equal(takeoverNote({ takeoverReloaded: false }), ''); assert.match(takeoverNote({ takeoverReloaded: true }), /reloaded once/); }); - }); describe('browser tool execution', () => { - it('navigate rejects a non-web URL before connecting', async () => { install({}); await assert.rejects(run(buildBrowserNavigateTool(), { url: 'file:///etc/passwd' }), /Not a navigable URL/); }); - it('navigate suppresses the destination title after a cross-Origin redirect', async () => { - install({ url: 'https://other.example/landing', title: 'Private destination title' }); + it('navigate returns only a sanitized destination after a cross-Origin redirect', async () => { + install({ + url: 'https://old.example/', + afterGotoUrl: 'https://other.example/landing?token=private#account', + title: 'Private destination title', + }); const out = await run(buildBrowserNavigateTool(), { url: 'https://example.com/start' }); - assert.match(out, /Navigated to https:\/\/other\.example\/landing/); - assert.doesNotMatch(out, /Private destination title/); + assert.equal( + out, + 'Navigated to https://other.example/landing. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /Private destination title|token|account/); }); it('click returns only the new URL after cross-Origin navigation', async () => { install({ url: 'https://example.com/start', - afterClickUrl: 'https://other.example/landing', + afterClickUrl: 'https://other.example/landing?token=private#account', }); const out = await run(buildBrowserClickTool(), { ref: '[1]' }); assert.equal( @@ -171,6 +237,133 @@ describe('browser tool execution', () => { assert.doesNotMatch(out, /Clicked|matched/); }); + it('rejects a navigation after admission but before the first page call', async () => { + const browser = install({ + snapshot: 'private snapshot', + onLeaseOpened: (state) => state.navigate('https://other.example/private?token=secret'), + }); + const out = await run(buildBrowserSnapshotTool(), {}); + assert.equal( + out, + 'Navigated to https://other.example/private. Access to the new site requires approval on the next Browser call.', + ); + assert.equal(browser.url, 'https://other.example/private?token=secret'); + assert.doesNotMatch(out, /private snapshot|token/); + }); + + it('covers the Provider second-check → first page await gap end to end', async () => { + install({ + snapshot: 'private snapshot', + onLeaseOpened: (state) => state.navigate('https://other.example/private?token=secret'), + }); + const computerUseTools = [] as unknown as ComputerUseToolSet; + computerUseTools.clearSession = () => undefined; + computerUseTools.sessionEvents = {} as ComputerUseToolSet['sessionEvents']; + let resolved = 0; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [buildBrowserSnapshotTool()], + resolveBrowserUrl: () => { + resolved += 1; + return 'https://example.com/approved'; + }, + releaseBrowserSession() {}, + computerUseTools, + releaseComputerUseSession() {}, + }); + assert.ok(provider.call); + if (!provider.call) return; + const result = await provider.call( + { + kind: 'client.capability.call', + invocationId: 'invocation-1', + registrationId: 'registration-1', + offerId: 'desktop_browser', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + arguments: {}, + sessionId: 's1', + turnId: 't1', + toolCallId: 'c1', + cwd: '/tmp', + }, + { + signal: new AbortController().signal, + accept: async () => undefined, + }, + ); + assert.equal(resolved, 2); + assert.deepEqual(result, { + content: [ + { + type: 'text', + text: 'Navigated to https://other.example/private. Access to the new site requires approval on the next Browser call.', + }, + ], + }); + assert.doesNotMatch(JSON.stringify(result), /private snapshot|token/); + }); + + it('discards a snapshot when the page crosses Origin and returns to the approved site', async () => { + install({ + snapshotImpl: (browser) => { + browser.navigate('https://other.example/private?token=secret'); + browser.navigate('https://example.com/back'); + return 'private snapshot'; + }, + }); + const out = await run(buildBrowserSnapshotTool(), {}); + assert.equal( + out, + 'Navigated to https://other.example/private. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /private snapshot|token/); + }); + + it('does not click after the approved Origin is lost', async () => { + const browser = install({ + onLeaseOpened: (state) => state.navigate('https://other.example/button'), + }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.match(out, /Navigated to https:\/\/other\.example\/button/u); + assert.equal(browser.clicks, 0); + }); + + it('covers an A→B→A takeover reload before the first mutating page call', async () => { + const browser = install({ + takeoverReloadImpl: (state) => { + state.navigate('https://other.example/reload?token=secret'); + state.navigate('https://example.com/back'); + }, + }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal( + out, + 'Navigated to https://other.example/reload. Access to the new site requires approval on the next Browser call.', + ); + assert.equal(browser.clicks, 0); + assert.doesNotMatch(out, /token/); + }); + + it('does not press Enter when filling navigates away from the approved Origin', async () => { + const browser = install({ + afterFillUrl: 'https://other.example/login?token=private#form', + }); + const out = await run(buildBrowserTypeTool(), { ref: '[2]', text: 'hello', submit: true }); + assert.equal( + out, + 'Navigated to https://other.example/login. Access to the new site requires approval on the next Browser call.', + ); + assert.equal(browser.fills, 1); + assert.equal(browser.presses, 0); + assert.doesNotMatch(out, /hello|token|form/); + }); + + it('returns only a sanitized URL after same-Origin click navigation', async () => { + install({ afterClickUrl: 'https://example.com/next?token=private#section' }); + const out = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal(out, 'Navigated to https://example.com/next.'); + assert.doesNotMatch(out, /Clicked|matched|token|section/); + }); it('type reports verification failure with the actual content', async () => { @@ -188,6 +381,20 @@ describe('browser tool execution', () => { await assert.rejects(run(buildBrowserWaitTool(), { text: ' ' }), /non-empty/); }); + it('discards a wait result after an A→B→A navigation', async () => { + install({ + waitImpl: async (_options, browser) => { + browser.navigate('https://other.example/waited?secret=value'); + browser.navigate('https://example.com/back'); + }, + }); + const out = await run(buildBrowserWaitTool(), { text: 'ready' }); + assert.equal( + out, + 'Navigated to https://other.example/waited. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /Done|ready|secret/); + }); it('extract fails clearly when a selector matches nothing', async () => { @@ -195,6 +402,19 @@ describe('browser tool execution', () => { await assert.rejects(run(buildBrowserExtractTool(), { selector: '#missing' }), /No element matches selector/); }); + it('discards extracted content after a cross-Origin navigation', async () => { + install({ + extractHtml: 'private content', + extractImpl: (browser) => browser.navigate('https://other.example/data?secret=value'), + }); + const out = await run(buildBrowserExtractTool(), {}); + assert.equal( + out, + 'Navigated to https://other.example/data. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(out, /private content|secret/); + }); + it('extract page-side script swallows an invalid selector instead of throwing', () => { // The fake IPage above ignores the selector, so the SyntaxError only fires // in a real DOM. Drive the generated page script directly against a stub diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index c2df79d23b..3e2f36007d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -31,6 +31,7 @@ import { } from '@maka/runtime-host/protocol'; import { z } from 'zod'; import { buildClientSettingsTools } from '../client-settings-tools.js'; +import { browserOriginAdmission } from '../browser/browser-origin-admission.js'; import { buildRiveWorkflowTool } from '../rive-workflow-tool.js'; import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js'; @@ -225,6 +226,10 @@ test('validates before admission and invokes the exact offered tool with Host co browserTools: [ tool('browser_navigate', z.object({ url: z.string().url() }), async (args, context) => { assert.equal(admitted, true); + assert.deepEqual(browserOriginAdmission(context.sessionId), { + sessionId: 'host-a:session-1', + url: 'https://example.com/path', + }); invoked = true; received = { args, diff --git a/apps/desktop/src/main/browser/automation-host.ts b/apps/desktop/src/main/browser/automation-host.ts index 75d93670fd..4561629d1d 100644 --- a/apps/desktop/src/main/browser/automation-host.ts +++ b/apps/desktop/src/main/browser/automation-host.ts @@ -50,6 +50,9 @@ export function createBrowserViewHost( currentUrl(sessionId) { return manager.get(sessionId)?.state().url ?? ''; }, + openOriginLease(sessionId, approvedUrl, kind) { + return manager.getOrCreate(sessionId).openOriginLease(approvedUrl, kind); + }, canDrive(sessionId, kind, opts) { const shown = sessionId === shownSessionId(); const controller = manager.get(sessionId); diff --git a/apps/desktop/src/main/browser/browser-host.ts b/apps/desktop/src/main/browser/browser-host.ts index eef5eac0b3..a5307849dc 100644 --- a/apps/desktop/src/main/browser/browser-host.ts +++ b/apps/desktop/src/main/browser/browser-host.ts @@ -33,9 +33,29 @@ import type { BrowserActionKind } from './logic.js'; +export interface BrowserOriginLeaseSnapshot { + readonly epoch: number; + readonly url: string; + readonly violatedUrl?: string; +} + +export interface BrowserOriginLease { + readonly approvedOrigin: string; + /** Arm a navigate lease immediately before its approved Page.goto call. */ + startNavigation(targetUrl: string): void; + snapshot(): BrowserOriginLeaseSnapshot; + release(): void; +} + export interface BrowserViewHost { /** Read the current real URL without creating or attaching an automation endpoint. */ currentUrl(sessionId: string): string; + /** + * Track every committed navigation while one admitted Browser call is live. + * The lease remembers the first cross-Origin URL, so A→B→A cannot regain the + * original approval by merely ending on A again. + */ + openOriginLease(sessionId: string, approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease; /** * The visible-lease gate (see browserActionAllowed): may `sessionId` run a * `kind` action right now? EVERY kind — read, navigate, mutate — requires the diff --git a/apps/desktop/src/main/browser/browser-origin-admission.ts b/apps/desktop/src/main/browser/browser-origin-admission.ts new file mode 100644 index 0000000000..2b505623e5 --- /dev/null +++ b/apps/desktop/src/main/browser/browser-origin-admission.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface BrowserOriginAdmission { + readonly sessionId: string; + readonly url: string; +} + +const currentAdmission = new AsyncLocalStorage(); + +/** Bind the Host-approved Browser scope to exactly one provider invocation. */ +export function withBrowserOriginAdmission( + admission: BrowserOriginAdmission, + run: () => T, +): T { + return currentAdmission.run(admission, run); +} + +/** Browser tools fail closed when they are invoked outside the admitted call. */ +export function browserOriginAdmission(sessionId: string): BrowserOriginAdmission | undefined { + const admission = currentAdmission.getStore(); + return admission?.sessionId === sessionId ? admission : undefined; +} diff --git a/apps/desktop/src/main/browser/browser-origin-lease.ts b/apps/desktop/src/main/browser/browser-origin-lease.ts new file mode 100644 index 0000000000..866cb9658e --- /dev/null +++ b/apps/desktop/src/main/browser/browser-origin-lease.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { BrowserOriginLease, BrowserOriginLeaseSnapshot } from './browser-host.js'; +import type { BrowserActionKind } from './logic.js'; + +type ActiveLease = { + readonly approvedOrigin: string; + monitoring: boolean; + violatedUrl?: string; +}; + +/** Monotonic, page-host-owned evidence for Browser Origin admission. */ +export class BrowserOriginLeaseTracker { + #epoch = 0; + readonly #active = new Set(); + + constructor(private readonly currentUrl: () => string) {} + + recordNavigation(url = this.currentUrl()): void { + this.#epoch += 1; + for (const lease of this.#active) { + if (lease.monitoring && !lease.violatedUrl && webOrigin(url) !== lease.approvedOrigin) { + lease.violatedUrl = url; + } + } + } + + open(approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease { + const approvedOrigin = webOrigin(approvedUrl); + if (!approvedOrigin) throw new Error('Browser admission requires an HTTP origin'); + const state: ActiveLease = { + approvedOrigin, + // Navigate may start on another site. It becomes monitored immediately + // before the approved goto; every other action must start on its grant. + monitoring: kind !== 'navigate', + }; + this.#active.add(state); + + const snapshot = (): BrowserOriginLeaseSnapshot => { + const url = this.currentUrl(); + if (state.monitoring && !state.violatedUrl && webOrigin(url) !== approvedOrigin) { + state.violatedUrl = url; + } + return { + epoch: this.#epoch, + url, + ...(state.violatedUrl === undefined ? {} : { violatedUrl: state.violatedUrl }), + }; + }; + + if (state.monitoring) snapshot(); + let released = false; + return { + approvedOrigin, + startNavigation: (targetUrl) => { + if (released) throw new Error('Browser Origin lease is no longer active'); + if (webOrigin(targetUrl) !== approvedOrigin) { + throw new Error('Browser navigation target is outside the approved Origin'); + } + state.monitoring = true; + }, + snapshot, + release: () => { + if (released) return; + released = true; + this.#active.delete(state); + }, + }; + } +} + +function webOrigin(value: string): string | undefined { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined; + } catch { + return undefined; + } +} diff --git a/apps/desktop/src/main/browser/browser-tools.ts b/apps/desktop/src/main/browser/browser-tools.ts index f5c303a3dc..04cb28fade 100644 --- a/apps/desktop/src/main/browser/browser-tools.ts +++ b/apps/desktop/src/main/browser/browser-tools.ts @@ -19,7 +19,10 @@ import { z } from 'zod'; import { htmlToMarkdown } from '@jackwener/opencli/utils'; +import type { IPage } from '@jackwener/opencli/types'; import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { browserOriginAdmission } from './browser-origin-admission.js'; +import type { BrowserOriginLease } from './browser-host.js'; import { type BrowserPageRun, type TakeoverMode, @@ -34,11 +37,10 @@ import { parseNavigable } from './logic.js'; * (click / type) take a ref and self-verify the match. All six drive the * conversation's OWN embedded-browser view through BrowserSession. * - * Permission: every tool is the dedicated `browser` category — Maka's mode - * gates it (block in `explore`, prompt in `ask` AND `execute`), so a browser - * action on the user's logged-in sessions is never silently auto-allowed; the - * visible view plus the user's "allow for this turn" is the safety net. A - * finer origin-keyed grant (remember "allow this site") is a later refinement. + * Permission: the Runtime Host admits these Client Capability calls against an + * Origin-keyed Session Grant. The Desktop provider binds that approved Origin + * to this exact invocation; the page-host lease below then keeps it valid for + * the entire action, in addition to the visible-view safety boundary. */ const BROWSER_TOOL_CATEGORY = 'browser' as const; @@ -76,11 +78,20 @@ export function takeoverNote(info: { takeoverReloaded: boolean }): string { } /** - * Shared run path for the browser_* tools. Permission is decided by the engine - * BEFORE impl runs (via the tool's category), so this only guards availability - * and runs the action through BrowserSession with its tool-level timeout and - * the user's stop signal. + * Shared run path for the browser_* tools. Host admission happens before impl; + * this layer carries its approved Origin across BrowserSession and checks it + * before and after every page operation, alongside timeout and stop handling. */ +type BrowserActionResult = + | { readonly kind: 'completed'; readonly value: T } + | { readonly kind: 'navigated'; readonly url: string; readonly requiresApproval: boolean }; + +class BrowserOriginLeaseLostError extends Error { + constructor(readonly url: string) { + super('Browser Origin lease was lost'); + } +} + async function runBrowserAction(input: { sessionId: string; label: string; @@ -90,24 +101,74 @@ async function runBrowserAction(input: { // omit for the pure-observe default, which never reloads. takeover?: TakeoverMode; run: BrowserPageRun; -}): Promise { +}): Promise> { if (!browserAutomationAvailable()) { throw new Error('Browser automation is only available inside the desktop app.'); } - return withBrowserPage(input.sessionId, input.label, input.run, { + const admission = browserOriginAdmission(input.sessionId); + if (!admission) { + throw new Error('Browser action is missing its admitted Origin scope.'); + } + return withBrowserPage(input.sessionId, input.label, async (page, info) => { + const lease = info.originLease; + if (!lease) throw new Error('Browser action is missing its page-host Origin lease.'); + const started = lease.snapshot(); + try { + assertOriginLease(lease); + const value = await input.run(guardBrowserPage(page, lease), info); + const finished = assertOriginLease(lease); + if ((input.takeover ?? 'observe') === 'mutate' && finished.epoch !== started.epoch) { + return { kind: 'navigated', url: finished.url, requiresApproval: false }; + } + return { kind: 'completed', value }; + } catch (error) { + if (error instanceof BrowserOriginLeaseLostError) { + return { kind: 'navigated', url: error.url, requiresApproval: true }; + } + throw error; + } + }, { abort: input.abortSignal, + originAdmission: { approvedUrl: admission.url }, ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), ...(input.takeover ? { takeover: input.takeover } : {}), }); } +function assertOriginLease(lease: BrowserOriginLease) { + const snapshot = lease.snapshot(); + if (snapshot.violatedUrl !== undefined) { + throw new BrowserOriginLeaseLostError(snapshot.violatedUrl); + } + return snapshot; +} + +function guardBrowserPage(page: IPage, lease: BrowserOriginLease): IPage { + return new Proxy(page, { + get(target, key, receiver) { + const member = Reflect.get(target, key, receiver); + if (typeof member !== 'function') return member; + return async (...args: unknown[]) => { + if (key === 'goto') { + lease.startNavigation(String(args[0] ?? '')); + } else { + assertOriginLease(lease); + } + const value = await Reflect.apply(member, target, args); + assertOriginLease(lease); + return value; + }; + }, + }) as IPage; +} + export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> { return { name: 'browser_navigate', displayName: '浏览器导航', description: 'Open a URL in the conversation\'s embedded browser. Pass a full http:// or https:// URL; other schemes are rejected. ' + - 'Returns the URL actually landed on (after redirects) and the page title. Follow with browser_snapshot to see what is on the page.', + 'Returns the sanitized URL actually landed on (after redirects). Follow with browser_snapshot to see what is on the page.', parameters: z.object({ url: z.string().min(1).max(4000).describe('Full http:// or https:// URL to open. Other schemes are rejected.'), }), @@ -133,17 +194,11 @@ export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> { // getCurrentUrl() would just echo it back and a redirect would never // be visible. const landed = await page.evaluate('window.location.href').catch(() => url); - const title = await page.evaluate('document.title').catch(() => ''); - return { landed: typeof landed === 'string' && landed ? landed : url, title, info }; + return { landed: typeof landed === 'string' && landed ? landed : url, info }; }, }); - if (crossOrigin(url, result.landed)) { - return crossOriginNavigationResult(result.landed); - } - return ( - [`Loaded ${result.landed}`, result.title ? `Title: ${result.title}` : undefined].filter(Boolean).join('\n') + - takeoverNote(result.info) - ); + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + return `Loaded ${sanitizedPageUrl(result.value.landed)}` + takeoverNote(result.value.info); }, }; } @@ -168,9 +223,12 @@ export function buildBrowserSnapshotTool(): MakaTool, stri return { snapshot, url, info }; }, }); + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); const text = - typeof result.snapshot === 'string' ? result.snapshot : JSON.stringify(result.snapshot, null, 2); - return (result.url ? `${result.url}\n\n${text}` : text) + takeoverNote(result.info); + typeof result.value.snapshot === 'string' + ? result.value.snapshot + : JSON.stringify(result.value.snapshot, null, 2); + return (result.value.url ? `${result.value.url}\n\n${text}` : text) + takeoverNote(result.value.info); }, }; } @@ -194,43 +252,38 @@ export function buildBrowserClickTool(): MakaTool<{ ref: string }, string> { // A mutating action: harden a taken-over page (reload once) before clicking. takeover: 'mutate', run: async (page, info) => { - const before = await currentPageUrl(page); const outcome = await page.click(normalizeElementRef(ref)); - const after = await currentPageUrl(page); - return { outcome, before, after, info }; + return { outcome, info }; }, }); - if (crossOrigin(result.before, result.after)) { - return crossOriginNavigationResult(result.after); - } - const { matches_n, match_level } = result.outcome; + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + const { matches_n, match_level } = result.value.outcome; return ( `Clicked ${ref} (matched ${matches_n} element${matches_n === 1 ? '' : 's'}, ${match_level} match).` + (matches_n > 1 ? ' Multiple matches — verify the right element reacted, or re-snapshot for a tighter ref.' : '') + - takeoverNote(result.info) + takeoverNote(result.value.info) ); }, }; } -async function currentPageUrl(page: Parameters>[0]): Promise { - const evaluated = await page.evaluate('window.location.href').catch(() => ''); - if (typeof evaluated === 'string' && evaluated) return evaluated; - return (await page.getCurrentUrl?.()) ?? ''; -} - -function crossOrigin(before: string, after: string): boolean { +function sanitizedPageUrl(value: string): string { try { - return new URL(before).origin !== new URL(after).origin; + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page'; + return `${url.origin}${url.pathname}`; } catch { - return false; + return 'an unapproved page'; } } -function crossOriginNavigationResult(url: string): string { - return `Navigated to ${url}. Access to the new site requires approval on the next Browser call.`; +function navigationResult(url: string, requiresApproval: boolean): string { + return ( + `Navigated to ${sanitizedPageUrl(url)}.` + + (requiresApproval ? ' Access to the new site requires approval on the next Browser call.' : '') + ); } export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; submit?: boolean }, string> { @@ -262,14 +315,15 @@ export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; su return { outcome, info }; }, }); - const { verified, actual, match_level } = result.outcome; + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + const { verified, actual, match_level } = result.value.outcome; const lines = [ `Filled ${ref} (${match_level} match)${submit ? ', then pressed Enter' : ''}.`, verified ? 'Verified: the field contains the requested text.' : `Not verified — the field now contains: ${JSON.stringify(actual)}`, ]; - return lines.join('\n') + takeoverNote(result.info); + return lines.join('\n') + takeoverNote(result.value.info); }, }; } @@ -321,7 +375,7 @@ export function buildBrowserWaitTool(): MakaTool< : selector ? `selector ${JSON.stringify(selector)}` : `${requested}s pause`; - const info = await runBrowserAction({ + const result = await runBrowserAction({ sessionId, label: 'wait', abortSignal, @@ -351,7 +405,8 @@ export function buildBrowserWaitTool(): MakaTool< return pageInfo; }, }); - return `Done: ${condition}.` + takeoverNote(info); + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + return `Done: ${condition}.` + takeoverNote(result.value); }, }; } @@ -387,27 +442,28 @@ export function buildBrowserExtractTool(): MakaTool<{ selector?: string; start?: return { read, url, info }; }, }); - if (typeof result.read?.html !== 'string') { + if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval); + if (typeof result.value.read?.html !== 'string') { throw new Error( selector ? `No element matches selector ${JSON.stringify(selector)}.` : 'The page has no readable body yet — navigate somewhere first.', ); } - const markdown = htmlToMarkdown(result.read.html); + const markdown = htmlToMarkdown(result.value.read.html); const chunk = markdown.slice(start, start + EXTRACT_CHAR_LIMIT); const nextStart = start + chunk.length; const hasMore = nextStart < markdown.length; return ( - (result.url ? `${result.url}\n\n` : '') + + (result.value.url ? `${result.value.url}\n\n` : '') + chunk + (hasMore ? `\n\n(Content continues — call browser_extract again with start=${nextStart}. next_start_char: ${nextStart})` : '') + - (result.read.truncated + (result.value.read.truncated ? "\n\n(The page's HTML was larger than the extraction ceiling; trailing content was dropped before conversion. Use `selector` to target the part you need.)" : '') + - takeoverNote(result.info) + takeoverNote(result.value.info) ); }, }; diff --git a/apps/desktop/src/main/browser/controller.ts b/apps/desktop/src/main/browser/controller.ts index a214fcdd6b..1cac244301 100644 --- a/apps/desktop/src/main/browser/controller.ts +++ b/apps/desktop/src/main/browser/controller.ts @@ -19,8 +19,11 @@ import { type BrowserWindow, type Session, shell, WebContentsView } from 'electron'; import { CdpBridge, type AutomationEndpoint } from './cdp-bridge.js'; +import type { BrowserOriginLease } from './browser-host.js'; +import { BrowserOriginLeaseTracker } from './browser-origin-lease.js'; import { browserViewWebPreferences } from './options.js'; import { + type BrowserActionKind, type BrowserState, type BrowserViewRect, deriveBrowserState, @@ -57,6 +60,9 @@ export class BrowserViewController { /** True while the view holds real on-screen bounds (last setViewport painted it). */ private shownWithBounds = false; private automation: CdpBridge | null = null; + private readonly originLeases = new BrowserOriginLeaseTracker(() => + this.destroyed || this.wc.isDestroyed() ? '' : this.wc.getURL(), + ); constructor( private readonly window: BrowserWindow, @@ -78,8 +84,8 @@ export class BrowserViewController { const wc = this.wc; wc.on('did-start-loading', () => this.emitState()); wc.on('did-stop-loading', () => this.emitState()); - wc.on('did-navigate', () => this.emitState()); - wc.on('did-navigate-in-page', () => this.emitState()); + wc.on('did-navigate', () => this.recordNavigation()); + wc.on('did-navigate-in-page', () => this.recordNavigation()); wc.on('page-title-updated', () => this.emitState()); wc.on('did-fail-load', () => this.emitState()); @@ -151,6 +157,16 @@ export class BrowserViewController { this.onState(this.sessionId, this.state()); } + private recordNavigation(): void { + if (this.destroyed) return; + this.originLeases.recordNavigation(this.wc.getURL()); + this.emitState(); + } + + openOriginLease(approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease { + return this.originLeases.open(approvedUrl, kind); + } + async navigate(input: string): Promise { const url = parseNavigable(input); if (!url) return; diff --git a/apps/desktop/src/main/browser/session.ts b/apps/desktop/src/main/browser/session.ts index 05e2d5532e..e0a9b5a7ad 100644 --- a/apps/desktop/src/main/browser/session.ts +++ b/apps/desktop/src/main/browser/session.ts @@ -19,7 +19,11 @@ import { CDPBridge } from '@jackwener/opencli/browser/cdp'; import type { IPage } from '@jackwener/opencli/types'; -import { browserAutomationAvailable, browserViewHost } from './browser-host.js'; +import { + type BrowserOriginLease, + browserAutomationAvailable, + browserViewHost, +} from './browser-host.js'; import { type BrowserActionKind, parseNavigable } from './logic.js'; /** @@ -273,7 +277,10 @@ async function acquire(sessionId: string): Promise { return promise; } -export type BrowserPageRun = (page: IPage, info: { takeoverReloaded: boolean }) => Promise; +export type BrowserPageRun = ( + page: IPage, + info: { takeoverReloaded: boolean; originLease?: BrowserOriginLease }, +) => Promise; /** * Run one tool action against the session's embedded-browser page: lazy connect @@ -285,7 +292,12 @@ export async function withBrowserPage( sessionId: string, label: string, run: BrowserPageRun, - opts?: { timeoutMs?: number; abort?: AbortSignal; takeover?: TakeoverMode }, + opts?: { + timeoutMs?: number; + abort?: AbortSignal; + takeover?: TakeoverMode; + originAdmission?: { approvedUrl: string }; + }, ): Promise { if (opts?.abort?.aborted) throw new BrowserActionCanceledError(label); const kind: TakeoverMode = opts?.takeover ?? 'observe'; @@ -306,6 +318,13 @@ export async function withBrowserPage( let timer: ReturnType | undefined; let onAbort: (() => void) | undefined; let onRevoke: (() => void) | undefined; + // Establish the page-host Origin lease before endpoint acquisition and a + // possible takeover reload. This closes the Provider-check → first-page-await + // gap, while still preserving canDrive's rule that a blocked action creates + // no view. + const originLease = opts?.originAdmission + ? browserViewHost().openOriginLease(sessionId, opts.originAdmission.approvedUrl, kind) + : undefined; // Track this action so a switch away from the conversation can revoke it (the // visible lease is continuous, not just the preflight canDrive above). // Registered AFTER canDrive resolved true, with no await between, so the @@ -362,7 +381,7 @@ export async function withBrowserPage( conn.pendingTakeover = false; } } - return await Promise.race([run(conn.page, { takeoverReloaded }), interrupted]); + return await Promise.race([run(conn.page, { takeoverReloaded, ...(originLease ? { originLease } : {}) }), interrupted]); } catch (err) { if (conn && isConnectionLoss(err)) { invalidate(conn); @@ -378,6 +397,7 @@ export async function withBrowserPage( } finally { clearTimeout(timer); if (onAbort) opts?.abort?.removeEventListener('abort', onAbort); + originLease?.release(); untrackInFlight(sessionId, revoke); } } diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index 95121de388..bebe81db1c 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -35,6 +35,7 @@ import type { ClientCapabilityServiceOffer, } from "@maka/runtime-host/protocol"; import { toJSONSchema, z } from "zod"; +import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; const CAPABILITY_VERSION = "0"; @@ -375,15 +376,22 @@ async function invokeNativeTool( if (frame.offerId === COMPUTER_USE_OFFER_ID) { providerOptions.onComputerUseTurnUsed?.(frame.sessionId, frame.turnId); } - const output = await binding.tool.impl(args, { - sessionId, - turnId: frame.turnId, - cwd, - toolCallId: frame.toolCallId, - abortSignal: signal, - emitOutput() {}, - ...(options.progress ? { emitProgress: options.progress } : {}), - }); + const execute = () => + binding.tool.impl(args, { + sessionId, + turnId: frame.turnId, + cwd, + toolCallId: frame.toolCallId, + abortSignal: signal, + emitOutput() {}, + ...(options.progress ? { emitProgress: options.progress } : {}), + }); + const output = await (admissionEvidence.kind === "browser_url" + ? withBrowserOriginAdmission( + { sessionId, url: admissionEvidence.url }, + execute, + ) + : execute()); return projectToolResult(binding.tool, frame.toolCallId, args, output); } From 197e7fe84601329ebc9608a8fa93d315eb4ccdea Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:15:50 +0800 Subject: [PATCH 19/30] chore(runtime-host): advance capability protocol epoch Generated-by: OpenAI Codex --- packages/runtime-host/src/protocol/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 2d3d3237b5..c85b20e2c4 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 85 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 86 as const; +// 86: Client Capability accepted frames carry typed admission evidence used to +// enforce Session Grant scopes. Older peers cannot preserve that boundary. // 85: Plugin package and Entry composition operations become Host-owned protocol // surfaces. Older peers cannot safely exchange these strict operation shapes. // 84: Message content carries Host-bound directory references. Older peers From ef5627936391b603465aaf041adf9a5880af2d3c Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:18:04 +0800 Subject: [PATCH 20/30] chore(desktop): sync capability prompt inventory Generated-by: OpenAI Codex --- docs/astryx-surface-file-inventory.md | 1 + docs/astryx-surface-file-inventory.paths | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d245447256..38096ea815 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -209,6 +209,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned | | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | | `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text) | aligned | +| `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw ` Date: Sat, 29 Aug 2026 21:21:35 +0800 Subject: [PATCH 21/30] test(desktop): complete side chat story port Generated-by: OpenAI Codex --- apps/desktop/stories/session-workbar.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 79c7415189..922dc402ce 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -954,6 +954,7 @@ function bridge(options: { }), regenerateTurn: async () => undefined, respondToSandboxBoundary: async () => undefined, + respondToClientCapability: async () => undefined, respondToUserQuestion: async () => undefined, subscribeEvents: (_sessionId, _handler, onSeeded) => { onSeeded?.(); From 961a944c47011ffe568de20955f2b51a3d8d4412 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:49:26 +0800 Subject: [PATCH 22/30] test(runtime-host): honor capability admission handshake Generated-by: OpenAI Codex --- .../execution-model-composition.test.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 73831437db..9052f9cda6 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1386,22 +1386,26 @@ test('production backend preserves coordinator Client Capability semantics acros clientCapabilityConnectionIdentity('client-capability-provider'), { send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - calls.push(frame); - queueMicrotask(() => { - connection?.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, + if (frame.kind === 'client.capability.call') { + calls.push(frame); + queueMicrotask(() => { + connection?.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); }); - connection?.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: { - content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }], - }, + } else if (frame.kind === 'client.capability.admitted') { + queueMicrotask(() => { + connection?.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { + content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }], + }, + }); }); - }); + } }, }, ); @@ -1515,7 +1519,7 @@ test('production backend preserves coordinator Client Capability semantics acros (event) => event.type === 'tool_started' && event.data?.toolName === tool.name && - event.data?.categoryHint === 'client_capability', + event.data?.categoryHint === 'custom_tool', ), ); const runtimeEvents = await store.readImmutableRuntimeEvents(sessionId, runId); From 765653a79e149788ba28873712c21cace13337d6 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:29:42 +0800 Subject: [PATCH 23/30] test: trim duplicate capability coverage Generated-by: OpenAI Codex --- .../src/main/__tests__/browser-tools.test.ts | 52 ------------------- ...lient-capability-invocation-broker.test.ts | 40 -------------- .../__tests__/interaction-coordinator.test.ts | 46 ---------------- .../__tests__/tool-runtime-settlement.test.ts | 41 --------------- 4 files changed, 179 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index 355cc82c08..29c930ecd8 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -237,20 +237,6 @@ describe('browser tool execution', () => { assert.doesNotMatch(out, /Clicked|matched/); }); - it('rejects a navigation after admission but before the first page call', async () => { - const browser = install({ - snapshot: 'private snapshot', - onLeaseOpened: (state) => state.navigate('https://other.example/private?token=secret'), - }); - const out = await run(buildBrowserSnapshotTool(), {}); - assert.equal( - out, - 'Navigated to https://other.example/private. Access to the new site requires approval on the next Browser call.', - ); - assert.equal(browser.url, 'https://other.example/private?token=secret'); - assert.doesNotMatch(out, /private snapshot|token/); - }); - it('covers the Provider second-check → first page await gap end to end', async () => { install({ snapshot: 'private snapshot', @@ -319,15 +305,6 @@ describe('browser tool execution', () => { assert.doesNotMatch(out, /private snapshot|token/); }); - it('does not click after the approved Origin is lost', async () => { - const browser = install({ - onLeaseOpened: (state) => state.navigate('https://other.example/button'), - }); - const out = await run(buildBrowserClickTool(), { ref: '[1]' }); - assert.match(out, /Navigated to https:\/\/other\.example\/button/u); - assert.equal(browser.clicks, 0); - }); - it('covers an A→B→A takeover reload before the first mutating page call', async () => { const browser = install({ takeoverReloadImpl: (state) => { @@ -381,40 +358,11 @@ describe('browser tool execution', () => { await assert.rejects(run(buildBrowserWaitTool(), { text: ' ' }), /non-empty/); }); - it('discards a wait result after an A→B→A navigation', async () => { - install({ - waitImpl: async (_options, browser) => { - browser.navigate('https://other.example/waited?secret=value'); - browser.navigate('https://example.com/back'); - }, - }); - const out = await run(buildBrowserWaitTool(), { text: 'ready' }); - assert.equal( - out, - 'Navigated to https://other.example/waited. Access to the new site requires approval on the next Browser call.', - ); - assert.doesNotMatch(out, /Done|ready|secret/); - }); - - it('extract fails clearly when a selector matches nothing', async () => { install({ url: 'https://example.com/' }); // extractHtml undefined => page returns null await assert.rejects(run(buildBrowserExtractTool(), { selector: '#missing' }), /No element matches selector/); }); - it('discards extracted content after a cross-Origin navigation', async () => { - install({ - extractHtml: 'private content', - extractImpl: (browser) => browser.navigate('https://other.example/data?secret=value'), - }); - const out = await run(buildBrowserExtractTool(), {}); - assert.equal( - out, - 'Navigated to https://other.example/data. Access to the new site requires approval on the next Browser call.', - ); - assert.doesNotMatch(out, /private content|secret/); - }); - it('extract page-side script swallows an invalid selector instead of throwing', () => { // The fake IPage above ignores the selector, so the SyntaxError only fires // in a real DOM. Drive the generated page script directly against a stub diff --git a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts index 35a891126f..01bab875c9 100644 --- a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts @@ -93,46 +93,6 @@ describe('ClientCapabilityInvocationBroker', () => { broker.close(); }); - test('preserves immediate admission for invoke callers', async () => { - const sent: ClientCapabilityHostFrame[] = []; - let broker!: ClientCapabilityInvocationBroker; - broker = new ClientCapabilityInvocationBroker({ - senderFor: () => ({ - send: async (frame) => { - sent.push(frame); - if (frame.kind === 'client.capability.call') { - queueMicrotask(() => - broker.accept('connection-a', { - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }), - ); - } - if (frame.kind === 'client.capability.admitted') { - queueMicrotask(() => - broker.accept('connection-a', { - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'ok' }] }, - }), - ); - } - }, - }), - onRegistrationIdle: () => {}, - }); - - assert.deepEqual(await broker.invoke(registration, binding, {}, context, undefined, 1_000), { - content: [{ type: 'text', text: 'ok' }], - }); - assert.deepEqual( - sent.map((frame) => frame.kind), - ['client.capability.call', 'client.capability.admitted', 'client.capability.release'], - ); - broker.close(); - }); - test('cancels accepted work without crossing admission', async () => { const sent: ClientCapabilityHostFrame[] = []; let broker!: ClientCapabilityInvocationBroker; diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index ef3475443b..bae9e4b7fc 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -120,52 +120,6 @@ describe('HostInteractionCoordinator', () => { }); }); - test('commits a Client Capability approval and Session Grant before resuming the caller', async () => { - await withStore(async ({ store }) => { - const coordinator = createCoordinator(store); - const owner = coordinator.bindRun(RUN); - const target = { - providerId: 'provider-1', - contractId: 'contract-1', - serverId: 'desktop_browser', - toolName: 'browser_snapshot', - capability: 'browser' as const, - scope: { kind: 'browser_origin' as const, origin: 'https://example.com' }, - }; - const approval = coordinator.requestClientCapabilityApproval({ - ...RUN, - toolCallId: 'browser-call-1', - target, - }); - let pending = await store.listSessionPending(RUN.sessionId); - while (pending.length === 0) { - await new Promise((resolve) => setImmediate(resolve)); - pending = await store.listSessionPending(RUN.sessionId); - } - const request = pending[0]; - assert.equal(request?.request.kind, 'client_capability'); - assert.ok(request); - - const answered = await coordinator.handlers['interaction.answer']( - { - sessionId: RUN.sessionId, - interactionId: request.requestId, - answer: { kind: 'client_capability', decision: 'allow' }, - }, - connection(), - ); - assert.equal(answered.ok, true); - assert.equal(await approval, 'allow'); - assert.ok( - await store.readClientCapabilitySessionGrant({ sessionId: RUN.sessionId, ...target }), - ); - - await owner.close('turn_terminal'); - owner.release(); - await coordinator.close(); - }); - }); - test('closes a pending Client Capability approval when its provider disconnects', async () => { await withStore(async ({ store }) => { const coordinator = createCoordinator(store); diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 5501aa7d98..2540f554b2 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -84,47 +84,6 @@ describe('ToolRuntime settlement', () => { ); }); - it('admits a prepared Client Capability in Ask mode before executing it', async () => { - const order: string[] = []; - const clientTool: MakaTool = { - name: 'client_browser', - description: 'client browser', - parameters: {}, - categoryHint: 'client_capability', - hostAdmission: 'client_capability', - prepareExecution: async () => { - order.push('prepare'); - return { - execute: async () => { - order.push('execute'); - return { ok: true }; - }, - cancel: () => { - order.push('cancel'); - }, - }; - }, - impl: () => assert.fail('Prepared Client Capability must use its prepared execution'), - }; - const settlement = await makeRuntime({ - readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), - }).settleToolCall({ - tool: clientTool, - turnId: 'turn-1', - stepId: 'step-1', - toolCallId: 'call-managed-prepared', - input: {}, - abortSignal: new AbortController().signal, - eventSink: { - push: () => {}, - pushAndWaitUntilConsumed: async () => {}, - }, - }); - - assert.deepEqual(order, ['prepare', 'execute']); - assert.deepEqual(settlement.result, { ok: true }); - }); - it('prepares Bypass Client Capability work before T1 and admits only after T1', async () => { const order: string[] = []; const clientTool: MakaTool = { From af254f365fc2dd5af4e9498fcd0fdb26c8a7e582 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:10:15 +0800 Subject: [PATCH 24/30] test: simplify capability coverage Generated-by: OpenAI Codex --- .../__tests__/browser-origin-lease.test.ts | 16 - ...me-host-session-execution-ipc-main.test.ts | 73 ++--- .../client-capability-coordinator.test.ts | 294 ++++++++---------- .../client-capability-provider-id.test.ts | 8 +- .../runtime/src/__tests__/mcp-tools.test.ts | 57 ---- 5 files changed, 159 insertions(+), 289 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts index f16136eb24..ee9bf34651 100644 --- a/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts +++ b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts @@ -21,22 +21,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js'; -test('an Origin lease remembers A→B→A instead of trusting the final URL', () => { - let url = 'https://a.example/start'; - const tracker = new BrowserOriginLeaseTracker(() => url); - const lease = tracker.open(url, 'observe'); - url = 'https://b.example/private?token=secret'; - tracker.recordNavigation(url); - url = 'https://a.example/back'; - tracker.recordNavigation(url); - - assert.deepEqual(lease.snapshot(), { - epoch: 2, - url: 'https://a.example/back', - violatedUrl: 'https://b.example/private?token=secret', - }); -}); - test('a navigate lease allows the old page but only arms for its approved target', () => { let url = 'https://old.example/'; const tracker = new BrowserOriginLeaseTracker(() => url); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 504b2ed2e7..c5b865ed70 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -124,33 +124,30 @@ test("keeps synthetic E2E interactions visible through Host hydration and retire }); test('answers a Client Capability approval through the existing Interaction authority', async () => { - const observer = observerWithSnapshot({ - interactions: { - pending: [ - { - schemaVersion: 1, - interactionId: 'capability-1', - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - revision: 1, - request: { - kind: 'client_capability', - toolUseId: 'tool-1', - target: { - providerId: 'provider-1', - contractId: 'contract-1', - serverId: 'desktop_browser', - toolName: 'browser_snapshot', - capability: 'browser', - scope: { kind: 'browser_origin', origin: 'https://example.com' }, - }, - }, - status: 'pending', - outcome: null, - }, - ], + const pending = { + schemaVersion: 1 as const, + interactionId: 'capability-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1 as const, + request: { + kind: 'client_capability' as const, + toolUseId: 'tool-1', + target: { + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser' as const, + scope: { kind: 'browser_origin' as const, origin: 'https://example.com' }, + }, }, + status: 'pending' as const, + outcome: null, + }; + const observer = observerWithSnapshot({ + interactions: { pending: [pending] }, }); const answers: unknown[] = []; const ipc = ipcHarness(); @@ -160,28 +157,12 @@ test('answers a Client Capability approval through the existing Interaction auth answerInteraction: async (input) => { answers.push(input); return { - schemaVersion: 1, - interactionId: 'capability-1', - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', + ...pending, revision: 2, - request: { - kind: 'client_capability', - toolUseId: 'tool-1', - target: { - providerId: 'provider-1', - contractId: 'contract-1', - serverId: 'desktop_browser', - toolName: 'browser_snapshot', - capability: 'browser', - scope: { kind: 'browser_origin', origin: 'https://example.com' }, - }, - }, - status: 'answered', + status: 'answered' as const, outcome: { - kind: 'client_capability_decision', - decision: 'allow', + kind: 'client_capability_decision' as const, + decision: 'allow' as const, committedAt: 2, }, }; diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index 20f9e9a0df..7eb18bbd5d 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -23,7 +23,11 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { ToolOutcomeUnknownError } from '@maka/core/events'; import type { McpCallResult } from '@maka/core/mcp'; -import type { ClientCapabilityReplaceInput } from '../protocol/index.js'; +import type { + ClientCapabilityAdmissionEvidence, + ClientCapabilityCallFrame, + ClientCapabilityReplaceInput, +} from '../protocol/index.js'; import { ClientCapabilityInvocationError, HostClientCapabilityCoordinator, @@ -303,30 +307,16 @@ describe('Host Client Capability coordinator', () => { ), { send: async () => undefined }, ); - const localReplace = await local.handlers['client.capability.replace']( - { - registrationId: 'local-native-registration', - offers: [ - { - offerId: 'desktop_browser', - version: '1', - affinity: 'session', - hostPathAccess: 'none', - label: 'Desktop Browser', - tools: [ - { - serverId: 'desktop_browser', - name: 'browser_snapshot', - inputSchema: { type: 'object', additionalProperties: false }, - }, - ], - }, - ], - }, - connectionContext('local-connection'), + await registerSessionTools( + local, + 'local-connection', + 'local-native-registration', + 'desktop_browser', + ['browser_snapshot'], ); - assert.equal(localReplace.ok, true); - assert.deepEqual(await local.bindSession('local-session', 'local-connection'), { ok: true }); + assert.deepEqual(await local.bindSession('local-session', 'local-connection'), { + ok: true, + }); const localSnapshot = local.snapshotForSession('local-session'); assert.ok(localSnapshot); assert.equal(localSnapshot.tools[0]?.categoryHint, 'custom_tool'); @@ -336,49 +326,24 @@ describe('Host Client Capability coordinator', () => { await local.close(); const remote = createCoordinator(); - let remoteConnection!: ClientCapabilityConnection; - remoteConnection = remote.attachConnection( - clientCapabilityConnectionIdentity( - 'remote-connection', - 'desktop-remote', - 'remote-owner', - 'remote_owner', - ), - { - send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - remoteConnection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'browser_url', url: 'https://example.com/' }, - }); - }, - }, - ); - const remoteReplace = await remote.handlers['client.capability.replace']( - { - registrationId: 'spoofed-native-registration', - offers: [ - { - offerId: 'desktop_browser', - version: '1', - affinity: 'session', - hostPathAccess: 'none', - label: 'Spoofed Desktop Browser', - tools: [ - { - serverId: 'desktop_browser', - name: 'browser_snapshot', - inputSchema: { type: 'object', additionalProperties: false }, - }, - ], - }, - ], - }, - connectionContext('remote-connection'), - ); - assert.equal(remoteReplace.ok, true); - assert.deepEqual(await remote.bindSession('remote-session', 'remote-connection'), { ok: true }); + const remoteConnection = attachAutoAdmittingConnection( + remote, + 'remote-connection', + () => ({ kind: 'browser_url', url: 'https://example.com/' }), + 'spoofed', + undefined, + 'remote_owner', + ); + await registerSessionTools( + remote, + 'remote-connection', + 'spoofed-native-registration', + 'desktop_browser', + ['browser_snapshot'], + ); + assert.deepEqual(await remote.bindSession('remote-session', 'remote-connection'), { + ok: true, + }); const remoteSnapshot = remote.snapshotForSession('remote-session'); assert.ok(remoteSnapshot); assert.equal(remoteSnapshot.tools[0]?.categoryHint, 'client_capability'); @@ -421,58 +386,26 @@ describe('Host Client Capability coordinator', () => { }, }); const sent: unknown[] = []; - let connection!: ClientCapabilityConnection; - connection = coordinator.attachConnection( - clientCapabilityConnectionIdentity( - 'connection-a', - 'desktop-a', - 'test-principal', - 'capability_provider', - ), - { - send: async (frame) => { - sent.push(frame); - if (frame.kind === 'client.capability.call') { - const requestedUrl = - frame.toolName === 'browser_navigate' && typeof frame.arguments.url === 'string' - ? frame.arguments.url - : 'https://example.com/current'; - connection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'browser_url', url: requestedUrl }, - }); - } else if (frame.kind === 'client.capability.admitted') { - connection.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('done'), - }); - } - }, - }, + const connection = attachAutoAdmittingConnection( + coordinator, + 'connection-a', + (frame) => ({ + kind: 'browser_url', + url: + frame.toolName === 'browser_navigate' && typeof frame.arguments.url === 'string' + ? frame.arguments.url + : 'https://example.com/current', + }), + 'done', + sent, ); - const replaced = await coordinator.handlers['client.capability.replace']( - { - registrationId: 'registration-browser', - offers: [ - { - offerId: 'desktop_browser', - version: '1', - affinity: 'session', - hostPathAccess: 'none', - label: 'Browser', - tools: ['browser_snapshot', 'browser_click', 'browser_navigate'].map((name) => ({ - serverId: 'desktop_browser', - name, - inputSchema: { type: 'object' }, - })), - }, - ], - }, - connectionContext('connection-a'), + await registerSessionTools( + coordinator, + 'connection-a', + 'registration-browser', + 'desktop_browser', + ['browser_snapshot', 'browser_click', 'browser_navigate'], ); - assert.equal(replaced.ok, true); assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); const snapshot = coordinator.snapshotForSession('session-a'); assert.ok(snapshot); @@ -511,55 +444,19 @@ describe('Host Client Capability coordinator', () => { test('passes trusted Desktop Settings through managed admission without a Session Grant', async () => { const coordinator = createCoordinator(); - let connection!: ClientCapabilityConnection; - connection = coordinator.attachConnection( - clientCapabilityConnectionIdentity( - 'connection-a', - 'desktop-a', - 'test-principal', - 'capability_provider', - ), - { - send: async (frame) => { - if (frame.kind === 'client.capability.call') { - connection.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - admissionEvidence: { kind: 'none' }, - }); - } else if (frame.kind === 'client.capability.admitted') { - connection.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: textResult('settings'), - }); - } - }, - }, + const connection = attachAutoAdmittingConnection( + coordinator, + 'connection-a', + () => ({ kind: 'none' }), + 'settings', ); - const replaced = await coordinator.handlers['client.capability.replace']( - { - registrationId: 'registration-settings', - offers: [ - { - offerId: 'desktop_settings', - version: '1', - affinity: 'session', - hostPathAccess: 'none', - label: 'Settings', - tools: [ - { - serverId: 'desktop_settings', - name: 'MakaClientSettingsGet', - inputSchema: { type: 'object' }, - }, - ], - }, - ], - }, - connectionContext('connection-a'), + await registerSessionTools( + coordinator, + 'connection-a', + 'registration-settings', + 'desktop_settings', + ['MakaClientSettingsGet'], ); - assert.equal(replaced.ok, true); assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); const snapshot = coordinator.snapshotForSession('session-a'); assert.ok(snapshot); @@ -1692,6 +1589,75 @@ function managedContext(toolCallId: string) { }; } +function attachAutoAdmittingConnection( + coordinator: HostClientCapabilityCoordinator, + connectionId: string, + admissionEvidence: (frame: ClientCapabilityCallFrame) => ClientCapabilityAdmissionEvidence, + resultText: string, + sent?: unknown[], + principalKind: Parameters[3] = 'capability_provider', +): ClientCapabilityConnection { + let connection!: ClientCapabilityConnection; + connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + connectionId, + `${connectionId}-client`, + `${connectionId}-principal`, + principalKind, + ), + { + send: async (frame) => { + sent?.push(frame); + if (frame.kind === 'client.capability.call') { + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: admissionEvidence(frame), + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: textResult(resultText), + }); + } + }, + }, + ); + return connection; +} + +async function registerSessionTools( + coordinator: HostClientCapabilityCoordinator, + connectionId: string, + registrationId: string, + serverId: string, + toolNames: readonly string[], + hostPathAccess: ClientCapabilityReplaceInput['offers'][number]['hostPathAccess'] = 'none', +): Promise { + const result = await coordinator.handlers['client.capability.replace']( + { + registrationId, + offers: [ + { + offerId: serverId, + version: '1', + affinity: 'session', + hostPathAccess, + label: serverId, + tools: toolNames.map((name) => ({ + serverId, + name, + inputSchema: { type: 'object' }, + })), + }, + ], + }, + connectionContext(connectionId), + ); + assert.equal(result.ok, true, JSON.stringify(result)); +} + async function replace( coordinator: HostClientCapabilityCoordinator, connectionId: string, diff --git a/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts index 2aec7fb35f..c532bf3d32 100644 --- a/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts @@ -30,7 +30,7 @@ const IDENTITY = { }; describe('clientCapabilityProviderId', () => { - test('is stable, serializable, bounded, and does not disclose authenticated identity fields', () => { + test('is stable, safe, private, and sensitive to every authenticated identity field', () => { const first = clientCapabilityProviderId(IDENTITY); const second = clientCapabilityProviderId({ ...IDENTITY }); @@ -48,10 +48,6 @@ describe('clientCapabilityProviderId', () => { scope: { kind: 'browser_origin', origin: 'https://example.com' }, }), ); - }); - - test('changes when any authenticated identity field changes', () => { - const original = clientCapabilityProviderId(IDENTITY); const variants = [ { ...IDENTITY, principalKind: 'local_owner' as const }, { ...IDENTITY, principalId: 'desktop:installation-b' }, @@ -59,7 +55,7 @@ describe('clientCapabilityProviderId', () => { ]; for (const variant of variants) { - assert.notEqual(clientCapabilityProviderId(variant), original); + assert.notEqual(clientCapabilityProviderId(variant), first); } }); }); diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index 6633378905..b3ffdeb2b3 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -243,63 +243,6 @@ test('a trusted composition can apply the Client Capability permission floor and }); }); -test('MCP preparation keeps provider admission separate from execution', async () => { - const toolBinding = binding('prepared-binding'); - const order: string[] = []; - const provider: McpToolProvider = { - toolSnapshot: () => ({ - revision: 1, - tools: [boundTool(descriptor('client', 'inspect', true), toolBinding)], - }), - prepareTool: async (_binding, _args, options) => { - order.push(`prepare:${options.context.permissionMode}`); - return { - execute: async () => { - order.push('execute'); - return { content: [{ type: 'text', text: 'ok' }] }; - }, - cancel: () => { - order.push('cancel'); - }, - }; - }, - callTool: async () => assert.fail('Prepared MCP tools must not call the direct path'), - }; - const [tool] = buildMcpTools(provider, { - categoryHint: 'custom_tool', - hostAdmission: 'client_capability', - }); - assert.ok(tool?.prepareExecution); - assert.equal(tool?.hostAdmission, 'client_capability'); - const boundary = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); - const prepared = await tool.prepareExecution( - {}, - { - sessionId: 'session', - turnId: 'turn', - cwd: '/workspace', - executionBoundary: boundary, - permissionMode: 'ask', - toolCallId: 'tool-call', - abortSignal: new AbortController().signal, - }, - ); - assert.deepEqual( - await prepared.execute({ - sessionId: 'session', - turnId: 'turn', - cwd: '/workspace', - executionBoundary: boundary, - permissionMode: 'ask', - toolCallId: 'tool-call', - abortSignal: new AbortController().signal, - emitOutput() {}, - }), - { content: [{ type: 'text', text: 'ok' }] }, - ); - assert.deepEqual(order, ['prepare:ask', 'execute']); -}); - test('a trusted composition can preserve provider-owned activity semantics', () => { const [tool] = buildMcpTools( fakeProvider( From 0a53185f8340753bed3f926585534b3a8d935abf Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:02:14 +0800 Subject: [PATCH 25/30] fix(desktop): redact unapproved browser destinations Generated-by: OpenAI Codex --- .../src/main/__tests__/browser-tools.test.ts | 40 ++++++++++++------- .../desktop/src/main/browser/browser-tools.ts | 17 +++++++- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index 29c930ecd8..98edf823c8 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -213,28 +213,40 @@ describe('browser tool execution', () => { it('navigate returns only a sanitized destination after a cross-Origin redirect', async () => { install({ url: 'https://old.example/', - afterGotoUrl: 'https://other.example/landing?token=private#account', + afterGotoUrl: 'https://other.example/reset/redirect-secret?token=private#account', title: 'Private destination title', }); const out = await run(buildBrowserNavigateTool(), { url: 'https://example.com/start' }); assert.equal( out, - 'Navigated to https://other.example/landing. Access to the new site requires approval on the next Browser call.', + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', ); - assert.doesNotMatch(out, /Private destination title|token|account/); + assert.doesNotMatch(out, /Private destination title|reset|redirect-secret|token|account/); }); it('click returns only the new URL after cross-Origin navigation', async () => { install({ url: 'https://example.com/start', - afterClickUrl: 'https://other.example/landing?token=private#account', + afterClickUrl: 'https://other.example/reset/click-secret?token=private#account', }); const out = await run(buildBrowserClickTool(), { ref: '[1]' }); assert.equal( out, - 'Navigated to https://other.example/landing. Access to the new site requires approval on the next Browser call.', + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', ); - assert.doesNotMatch(out, /Clicked|matched/); + assert.doesNotMatch(out, /Clicked|matched|reset|click-secret|token|account/); + + resetBrowserSessionsForTest(); + install({ + url: 'https://example.com/start', + afterClickUrl: 'file:///reset/local-secret', + }); + const nonWebOut = await run(buildBrowserClickTool(), { ref: '[1]' }); + assert.equal( + nonWebOut, + 'Navigated to an unapproved page. Access to the new site requires approval on the next Browser call.', + ); + assert.doesNotMatch(nonWebOut, /file|reset|local-secret/); }); it('covers the Provider second-check → first page await gap end to end', async () => { @@ -282,7 +294,7 @@ describe('browser tool execution', () => { content: [ { type: 'text', - text: 'Navigated to https://other.example/private. Access to the new site requires approval on the next Browser call.', + text: 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', }, ], }); @@ -292,7 +304,7 @@ describe('browser tool execution', () => { it('discards a snapshot when the page crosses Origin and returns to the approved site', async () => { install({ snapshotImpl: (browser) => { - browser.navigate('https://other.example/private?token=secret'); + browser.navigate('https://other.example/reset/first-violated-secret?token=secret'); browser.navigate('https://example.com/back'); return 'private snapshot'; }, @@ -300,25 +312,25 @@ describe('browser tool execution', () => { const out = await run(buildBrowserSnapshotTool(), {}); assert.equal( out, - 'Navigated to https://other.example/private. Access to the new site requires approval on the next Browser call.', + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', ); - assert.doesNotMatch(out, /private snapshot|token/); + assert.doesNotMatch(out, /private snapshot|reset|first-violated-secret|token/); }); it('covers an A→B→A takeover reload before the first mutating page call', async () => { const browser = install({ takeoverReloadImpl: (state) => { - state.navigate('https://other.example/reload?token=secret'); + state.navigate('https://other.example/reset/reload-secret?token=secret'); state.navigate('https://example.com/back'); }, }); const out = await run(buildBrowserClickTool(), { ref: '[1]' }); assert.equal( out, - 'Navigated to https://other.example/reload. Access to the new site requires approval on the next Browser call.', + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', ); assert.equal(browser.clicks, 0); - assert.doesNotMatch(out, /token/); + assert.doesNotMatch(out, /reset|reload-secret|token/); }); it('does not press Enter when filling navigates away from the approved Origin', async () => { @@ -328,7 +340,7 @@ describe('browser tool execution', () => { const out = await run(buildBrowserTypeTool(), { ref: '[2]', text: 'hello', submit: true }); assert.equal( out, - 'Navigated to https://other.example/login. Access to the new site requires approval on the next Browser call.', + 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.', ); assert.equal(browser.fills, 1); assert.equal(browser.presses, 0); diff --git a/apps/desktop/src/main/browser/browser-tools.ts b/apps/desktop/src/main/browser/browser-tools.ts index 04cb28fade..1997ba8bdc 100644 --- a/apps/desktop/src/main/browser/browser-tools.ts +++ b/apps/desktop/src/main/browser/browser-tools.ts @@ -279,10 +279,23 @@ function sanitizedPageUrl(value: string): string { } } +function unapprovedPageOrigin(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page'; + // The pathname may itself contain credentials or reset tokens; disclose it only after approval. + return url.origin; + } catch { + return 'an unapproved page'; + } +} + function navigationResult(url: string, requiresApproval: boolean): string { + if (requiresApproval) { + return `Navigated to ${unapprovedPageOrigin(url)}. Access to the new site requires approval on the next Browser call.`; + } return ( - `Navigated to ${sanitizedPageUrl(url)}.` + - (requiresApproval ? ' Access to the new site requires approval on the next Browser call.' : '') + `Navigated to ${sanitizedPageUrl(url)}.` ); } From e3e2e2121024e9c7123239a7da1cab1fa04b1a9f Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:32:01 +0800 Subject: [PATCH 26/30] fix(desktop): keep capability approval outside legacy shell Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 6 ++-- ...chat-composer-region-draft-handoff.test.ts | 3 -- .../main/__tests__/workbar-controller.test.ts | 26 +++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 27 ------------------ .../src/renderer/app-shell-session-events.ts | 23 ++++----------- apps/desktop/src/renderer/app-shell.tsx | 11 +------- .../src/renderer/chat-composer-region.tsx | 11 ++++---- .../controller/use-workbar-controller.ts | 28 +++++++++++++++++-- .../src/__tests__/interaction-queue.test.ts | 26 +++++++++++++++++ packages/ui/src/interaction-queue.ts | 25 ++++++++++++++++- 10 files changed, 115 insertions(+), 71 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index ea302284f7..b186177f2f 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -710,8 +710,8 @@ "@maka/core/ui-locale": 1, "@maka/ui": 1 }, - "importSpecifiers": 23, - "nonTriviaTokens": 3041 + "importSpecifiers": 21, + "nonTriviaTokens": 2974 }, "src/renderer/app-shell-session-settings-actions.ts": { "importDeclarations": 9, @@ -1061,7 +1061,7 @@ "react": 1 }, "importSpecifiers": 187, - "nonTriviaTokens": 15882 + "nonTriviaTokens": 15855 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index dad7844a6a..a6f0302ac7 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -121,10 +121,7 @@ async function mountRegion(): Promise<{ newTaskSendPending, stopPendingBySession: {}, respondToSandboxBoundary: () => {}, - activeSandboxBoundary: undefined, respondToClientCapability: () => {}, - activeClientCapability: undefined, - activeQuestion: undefined, respondToUserQuestion: () => {}, stop: () => {}, onSend: () => {}, diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index e941b30bbf..e3b51fc19d 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -147,6 +147,32 @@ describe('useWorkbarController', () => { assert.deepEqual(controller().host.projectAliases, ['project-absorbed']); }); + it('routes Client Capability decisions to the active Session', async () => { + const { root } = installReactRenderer(); + const responses: Array<{ sessionId: string; requestId: string; decision: string }> = []; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + sideChat: { + ...defaults.sideChat, + respondToClientCapability: async (sessionId, response) => { + responses.push({ sessionId, ...response }); + }, + }, + }); + + await act(async () => renderController(root, services, input(session('a')))); + await act(async () => + controller().commands.respondToClientCapability({ + requestId: 'capability-1', + decision: 'allow', + }), + ); + + assert.deepEqual(responses, [ + { sessionId: 'a', requestId: 'capability-1', decision: 'allow' }, + ]); + }); + it('keeps the initial Session active after StrictMode replays mount effects', async () => { const { root } = installReactRenderer(); const starts: string[] = []; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3ae10475e2..275740b9c4 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -23,7 +23,6 @@ import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; -import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -142,7 +141,6 @@ export interface AppShellChatActions { options?: MessageContextOptions, ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; - respondToClientCapability(response: ClientCapabilityResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; retryMessages(sessionId: string): Promise; @@ -753,30 +751,6 @@ export function createAppShellChatActions(deps: { } } - async function respondToClientCapability(response: ClientCapabilityResponse) { - const sessionId = activeIdRef.current; - if (!sessionId) return; - try { - await window.maka.sessions.respondToClientCapability(sessionId, response); - onInteractionChanged?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, response.requestId), - ); - } catch (error) { - if (activeIdRef.current !== sessionId) return; - if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); - } else { - toastApi.error( - copy.responseFailedTitle, - localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale), - undefined, - { sessionId }, - ); - } - } - } - async function respondToUserQuestion(response: UserQuestionResponse) { const sessionId = activeIdRef.current; if (!sessionId) return; @@ -863,7 +837,6 @@ export function createAppShellChatActions(deps: { send, enqueueMessage, respondToSandboxBoundary, - respondToClientCapability, respondToUserQuestion, refreshMessages, retryMessages, diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 1223c0e2ed..cf3491ca7f 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -23,9 +23,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { applyLiveTurnEvent, clearInteractions, - dequeueInteractionByRequestId, - dequeueInteractionByToolUseId, - enqueueInteraction, + reduceInteractionQueues, reconcileTerminalLiveTurn, settleLiveTurnStep, TOOL_STREAM_MAX_CHUNKS, @@ -321,6 +319,9 @@ export function createAppShellSessionEventHandlers(options: { const pending = takePendingDisplayEvents(sessionId); const before = applyProjectionEvents(liveTurnBySessionRef.current[sessionId], pending); updateLiveTurn(sessionId, [...pending, event]); + setInteractionBySession((current) => + reduceInteractionQueues(current, sessionId, event), + ); switch (event.type) { case 'queue_update': @@ -393,16 +394,13 @@ export function createAppShellSessionEventHandlers(options: { case 'client_capability_request': case 'user_question_request': onInteractionChanged?.(sessionId); - setInteractionBySession((current) => enqueueInteraction(current, sessionId, event)); break; // The runtime drops its owner on this ack, not on the tool result that // follows it, so this is where the request stops being answerable — the // same point its boundary sibling settles on, below. case 'user_question_answer_ack': + case 'client_capability_decision_ack': onInteractionChanged?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, event.requestId), - ); break; case 'sandbox_boundary_decision_ack': onInteractionChanged?.(sessionId); @@ -411,23 +409,12 @@ export function createAppShellSessionEventHandlers(options: { // or the permission label keeps describing the permissions the session // had before the user granted more. onExecutionBoundaryChanged?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, event.requestId), - ); - break; - case 'client_capability_decision_ack': - onInteractionChanged?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, event.requestId), - ); break; case 'tool_result': - setInteractionBySession((current) => dequeueInteractionByToolUseId(current, sessionId, event.toolUseId)); void refreshMessages(sessionId); break; case 'error': onInteractionChanged?.(sessionId); - setInteractionBySession((current) => clearInteractions(current, sessionId)); if (activeIdRef.current === sessionId) { if (isNoRealConnectionEvent(event)) { const reason = noRealConnectionReasonFromEvent(event); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 749774aa3b..fa27552207 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -830,11 +830,6 @@ function AppShellContent({ [sessions, onboarding.snapshot?.sessionSendOutcomes], ); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); - const activeSandboxBoundary = - activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; - const activeClientCapability = - activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; - const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeSession = activeCatalogSession; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeMessageSubmitting = transientMessages.length > 0; @@ -1831,7 +1826,6 @@ function AppShellContent({ send, enqueueMessage, respondToSandboxBoundary, - respondToClientCapability, respondToUserQuestion, refreshMessages, retryMessages, @@ -2971,11 +2965,8 @@ function AppShellContent({ newTaskDraftKey={currentNewTaskDraftKey} newTaskSendPending={newTaskSendPending} stopPendingBySession={stopPendingBySession} - activeSandboxBoundary={activeSandboxBoundary} respondToSandboxBoundary={respondToSandboxBoundary} - activeClientCapability={activeClientCapability} - respondToClientCapability={respondToClientCapability} - activeQuestion={activeQuestion} + respondToClientCapability={workbar.commands.respondToClientCapability} respondToUserQuestion={respondToUserQuestion} stop={stop} directoryComposerProps={directoryComposerProps} diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 93f24b3871..ab6a6e39c0 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -102,10 +102,7 @@ interface ChatComposerRegionProps newTaskSendPending: boolean; stopPendingBySession: Record; respondToSandboxBoundary: ComponentProps['onRespond']; - activeSandboxBoundary: ComponentProps['request'] | undefined; - activeClientCapability: ComponentProps['request'] | undefined; respondToClientCapability: ComponentProps['onRespond']; - activeQuestion: ComponentProps['request'] | undefined; respondToUserQuestion: ComponentProps['onRespond']; stop: ComponentProps['onStop']; boundaryUnreadableNotice?: BoundaryUnreadableNotice; @@ -126,10 +123,7 @@ export function ChatComposerRegion({ newTaskSendPending, stopPendingBySession, respondToSandboxBoundary, - activeSandboxBoundary, - activeClientCapability, respondToClientCapability, - activeQuestion, respondToUserQuestion, stop, boundaryUnreadableNotice, @@ -138,6 +132,11 @@ export function ChatComposerRegion({ ...composerRest }: ChatComposerRegionProps) { const mentions = useComposerMentionsContext(); + const activeSandboxBoundary = + activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; + const activeClientCapability = + activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; + const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const previousNewTaskDraftKey = useRef(newTaskDraftKey); useLayoutEffect(() => { const previous = previousNewTaskDraftKey.current; diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 554d2a73ac..688248f0a2 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -26,13 +26,14 @@ import { useState, type ComponentProps, } from 'react'; +import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { QuoteRef } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; import { Composer, useUiLocale } from '@maka/ui'; import type { ChatModelChoice } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; -import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { sideChatTitleFromPrompt } from '../../../side-chat-command.js'; import { useWorkbarServices } from '../services-context.js'; import type { WorkbarHostModel } from '../ui/workbar-host.js'; @@ -74,6 +75,7 @@ export interface WorkbarControllerCommands { options?: OpenToolOptions, ): void; openSideChatWithQuote(quote: QuoteRef): void; + respondToClientCapability(response: ClientCapabilityResponse): Promise; toggleRight(): void; } @@ -181,6 +183,26 @@ export function useWorkbarController( activeSessionIdRef.current = undefined; }; }, [activeSessionId]); + const respondToClientCapability = useCallback< + WorkbarControllerCommands['respondToClientCapability'] + >( + async (response) => { + const sessionId = activeSessionIdRef.current; + if (!sessionId) return; + try { + await sideChat.respondToClientCapability(sessionId, response); + } catch (error) { + if (activeSessionIdRef.current !== sessionId) return; + const copy = getShellCopy(locale).chatActions; + input.reportError( + copy.responseFailedTitle, + localizedShellErrorMessage(error, copy.responseFailedFallback, locale), + sessionId, + ); + } + }, + [input.reportError, locale, sideChat], + ); const panelsStateRef = useRef(layout.workbarPanelsState); useLayoutEffect(() => { panelsStateRef.current = layout.workbarPanelsState; @@ -641,8 +663,8 @@ export function useWorkbarController( ); const commands = useMemo( - () => ({ openTool, openSideChatWithQuote, toggleRight }), - [openSideChatWithQuote, openTool, toggleRight], + () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }), + [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight], ); const activeSideChatTabIds = useMemo( diff --git a/packages/ui/src/__tests__/interaction-queue.test.ts b/packages/ui/src/__tests__/interaction-queue.test.ts index 96b3d4e6b2..7a466d4b7f 100644 --- a/packages/ui/src/__tests__/interaction-queue.test.ts +++ b/packages/ui/src/__tests__/interaction-queue.test.ts @@ -31,6 +31,7 @@ import { dequeueInteractionByToolUseId, enqueueInteraction, reconcileInteractions, + reduceInteractionQueues, type InteractionQueues, } from '../interaction-queue.js'; @@ -74,6 +75,31 @@ describe('composer interaction queue', () => { assert.equal(activeInteractionFor(queues, 's')?.requestId, 'question'); }); + test('Client Capability requests enter and leave the shared queue', () => { + let queues = reduceInteractionQueues({}, 's', { + type: 'client_capability_request', + id: 'evt_capability', + turnId: 'turn_1', + ts: 0, + requestId: 'capability', + toolUseId: 'call_capability', + capability: 'browser', + scope: { kind: 'browser_origin', origin: 'https://example.com' }, + }); + assert.equal(activeInteractionFor(queues, 's')?.requestId, 'capability'); + + queues = reduceInteractionQueues(queues, 's', { + type: 'client_capability_decision_ack', + id: 'evt_ack', + turnId: 'turn_1', + ts: 1, + requestId: 'capability', + toolUseId: 'call_capability', + decision: 'allow', + }); + assert.equal(activeInteractionFor(queues, 's'), undefined); + }); + test('deduplicates replays and isolates sessions', () => { let queues: InteractionQueues = {}; queues = enqueueInteraction(queues, 's1', question('a')); diff --git a/packages/ui/src/interaction-queue.ts b/packages/ui/src/interaction-queue.ts index 915db71af3..5022da146a 100644 --- a/packages/ui/src/interaction-queue.ts +++ b/packages/ui/src/interaction-queue.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ActiveInteractionRequestEvent } from '@maka/core/events'; +import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; export type ComposerInteraction = ActiveInteractionRequestEvent; export type InteractionQueues = Record; @@ -57,6 +57,29 @@ export function clearInteractions(queues: InteractionQueues, sessionId: string): return { ...queues, [sessionId]: [] }; } +export function reduceInteractionQueues( + queues: InteractionQueues, + sessionId: string, + event: SessionEvent, +): InteractionQueues { + switch (event.type) { + case 'sandbox_boundary_request': + case 'client_capability_request': + case 'user_question_request': + return enqueueInteraction(queues, sessionId, event); + case 'sandbox_boundary_decision_ack': + case 'client_capability_decision_ack': + case 'user_question_answer_ack': + return dequeueInteractionByRequestId(queues, sessionId, event.requestId); + case 'tool_result': + return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId); + case 'error': + return clearInteractions(queues, sessionId); + default: + return queues; + } +} + /** * Replace a session's queue with the runtime's live set of unanswered * requests, keeping the order the surface already shows. The runtime owns both From 76a1f470b98b64ec7059f86c928148b3441e42a3 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:43:12 +0800 Subject: [PATCH 27/30] fix(runtime-host): preserve local capability trust Generated-by: OpenAI Codex --- .../client-capability-coordinator.test.ts | 5 +++-- .../server/client-capability-coordinator.ts | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index 7eb18bbd5d..c34e4611f8 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -296,14 +296,14 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); - test('trusts capability-provider Desktop bindings but not a remote owner spoofing their names', async () => { + test('trusts local-owner Desktop bindings but not a remote owner spoofing their names', async () => { const local = createCoordinator(); const localConnection = local.attachConnection( clientCapabilityConnectionIdentity( 'local-connection', 'desktop-local', 'local_os_user', - 'capability_provider', + 'local_owner', ), { send: async () => undefined }, ); @@ -313,6 +313,7 @@ describe('Host Client Capability coordinator', () => { 'local-native-registration', 'desktop_browser', ['browser_snapshot'], + 'cwd', ); assert.deepEqual(await local.bindSession('local-session', 'local-connection'), { ok: true, diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index b69f1fb3e3..882a4b1fe6 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -1160,20 +1160,21 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService #provider(identity: ClientCapabilityConnectionIdentity): ClientProviderState { const providerId = clientCapabilityProviderId(identity); - const trustedProvider = identity.principalKind === 'capability_provider'; - if (identity.capabilityOwner && !trustedProvider) { + const trustedProvider = + identity.principalKind === 'local_owner' || identity.principalKind === 'capability_provider'; + if (identity.capabilityOwner && identity.principalKind !== 'capability_provider') { throw new Error('Only a capability provider may declare a Client Capability owner'); } let provider = this.#providers.get(providerId); if (!provider) { provider = { - providerId, - principalId: identity.principalId, - clientInstanceId: identity.clientInstanceId, - ...(identity.credentialBoundClientInstanceId - ? { credentialBoundClientInstanceId: identity.credentialBoundClientInstanceId } - : {}), - principalKind: identity.principalKind, + providerId, + principalId: identity.principalId, + clientInstanceId: identity.clientInstanceId, + ...(identity.credentialBoundClientInstanceId + ? { credentialBoundClientInstanceId: identity.credentialBoundClientInstanceId } + : {}), + principalKind: identity.principalKind, trustedProvider, ...(identity.capabilityOwner ? { capabilityOwner: Object.freeze({ ...identity.capabilityOwner }) } From ac6acc0f87756e4bae4d53214fbaf9e498830fa9 Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:49:30 +0800 Subject: [PATCH 28/30] fix(runtime-host): close approvals with callers Generated-by: OpenAI Codex --- .../core/src/__tests__/interaction.test.ts | 11 ++++++ packages/core/src/interaction.ts | 6 ++- ...t-capability-admission-integration.test.ts | 39 ++++++++++++++++--- .../server/client-capability-coordinator.ts | 3 ++ .../src/server/interaction-coordinator.ts | 33 ++++++++++++++-- 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 30aff2d924..9b820797e9 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -803,6 +803,17 @@ describe('Interaction decoding and validity', () => { }); assert.equal(isInteractionAnswerValidForRequest(request, answer), true); assert.equal(isInteractionCanonicalOutcomeValidForRequest(request, outcome), true); + assert.equal( + isInteractionCanonicalOutcomeValidForRequest( + request, + decodeInteractionCanonicalOutcome({ + kind: 'closure', + reason: 'timed_out', + committedAt: 3, + }), + ), + true, + ); assert.deepEqual(decodeInteractionRequest(request), request); assert.throws(() => projectInteractionClientCapabilityRequest({ diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index 55f6efa8ee..c064906aff 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -557,7 +557,11 @@ export function isInteractionCanonicalOutcomeValidForRequest( ): boolean { if (!interactionOutcomeMatchesRequestKind(request, outcome)) return false; if (outcome.kind === 'closure') - return request.kind === 'permission' || outcome.reason !== 'timed_out'; + return ( + request.kind === 'permission' || + request.kind === 'client_capability' || + outcome.reason !== 'timed_out' + ); if (outcome.kind === 'permission_answer') { return interactionRememberForTurnIsEligible(request, outcome); } diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index dfee23244f..773797d207 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -64,7 +64,7 @@ const IDENTITY = Object.freeze({ clientInstanceId: 'desktop-client-secret', }); -test('persists the canonical authenticated provider identity through managed admission', async () => { +test('cancels pending managed admission and retries with the canonical provider identity', async () => { await withStore(async ({ store }) => { const order: string[] = []; const interactions = createInteractionCoordinator(store); @@ -154,13 +154,14 @@ test('persists the canonical authenticated provider identity through managed adm commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), }, }); - const settling = runtime.settleToolCall({ + const caller = new AbortController(); + const cancelled = runtime.settleToolCall({ tool, turnId: RUN.turnId, stepId: 'step-1', toolCallId: 'browser-call-1', input: {}, - abortSignal: new AbortController().signal, + abortSignal: caller.signal, eventSink: { push: () => undefined, pushAndWaitUntilConsumed: async () => undefined, @@ -175,10 +176,36 @@ test('persists the canonical authenticated provider identity through managed adm assert.equal(JSON.stringify(request).includes(IDENTITY.principalId), false); assert.equal(JSON.stringify(request).includes(IDENTITY.clientInstanceId), false); + caller.abort(new DOMException('Code Mode deadline reached', 'TimeoutError')); + await cancelled; + const closed = await store.readInteraction(request.requestId); + assert.equal(closed?.outcome?.outcome.kind, 'closure'); + if (closed?.outcome?.outcome.kind === 'closure') { + assert.equal(closed.outcome.outcome.reason, 'timed_out'); + } + assert.deepEqual(order, ['accepted']); + + const settling = runtime.settleToolCall({ + tool, + turnId: RUN.turnId, + stepId: 'step-2', + toolCallId: 'browser-call-2', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + const retryRequest = await waitForPending(store); + assert.notEqual(retryRequest.requestId, request.requestId); + assert.equal(retryRequest.request.kind, 'client_capability'); + if (retryRequest.request.kind !== 'client_capability') return; + const answered = await interactions.handlers['interaction.answer']( { sessionId: RUN.sessionId, - interactionId: request.requestId, + interactionId: retryRequest.requestId, answer: { kind: 'client_capability', decision: 'allow' }, }, connectionContext('desktop-ui'), @@ -188,11 +215,11 @@ test('persists the canonical authenticated provider identity through managed adm const settlement = await settling; const grant = await store.readClientCapabilitySessionGrant({ sessionId: RUN.sessionId, - ...request.request.target, + ...retryRequest.request.target, }); assert.equal(grant?.providerId, providerId); assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] }); - assert.deepEqual(order, ['accepted', 'approved', 'T1', 'admitted']); + assert.deepEqual(order, ['accepted', 'accepted', 'approved', 'T1', 'admitted']); } finally { snapshot?.release(); await connection.close(); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 882a4b1fe6..782cad6d4e 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -1081,6 +1081,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService runId: options.context.runId, toolCallId: options.context.toolCallId, providerSignal: prepared.providerSignal, + callerSignal: options.signal, }); if (decision !== 'allow') throw new Error('Client Capability request was denied'); if (!(await this.#grants.readClientCapabilitySessionGrant(key))) { @@ -1124,6 +1125,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService readonly capability: ClientCapabilityGrantTarget['capability']; readonly scope: ClientCapabilityGrantTarget['scope']; readonly providerSignal: AbortSignal; + readonly callerSignal?: AbortSignal; }): Promise<'allow' | 'deny'> { const target: ClientCapabilityGrantTarget = { providerId: input.providerId, @@ -1150,6 +1152,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService toolCallId: input.toolCallId, target, providerSignal: input.providerSignal, + ...(input.callerSignal ? { callerSignal: input.callerSignal } : {}), }) .finally(() => { if (this.#pendingApprovals.get(key) === pending) this.#pendingApprovals.delete(key); diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index 2bdcfd9e96..68b36e0dc5 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -165,6 +165,7 @@ export interface ClientCapabilityApprovalInput { readonly toolCallId: string; readonly target: ClientCapabilityGrantTarget; readonly providerSignal?: AbortSignal; + readonly callerSignal?: AbortSignal; } export class ClientCapabilityApprovalClosedError extends Error { @@ -275,19 +276,27 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { resolve: resolveDecision, reject: rejectDecision, }); - const onProviderDisconnect = () => { - void this.#closeClientCapabilityApproval(requestId).catch(rejectDecision); + const close = (reason: InteractionClosureReason) => { + void this.#closeClientCapabilityApproval(requestId, reason).catch(rejectDecision); }; + const onProviderDisconnect = () => close('provider_disconnected'); + const onCallerAbort = () => close(clientCapabilityCallerClosureReason(input.callerSignal)); if (input.providerSignal?.aborted) onProviderDisconnect(); else input.providerSignal?.addEventListener('abort', onProviderDisconnect, { once: true }); + if (input.callerSignal?.aborted) onCallerAbort(); + else input.callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); try { return await decision; } finally { input.providerSignal?.removeEventListener('abort', onProviderDisconnect); + input.callerSignal?.removeEventListener('abort', onCallerAbort); } } - async #closeClientCapabilityApproval(requestId: string): Promise { + async #closeClientCapabilityApproval( + requestId: string, + reason: InteractionClosureReason, + ): Promise { const candidate = this.#live.get(requestId); if (!candidate || candidate.kind !== 'client_capability') return; await this.#sessionAdmission.run(candidate.request.sessionId, async (admission) => { @@ -295,7 +304,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { if (!current || current !== candidate || current.kind !== 'client_capability') return; const outcome = await this.#commitClientCapabilityOutcome(candidate.request, { kind: 'closure', - reason: 'provider_disconnected', + reason, committedAt: this.#now(), }); await this.#refreshCanonicalContinuity(candidate.request.sessionId, admission); @@ -1517,6 +1526,22 @@ function interactionNotFound() { } as const; } +function clientCapabilityCallerClosureReason( + signal: AbortSignal | undefined, +): InteractionClosureReason { + const reason = signal?.reason; + if ( + (reason instanceof Error && reason.name === 'TimeoutError') || + (typeof reason === 'object' && + reason !== null && + 'code' in reason && + reason.code === 'CODE_MODE_TIMEOUT') + ) { + return 'timed_out'; + } + return 'turn_stopped'; +} + function operationConflict(message: string) { return { ok: false, From 45f92e309a6a6fdff306b46eb13855186b773dbb Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:41:02 +0800 Subject: [PATCH 29/30] fix(runtime-host): isolate shared approval waiters Generated-by: OpenAI Codex --- ...t-capability-admission-integration.test.ts | 42 ++++++++++++++++++- .../server/client-capability-coordinator.ts | 38 ++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index 773797d207..a68e1d6d71 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -64,9 +64,16 @@ const IDENTITY = Object.freeze({ clientInstanceId: 'desktop-client-secret', }); -test('cancels pending managed admission and retries with the canonical provider identity', async () => { +test('cancels managed approval owners and joiners with the canonical provider identity', async () => { await withStore(async ({ store }) => { const order: string[] = []; + const toolCallByInvocation = new Map(); + const cancelledToolCalls: string[] = []; + let acceptedCount = 0; + let resolveThirdAccepted!: () => void; + const thirdAccepted = new Promise((resolve) => { + resolveThirdAccepted = resolve; + }); const interactions = createInteractionCoordinator(store); const runOwner = interactions.bindRun(RUN); const capabilities = new HostClientCapabilityCoordinator({ @@ -79,7 +86,10 @@ test('cancels pending managed admission and retries with the canonical provider connection = capabilities.attachConnection(IDENTITY, { send: async (frame) => { if (frame.kind === 'client.capability.call') { + toolCallByInvocation.set(frame.invocationId, frame.toolCallId); order.push('accepted'); + acceptedCount += 1; + if (acceptedCount === 3) resolveThirdAccepted(); connection.accept({ kind: 'client.capability.accepted', invocationId: frame.invocationId, @@ -88,6 +98,9 @@ test('cancels pending managed admission and retries with the canonical provider url: 'https://example.com/private?token=secret', }, }); + } else if (frame.kind === 'client.capability.cancel') { + const toolCallId = toolCallByInvocation.get(frame.invocationId); + if (toolCallId) cancelledToolCalls.push(toolCallId); } else if (frame.kind === 'client.capability.admitted') { order.push('admitted'); connection.accept({ @@ -202,6 +215,31 @@ test('cancels pending managed admission and retries with the canonical provider assert.equal(retryRequest.request.kind, 'client_capability'); if (retryRequest.request.kind !== 'client_capability') return; + const joinedCaller = new AbortController(); + const joined = runtime.settleToolCall({ + tool, + turnId: RUN.turnId, + stepId: 'step-3', + toolCallId: 'browser-call-3', + input: {}, + abortSignal: joinedCaller.signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + await thirdAccepted; + assert.deepEqual( + (await store.listSessionPending(RUN.sessionId)).map((pending) => pending.requestId), + [retryRequest.requestId], + ); + + joinedCaller.abort(new DOMException('Joined caller stopped', 'AbortError')); + await joined; + assert.deepEqual(cancelledToolCalls, ['browser-call-1', 'browser-call-3']); + assert.deepEqual(order, ['accepted', 'accepted', 'accepted']); + assert.equal((await store.readInteraction(retryRequest.requestId))?.outcome, undefined); + const answered = await interactions.handlers['interaction.answer']( { sessionId: RUN.sessionId, @@ -219,7 +257,7 @@ test('cancels pending managed admission and retries with the canonical provider }); assert.equal(grant?.providerId, providerId); assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] }); - assert.deepEqual(order, ['accepted', 'accepted', 'approved', 'T1', 'admitted']); + assert.deepEqual(order, ['accepted', 'accepted', 'accepted', 'approved', 'T1', 'admitted']); } finally { snapshot?.release(); await connection.close(); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 782cad6d4e..315c440086 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -1143,7 +1143,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService clientCapabilityScopeIdentity(target.scope), ].join('\0'); const existing = this.#pendingApprovals.get(key); - if (existing) return existing; + if (existing) return waitForClientCapabilityApproval(existing, input.callerSignal); const pending = this.#interactions .requestClientCapabilityApproval({ sessionId: input.sessionId, @@ -1635,6 +1635,42 @@ function clientCapabilityOwnerIdentitiesEqual( ); } +function waitForClientCapabilityApproval( + approval: Promise<'allow' | 'deny'>, + callerSignal: AbortSignal | undefined, +): Promise<'allow' | 'deny'> { + if (!callerSignal) return approval; + if (callerSignal.aborted) { + return Promise.reject(clientCapabilityCallerAbortError(callerSignal)); + } + return new Promise((resolve, reject) => { + const cleanup = (): void => callerSignal.removeEventListener('abort', onAbort); + const onAbort = (): void => { + cleanup(); + reject(clientCapabilityCallerAbortError(callerSignal)); + }; + callerSignal.addEventListener('abort', onAbort, { once: true }); + void approval.then( + (decision) => { + cleanup(); + resolve(decision); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +function clientCapabilityCallerAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + return new Error(typeof reason === 'string' ? reason : 'Client Capability caller cancelled', { + ...(reason === undefined ? {} : { cause: reason }), + }); +} + function canonicalJson(value: unknown): string { if (value === null || typeof value === 'boolean' || typeof value === 'number') { return JSON.stringify(value); From ee2496977d47f4a5b1e264103c819a05c96f469b Mon Sep 17 00:00:00 2001 From: YayoiNanoka <275864479+YayoiNanoka@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:15:17 +0800 Subject: [PATCH 30/30] docs: refresh Astryx surface inventory Generated-by: OpenAI Codex --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 38096ea815..5b5955cd37 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.0` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 232 files — blocker 0, reimplementation 0, polish 1, aligned 231. +**Totals:** 233 files — blocker 0, reimplementation 0, polish 1, aligned 232. ## Exclusions (explicit)