You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ya...@apache.org on 2026/09/01 05:41:15 UTC
(maka) branch main updated: feat(desktop): authorize client capabilities in managed sessions (#4143)
This is an automated email from the ASF dual-hosted git repository.
YayoiNanoka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 409c71a532 feat(desktop): authorize client capabilities in managed sessions (#4143)
409c71a532 is described below
commit 409c71a53289c189b8ecc25d97dd818f3fb1f295
Author: YayoiNanoka <ya...@qq.com>
AuthorDate: Tue Sep 1 13:41:11 2026 +0800
feat(desktop): authorize client capabilities in managed sessions (#4143)
* refactor(runtime-host): separate capability acceptance from admission
Generated-by: OpenAI Codex
* feat(runtime-host): carry capability admission evidence
Generated-by: OpenAI Codex
* feat(storage): persist client capability session grants
Generated-by: OpenAI Codex
* refactor(core): centralize capability grant identity
Generated-by: OpenAI Codex
* feat(core): define client capability approval interactions
Generated-by: OpenAI Codex
* feat(storage): atomically commit capability approvals and grants
Generated-by: OpenAI Codex
* feat(runtime-host): host client capability approval interactions
Generated-by: OpenAI Codex
* feat(runtime): prepare client capabilities before durable dispatch
Generated-by: OpenAI Codex
* fix(storage): share capability grants by scope
Generated-by: OpenAI Codex
* feat(runtime-host): authorize managed client capabilities
Generated-by: OpenAI Codex
* feat(desktop): attest browser origins before admission
Generated-by: OpenAI Codex
* fix(desktop): limit cross-origin browser results
Generated-by: OpenAI Codex
* fix(runtime-host): close approvals when providers disconnect
Generated-by: OpenAI Codex
* feat(desktop): present client capability approvals
Generated-by: OpenAI Codex
* fix(runtime): classify client capability interaction events
Generated-by: OpenAI Codex
* fix(runtime-host): canonicalize client capability provider ids
Generated-by: OpenAI Codex
* fix(runtime): route client capability host admission
Generated-by: OpenAI Codex
* fix(desktop): enforce browser origin leases
Generated-by: OpenAI Codex
* chore(runtime-host): advance capability protocol epoch
Generated-by: OpenAI Codex
* chore(desktop): sync capability prompt inventory
Generated-by: OpenAI Codex
* test(desktop): complete side chat story port
Generated-by: OpenAI Codex
* test(runtime-host): honor capability admission handshake
Generated-by: OpenAI Codex
* test: trim duplicate capability coverage
Generated-by: OpenAI Codex
* test: simplify capability coverage
Generated-by: OpenAI Codex
* fix(desktop): redact unapproved browser destinations
Generated-by: OpenAI Codex
* fix(desktop): keep capability approval outside legacy shell
Generated-by: OpenAI Codex
* fix(runtime-host): preserve local capability trust
Generated-by: OpenAI Codex
* fix(runtime-host): close approvals with callers
Generated-by: OpenAI Codex
* fix(runtime-host): isolate shared approval waiters
Generated-by: OpenAI Codex
* docs: refresh Astryx surface inventory
Generated-by: OpenAI Codex
---------
Co-authored-by: YayoiNanoka <27...@users.noreply.github.com>
---
apps/desktop/renderer-architecture.json | 6 +-
.../main/__tests__/browser-origin-lease.test.ts | 41 ++
.../src/main/__tests__/browser-session.test.ts | 7 +
.../src/main/__tests__/browser-tools.test.ts | 250 ++++++++++-
.../chat-composer-region-draft-handoff.test.ts | 3 +-
.../permission-response-ipc-boundary.test.ts | 15 +
.../main/__tests__/runtime-host-client-uds.test.ts | 1 +
.../runtime-host-desktop-candidate.test.ts | 7 +
.../runtime-host-native-capabilities.test.ts | 81 +++-
...runtime-host-session-execution-ipc-main.test.ts | 65 +++
.../src/main/__tests__/workbar-controller.test.ts | 26 ++
.../__tests__/workbar-services-adapter.test.ts | 2 +
apps/desktop/src/main/browser/automation-host.ts | 6 +
apps/desktop/src/main/browser/browser-host.ts | 22 +
.../src/main/browser/browser-origin-admission.ts | 37 +-
.../src/main/browser/browser-origin-lease.ts | 96 ++++
apps/desktop/src/main/browser/browser-tools.ts | 162 +++++--
apps/desktop/src/main/browser/controller.ts | 20 +-
apps/desktop/src/main/browser/session.ts | 28 +-
apps/desktop/src/main/permission-response-guard.ts | 19 +
apps/desktop/src/main/runtime-host-boot.ts | 12 +
.../src/main/runtime-host-native-capabilities.ts | 79 +++-
.../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-session-events.ts | 18 +-
apps/desktop/src/renderer/app-shell.tsx | 6 +-
apps/desktop/src/renderer/chat-composer-region.tsx | 26 +-
.../workbar/controller/use-workbar-controller.ts | 28 +-
.../desktop/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 +-
.../workbar/tools/side-chat/use-quote-companion.ts | 21 +
.../platform/desktop/create-workbar-services.ts | 2 +
apps/desktop/stories/session-workbar.stories.tsx | 1 +
docs/astryx-surface-file-inventory.md | 1 +
docs/astryx-surface-file-inventory.paths | 1 +
.../__tests__/tui-mcp-local-integration.test.ts | 2 +
packages/cli/src/mcp-capability-provider.ts | 2 +-
packages/core/package.json | 1 +
packages/core/src/__tests__/interaction.test.ts | 44 ++
packages/core/src/backend-types.ts | 9 +-
packages/core/src/client-capability-grant.ts | 210 +++++++++
packages/core/src/events.ts | 26 +-
packages/core/src/interaction.ts | 90 +++-
...client-capability-admission-integration.test.ts | 385 ++++++++++++++++
.../__tests__/client-capability-channel.test.ts | 16 +-
.../client-capability-coordinator.test.ts | 488 +++++++++++++++++----
.../client-capability-invocation-broker.test.ts | 129 ++++++
.../__tests__/client-capability-protocol.test.ts | 2 +
.../client-capability-provider-id.test.ts | 61 +++
.../src/__tests__/client-capability-uds.test.ts | 6 +-
.../__tests__/execution-host-continuation.test.ts | 2 +-
.../__tests__/execution-model-composition.test.ts | 40 +-
.../src/__tests__/fixtures/client-capability.ts | 21 +
.../src/__tests__/interaction-coordinator.test.ts | 44 ++
.../src/__tests__/oauth-coordinator.test.ts | 7 +-
.../src/__tests__/oauth-two-client-uds.test.ts | 3 +
.../src/__tests__/root-turn-coordinator.test.ts | 15 +-
.../src/__tests__/session-projector.test.ts | 43 ++
.../runtime-host/src/adapter/session-projector.ts | 10 +
.../src/client/client-capability-channel.ts | 8 +-
.../runtime-host/src/client/client-capability.ts | 5 +-
.../runtime-host/src/client/oauth-presentation.ts | 2 +-
.../runtime-host/src/protocol/client-capability.ts | 31 +-
packages/runtime-host/src/protocol/index.ts | 4 +-
.../src/server/client-capability-coordinator.ts | 274 ++++++++++--
.../server/client-capability-invocation-broker.ts | 296 +++++++++----
.../client-capability-provider-id.ts} | 28 +-
.../src/server/execution-composition.ts | 2 +
.../src/server/interaction-coordinator.ts | 271 +++++++++++-
.../src/server/interaction-projection.ts | 10 +
.../src/test-only/client-capability-host.ts | 24 +
.../__tests__/pre-dispatch-refusal-ledger.test.ts | 42 +-
.../__tests__/session-event-runtime-mapper.test.ts | 31 ++
.../src/__tests__/tool-runtime-settlement.test.ts | 121 ++++-
.../__tests__/tool-runtime-sqlite-boundary.test.ts | 8 +-
packages/runtime/src/mcp-tools.ts | 48 +-
.../runtime/src/session-event-runtime-mapper.ts | 22 +-
packages/runtime/src/tool-runtime.ts | 217 +++++----
.../client-capability-session-grant-store.test.ts | 105 +++++
.../storage/src/conversation-operational-state.ts | 3 +
packages/storage/src/interaction-store-public.ts | 1 +
packages/storage/src/interaction-store.ts | 277 ++++++++++++
.../storage/src/sqlite-core-execution-schema.ts | 19 +
.../ui/src/__tests__/interaction-queue.test.ts | 26 ++
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 +
packages/ui/src/interaction-queue.ts | 25 +-
93 files changed, 4353 insertions(+), 497 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__/browser-origin-lease.test.ts b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts
new file mode 100644
index 0000000000..ee9bf34651
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/browser-origin-lease.test.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 assert from 'node:assert/strict';
+import test from 'node:test';
+import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js';
+
+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 a81d08db44..542172700c 100644
--- a/apps/desktop/src/main/__tests__/browser-session.test.ts
+++ b/apps/desktop/src/main/__tests__/browser-session.test.ts
@@ -92,6 +92,13 @@ type HostSpy = {
function installHost(overrides: Partial<BrowserViewHost> = {}): 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 d121388874..98edf823c8 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,50 +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<unknown>;
extractHtml?: string;
- waitImpl?: (options: unknown) => Promise<void>;
+ extractImpl?: (browser: FakeBrowser) => void;
+ waitImpl?: (options: unknown, browser: FakeBrowser) => Promise<void>;
+ 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 {
+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 () => cfg.url ?? 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 (cfg.url ?? '') 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"',
- click: async () => cfg.click ?? { matches_n: 1, match_level: 'exact' },
- fillText: async () =>
- cfg.fill
+ snapshot: async () =>
+ cfg.snapshotImpl ? cfg.snapshotImpl(browser) : (cfg.snapshot ?? '[1] link "Home"'),
+ click: async () => {
+ browser.clicks += 1;
+ if (cfg.afterClickUrl) browser.navigate(cfg.afterClickUrl);
+ return cfg.click ?? { matches_n: 1, match_level: 'exact' };
+ },
+ 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<IPage> {
return this.page;
}
async close(): Promise<void> {}
- async send(): Promise<unknown> {
+ async send(method: string): Promise<unknown> {
+ if (method === 'Page.reload') this.onReload?.();
return {};
}
async waitForEvent(): Promise<unknown> {
@@ -90,15 +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: () => 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 {
@@ -113,7 +177,10 @@ function ctx(): MakaToolContext {
}
function run<P>(tool: MakaTool<P, string>, args: P): Promise<string> {
- return Promise.resolve(tool.impl(args, ctx())) as Promise<string>;
+ return withBrowserOriginAdmission(
+ { sessionId: 's1', url: 'https://example.com/approved?secret=grant' },
+ () => Promise.resolve(tool.impl(args, ctx())) as Promise<string>,
+ );
}
afterEach(() => {
@@ -135,16 +202,157 @@ 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 returns only a sanitized destination after a cross-Origin redirect', async () => {
+ install({
+ url: 'https://old.example/',
+ 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. Access to the new site requires approval on the next Browser call.',
+ );
+ 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/reset/click-secret?token=private#account',
+ });
+ const out = await run(buildBrowserClickTool(), { ref: '[1]' });
+ assert.equal(
+ out,
+ 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+ );
+ 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 () => {
+ 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. 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/reset/first-violated-secret?token=secret');
+ browser.navigate('https://example.com/back');
+ return 'private snapshot';
+ },
+ });
+ const out = await run(buildBrowserSnapshotTool(), {});
+ assert.equal(
+ out,
+ 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+ );
+ 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/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. Access to the new site requires approval on the next Browser call.',
+ );
+ assert.equal(browser.clicks, 0);
+ assert.doesNotMatch(out, /reset|reload-secret|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. 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 () => {
@@ -162,8 +370,6 @@ describe('browser tool execution', () => {
await assert.rejects(run(buildBrowserWaitTool(), { text: ' ' }), /non-empty/);
});
-
-
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/);
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..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,8 +121,7 @@ async function mountRegion(): Promise<{
newTaskSendPending,
stopPendingBySession: {},
respondToSandboxBoundary: () => {},
- activeSandboxBoundary: undefined,
- activeQuestion: undefined,
+ respondToClientCapability: () => {},
respondToUserQuestion: () => {},
stop: () => {},
onSend: () => {},
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-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<typeof ipcHarness>,
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..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
@@ -25,17 +25,20 @@ 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';
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';
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 +94,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 +115,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 +147,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 +180,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 +213,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;
@@ -217,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,
@@ -230,6 +243,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 +263,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 +283,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 +328,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 +386,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 +444,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 +476,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 +524,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 +550,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 +587,7 @@ test('forwards Host cancellation to an admitted Desktop invocation', async () =>
});
}),
],
+ resolveBrowserUrl: () => 'https://example.com/',
releaseBrowserSession() {},
computerUseTools: computerTools(),
releaseComputerUseSession() {},
@@ -643,11 +712,11 @@ function computerFrame(overrides: Partial<ClientCapabilityCallFrame> = {}): 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/__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..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
@@ -123,6 +123,71 @@ 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 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();
+ registerExecutionIpc(
+ {
+ client: executionClient({
+ answerInteraction: async (input) => {
+ answers.push(input);
+ return {
+ ...pending,
+ revision: 2,
+ status: 'answered' as const,
+ outcome: {
+ kind: 'client_capability_decision' as const,
+ decision: 'allow' as const,
+ 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<string, SessionCatalogProjection>();
const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]);
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/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/browser/automation-host.ts b/apps/desktop/src/main/browser/automation-host.ts
index ca296f1360..4561629d1d 100644
--- a/apps/desktop/src/main/browser/automation-host.ts
+++ b/apps/desktop/src/main/browser/automation-host.ts
@@ -47,6 +47,12 @@ export function createBrowserViewHost(
shownSessionId: () => string | null,
): BrowserViewHost {
return {
+ 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 3c48ae8a27..a5307849dc 100644
--- a/apps/desktop/src/main/browser/browser-host.ts
+++ b/apps/desktop/src/main/browser/browser-host.ts
@@ -33,7 +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/packages/runtime-host/src/__tests__/fixtures/client-capability.ts b/apps/desktop/src/main/browser/browser-origin-admission.ts
similarity index 51%
copy from packages/runtime-host/src/__tests__/fixtures/client-capability.ts
copy to apps/desktop/src/main/browser/browser-origin-admission.ts
index 6bdd854295..2b505623e5 100644
--- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts
+++ b/apps/desktop/src/main/browser/browser-origin-admission.ts
@@ -17,22 +17,25 @@
* under the License.
*/
-import type { ClientCapabilityConnectionIdentity } from '../../server/client-capability-service.js';
+import { AsyncLocalStorage } from 'node:async_hooks';
-export function clientCapabilityConnectionIdentity(
- connectionId: string,
- clientInstanceId = connectionId,
- principalId = 'test-principal',
- principalKind: ClientCapabilityConnectionIdentity['principalKind'] = 'local_owner',
- capabilityOwner?: ClientCapabilityConnectionIdentity['capabilityOwner'],
- credentialBound = principalKind === 'remote_owner',
-): ClientCapabilityConnectionIdentity {
- return {
- connectionId,
- principalId,
- clientInstanceId,
- ...(credentialBound ? { credentialBoundClientInstanceId: clientInstanceId } : {}),
- principalKind,
- ...(capabilityOwner ? { capabilityOwner } : {}),
- };
+export interface BrowserOriginAdmission {
+ readonly sessionId: string;
+ readonly url: string;
+}
+
+const currentAdmission = new AsyncLocalStorage<BrowserOriginAdmission>();
+
+/** Bind the Host-approved Browser scope to exactly one provider invocation. */
+export function withBrowserOriginAdmission<T>(
+ 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<ActiveLease>();
+
+ 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 bf6947b65b..1997ba8bdc 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<T> =
+ | { 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<T>(input: {
sessionId: string;
label: string;
@@ -90,24 +101,74 @@ async function runBrowserAction<T>(input: {
// omit for the pure-observe default, which never reloads.
takeover?: TakeoverMode;
run: BrowserPageRun<T>;
-}): Promise<T> {
+}): Promise<BrowserActionResult<T>> {
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,14 +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<string>('window.location.href').catch(() => url);
- const title = await page.evaluate<string>('document.title').catch(() => '');
- return { landed: typeof landed === 'string' && landed ? landed : url, title, info };
+ return { landed: typeof landed === 'string' && landed ? landed : url, info };
},
});
- 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);
},
};
}
@@ -165,9 +223,12 @@ export function buildBrowserSnapshotTool(): MakaTool<Record<string, never>, 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);
},
};
}
@@ -190,20 +251,54 @@ 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 outcome = await page.click(normalizeElementRef(ref));
+ return { outcome, info };
+ },
});
- 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)
);
},
};
}
+function sanitizedPageUrl(value: string): string {
+ try {
+ const url = new URL(value);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page';
+ return `${url.origin}${url.pathname}`;
+ } catch {
+ return 'an unapproved page';
+ }
+}
+
+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)}.`
+ );
+}
+
export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; submit?: boolean }, string> {
return {
name: 'browser_type',
@@ -233,14 +328,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);
},
};
}
@@ -292,7 +388,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,
@@ -322,7 +418,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);
},
};
}
@@ -358,27 +455,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<void> {
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<Connection> {
return promise;
}
-export type BrowserPageRun<T> = (page: IPage, info: { takeoverReloaded: boolean }) => Promise<T>;
+export type BrowserPageRun<T> = (
+ page: IPage,
+ info: { takeoverReloaded: boolean; originLease?: BrowserOriginLease },
+) => Promise<T>;
/**
* Run one tool action against the session's embedded-browser page: lazy connect
@@ -285,7 +292,12 @@ export async function withBrowserPage<T>(
sessionId: string,
label: string,
run: BrowserPageRun<T>,
- opts?: { timeoutMs?: number; abort?: AbortSignal; takeover?: TakeoverMode },
+ opts?: {
+ timeoutMs?: number;
+ abort?: AbortSignal;
+ takeover?: TakeoverMode;
+ originAdmission?: { approvedUrl: string };
+ },
): Promise<T> {
if (opts?.abort?.aborted) throw new BrowserActionCanceledError(label);
const kind: TakeoverMode = opts?.takeover ?? 'observe';
@@ -306,6 +318,13 @@ export async function withBrowserPage<T>(
let timer: ReturnType<typeof setTimeout> | 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<T>(
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<T>(
} finally {
clearTimeout(timer);
if (onAbort) opts?.abort?.removeEventListener('abort', onAbort);
+ originLease?.release();
untrackInFlight(sessionId, revoke);
}
}
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<string, unknown>;
+ 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-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 e812492c62..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";
@@ -62,6 +63,12 @@ type DesktopToolContentPart = Extract<
export interface DesktopNativeCapabilityProviderInput {
readonly browserTools: readonly MakaTool[];
+ readonly resolveBrowserUrl: (input: {
+ readonly sessionId: string;
+ readonly toolName: string;
+ readonly arguments: Record<string, unknown>;
+ readonly signal: AbortSignal;
+ }) => string | Promise<string>;
readonly releaseBrowserSession: (sessionId: string) => void | Promise<void>;
readonly computerUseTools: ComputerUseToolSet;
readonly releaseComputerUseSession: (
@@ -156,6 +163,7 @@ export function createDesktopNativeCapabilityProvider(
const invocation = new AbortController();
const task = invokeNativeTool(
+ input,
binding,
frame,
options,
@@ -238,7 +246,7 @@ async function invokeAdditionalService(
options: Parameters<NonNullable<ClientCapabilityProvider["callService"]>>[1],
): Promise<Record<string, unknown>> {
options.signal.throwIfAborted();
- await options.accept();
+ await options.accept({ kind: "none" });
options.signal.throwIfAborted();
return service.call(frame.method, frame.input, { signal: options.signal });
}
@@ -309,6 +317,7 @@ function capabilityGroups(
}
async function invokeNativeTool(
+ input: DesktopNativeCapabilityProviderInput,
binding: NativeToolBinding,
frame: ClientCapabilityCallFrame,
options: Parameters<NonNullable<ClientCapabilityProvider["call"]>>[1],
@@ -331,29 +340,69 @@ async function invokeNativeTool(
const parameters = requireZodSchema(binding.tool);
const args = await parameters.parseAsync(frame.arguments);
signal.throwIfAborted();
- await options.accept();
+ 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,
- 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);
}
+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,
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<DesktopSessionSummary>;
reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise<DesktopSessionSummary>;
respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise<void>;
+ respondToClientCapability(
+ sessionId: string,
+ response: ClientCapabilityResponse,
+ ): Promise<void>;
respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void>;
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<void> {
return invokeSessionRuntimeHost('sessions:respondToSandboxBoundary', sessionId, response);
},
+ respondToClientCapability(
+ sessionId: string,
+ response: ClientCapabilityResponse,
+ ): Promise<void> {
+ return invokeSessionRuntimeHost(
+ 'sessions:respondToClientCapability',
+ sessionId,
+ response,
+ );
+ },
respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void> {
return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response);
},
diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts
index 09e752c618..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':
@@ -390,18 +391,16 @@ 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));
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);
@@ -410,17 +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 '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 c96cccdc8f..fa27552207 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -830,9 +830,6 @@ function AppShellContent({
[sessions, onboarding.snapshot?.sessionSendOutcomes],
);
const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId);
- const activeSandboxBoundary =
- activeInteraction?.type === 'sandbox_boundary_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;
@@ -2968,9 +2965,8 @@ function AppShellContent({
newTaskDraftKey={currentNewTaskDraftKey}
newTaskSendPending={newTaskSendPending}
stopPendingBySession={stopPendingBySession}
- activeSandboxBoundary={activeSandboxBoundary}
respondToSandboxBoundary={respondToSandboxBoundary}
- 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 0ac9850d59..ab6a6e39c0 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 {
@@ -95,8 +102,7 @@ interface ChatComposerRegionProps
newTaskSendPending: boolean;
stopPendingBySession: Record<string, boolean>;
respondToSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['onRespond'];
- activeSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['request'] | undefined;
- activeQuestion: ComponentProps<typeof UserQuestionPrompt>['request'] | undefined;
+ respondToClientCapability: ComponentProps<typeof ClientCapabilityPrompt>['onRespond'];
respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
boundaryUnreadableNotice?: BoundaryUnreadableNotice;
@@ -117,8 +123,7 @@ export function ChatComposerRegion({
newTaskSendPending,
stopPendingBySession,
respondToSandboxBoundary,
- activeSandboxBoundary,
- activeQuestion,
+ respondToClientCapability,
respondToUserQuestion,
stop,
boundaryUnreadableNotice,
@@ -127,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;
@@ -216,6 +226,12 @@ export function ChatComposerRegion({
onRespond={respondToSandboxBoundary}
/>
)}
+ {activeClientCapability && (
+ <ClientCapabilityPrompt
+ request={activeClientCapability}
+ onRespond={respondToClientCapability}
+ />
+ )}
{activeQuestion && (
<UserQuestionPrompt
request={activeQuestion}
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<void>;
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<WorkbarControllerCommands>(
- () => ({ openTool, openSideChatWithQuote, toggleRight }),
- [openSideChatWithQuote, openTool, toggleRight],
+ () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }),
+ [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight],
);
const activeSideChatTabIds = useMemo(
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index e5ec333f16..37c4c87fc7 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -34,6 +34,7 @@ import type { GitReviewReadResult, GitReviewSource } from '@maka/core/git-review
import type { PermissionMode } from '@maka/core/permission';
import type { RegenerateTurnInput } from '@maka/core/runtime-inputs';
import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
import type {
SessionChangedEvent,
SessionSummary,
@@ -256,6 +257,10 @@ export interface SideChatSessionPort {
sessionId: string,
response: SandboxBoundaryResponse,
): Promise<void>;
+ respondToClientCapability(
+ sessionId: string,
+ response: ClientCapabilityResponse,
+ ): Promise<void>;
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<ComponentProps<typeof ChatView>['deriveTurnPresentation']>
>(
@@ -215,7 +219,9 @@ export function QuoteCompanionPanel(props: {
{companion.error && (
<Banner status="error" role="alert" title={companion.error} />
)}
- {(companion.activeSandboxBoundary || companion.activeQuestion) && (
+ {(companion.activeSandboxBoundary ||
+ companion.activeClientCapability ||
+ companion.activeQuestion) && (
<div className="maka-composer-interaction-slot">
{companion.activeSandboxBoundary && (
<SandboxBoundaryPrompt
@@ -223,6 +229,12 @@ export function QuoteCompanionPanel(props: {
onRespond={companion.respondToSandboxBoundary}
/>
)}
+ {companion.activeClientCapability && (
+ <ClientCapabilityPrompt
+ request={companion.activeClientCapability}
+ onRespond={companion.respondToClientCapability}
+ />
+ )}
{companion.activeQuestion && (
<UserQuestionPrompt
request={companion.activeQuestion}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
index 2c6244eb18..e65526dfb6 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
@@ -29,11 +29,13 @@ import {
} from '@maka/ui';
import type {
SandboxBoundaryRequestEvent,
+ ClientCapabilityRequestEvent,
QuoteRef,
SessionEvent,
UserQuestionRequestEvent,
} from '@maka/core/events';
import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
import type { PermissionMode } from '@maka/core/permission';
import type { SessionSummary, StoredMessage } from '@maka/core/session';
import type { UiLocale } from '@maka/core/ui-locale';
@@ -137,6 +139,7 @@ export interface UseQuoteCompanionResult {
activeModel: { llmConnectionSlug: string; model: string } | undefined;
/** Pending sandbox-boundary / user-question prompt raised by the companion's run. */
activeSandboxBoundary: SandboxBoundaryRequestEvent | undefined;
+ activeClientCapability: ClientCapabilityRequestEvent | undefined;
activeQuestion: UserQuestionRequestEvent | undefined;
/** Returns whether the send was accepted; false leaves the draft + staged
* quotes in place so the user can retry. */
@@ -147,6 +150,7 @@ export interface UseQuoteCompanionResult {
regenerate: (turnId: string) => Promise<boolean>;
stop: () => Promise<void>;
respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise<void>;
+ respondToClientCapability: (response: ClientCapabilityResponse) => Promise<void>;
respondToUserQuestion: (response: UserQuestionResponse) => Promise<void>;
}
@@ -1025,6 +1029,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
[mountedRef, sideChat],
);
+ const respondToClientCapability = useCallback(
+ async (response: ClientCapabilityResponse): Promise<void> => {
+ 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/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 30b6387120..9c5246717b 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -910,6 +910,7 @@ function bridge(options: {
}),
regenerateTurn: async () => undefined,
respondToSandboxBoundary: async () => undefined,
+ respondToClientCapability: async () => undefined,
respondToUserQuestion: async () => undefined,
subscribeEvents: (_sessionId, _handler, onSeeded) => {
onSeeded?.();
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 7df6c86044..6708f4a6e3 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 `<textarea` (API Use-the-System) | polish |
| `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 228465236a..4b39af6d02 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -180,6 +180,7 @@ packages/ui/src/chat-model-switcher.tsx
packages/ui/src/chat-surface-layout.tsx
packages/ui/src/chat-turn.tsx
packages/ui/src/chat-view.tsx
+packages/ui/src/client-capability-prompt.tsx
packages/ui/src/components.tsx
packages/ui/src/composer-message-queue.tsx
packages/ui/src/composer.tsx
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/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/core/package.json b/packages/core/package.json
index bb0fcbd321..5ce1b558e5 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/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts
index 304a78d04c..9b820797e9 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,49 @@ 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.equal(
+ isInteractionCanonicalOutcomeValidForRequest(
+ request,
+ decodeInteractionCanonicalOutcome({
+ kind: 'closure',
+ reason: 'timed_out',
+ committedAt: 3,
+ }),
+ ),
+ 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/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/core/src/client-capability-grant.ts b/packages/core/src/client-capability-grant.ts
new file mode 100644
index 0000000000..d2ffca1f0f
--- /dev/null
+++ b/packages/core/src/client-capability-grant.ts
@@ -0,0 +1,210 @@
+/*
+ * 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 ClientCapabilityGrantTarget {
+ readonly providerId: string;
+ readonly contractId: string;
+ readonly serverId: string;
+ readonly toolName: string;
+ readonly capability: ClientCapabilityGrantCapability;
+ readonly scope: ClientCapabilityGrantScope;
+}
+
+export interface ClientCapabilitySessionGrantKey extends ClientCapabilityGrantTarget {
+ readonly sessionId: string;
+}
+
+export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGrantKey {
+ readonly version: 1;
+ readonly grantedAt: number;
+}
+
+export interface ClientCapabilityResponse {
+ readonly requestId: string;
+ readonly decision: 'allow' | 'deny';
+}
+
+const GRANT_SHAPE = defineObjectShape<ClientCapabilitySessionGrant>()(
+ [
+ 'version',
+ 'sessionId',
+ 'providerId',
+ 'contractId',
+ 'serverId',
+ 'toolName',
+ 'capability',
+ 'scope',
+ 'grantedAt',
+ ],
+ [],
+);
+const BROWSER_ORIGIN_SCOPE_SHAPE = defineObjectShape<
+ Extract<ClientCapabilityGrantScope, { kind: 'browser_origin' }>
+>()(['kind', 'origin'], []);
+const CAPABILITY_SCOPE_SHAPE = defineObjectShape<
+ Extract<ClientCapabilityGrantScope, { kind: 'capability' }>
+>()(['kind'], []);
+const MCP_TOOL_SCOPE_SHAPE = defineObjectShape<
+ Extract<ClientCapabilityGrantScope, { kind: 'mcp_tool' }>
+>()(['kind', 'serverId', 'toolName'], []);
+
+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,
+ '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({
+ 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<string, unknown> {
+ 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<string, unknown>;
+}
+
+function safeId(value: unknown, label: string): string {
+ if (typeof value !== 'string' || !SAFE_ID.test(value)) throw new Error(`Invalid ${label}`);
+ return value;
+}
+
+function oneOf<const T extends readonly unknown[]>(
+ value: unknown,
+ values: T,
+ label: string,
+): T[number] {
+ if (!values.includes(value)) throw new Error(`Invalid ${label}`);
+ return value as T[number];
+}
+
+function deepFreeze<T>(value: T): T {
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
+ for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
+ Object.freeze(value);
+ }
+ return value;
+}
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/core/src/interaction.ts b/packages/core/src/interaction.ts
index f49136af08..c064906aff 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';
@@ -61,6 +65,7 @@ export const INTERACTION_CLOSURE_REASONS = [
'turn_terminal',
'timed_out',
'host_restarted',
+ 'provider_disconnected',
] as const;
const UTF8 = new TextEncoder();
@@ -95,10 +100,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 +130,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 +162,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 +178,7 @@ export type InteractionCanonicalOutcome =
| InteractionCanonicalPermissionOutcome
| InteractionCanonicalQuestionOutcome
| InteractionCanonicalSandboxBoundaryOutcome
+ | InteractionCanonicalClientCapabilityOutcome
| InteractionCanonicalClosureOutcome;
export type InteractionQuestionProjectionInput = Pick<
@@ -173,6 +198,10 @@ const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape<InteractionSandboxBound
['kind', 'expansion', 'justification'],
[],
);
+const CLIENT_CAPABILITY_REQUEST_SHAPE = defineObjectShape<InteractionClientCapabilityRequest>()(
+ ['kind', 'toolUseId', 'target'],
+ [],
+);
const PERMISSION_ANSWER_SHAPE = defineObjectShape<InteractionPermissionAnswer>()(
['kind', 'decision', 'rememberForTurn'],
[],
@@ -185,6 +214,10 @@ const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape<InteractionSandboxBounda
['kind', 'decision'],
[],
);
+const CLIENT_CAPABILITY_ANSWER_SHAPE = defineObjectShape<InteractionClientCapabilityAnswer>()(
+ ['kind', 'decision'],
+ [],
+);
const PERMISSION_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalPermissionOutcome>()(
['kind', 'reviewer', 'committedAt', 'decision', 'rememberForTurn'],
['rationale', 'riskLevel'],
@@ -198,6 +231,11 @@ const SANDBOX_BOUNDARY_OUTCOME_SHAPE =
['kind', 'decision', 'status', 'committedAt'],
[],
);
+const CLIENT_CAPABILITY_OUTCOME_SHAPE =
+ defineObjectShape<InteractionCanonicalClientCapabilityOutcome>()(
+ ['kind', 'decision', 'committedAt'],
+ [],
+ );
const CLOSURE_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalClosureOutcome>()(
['kind', 'reason', 'committedAt'],
[],
@@ -240,6 +278,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 +316,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 +390,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 +481,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 +509,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 +547,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);
}
@@ -485,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);
}
@@ -495,6 +571,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 +589,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;
}
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..a68e1d6d71
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts
@@ -0,0 +1,385 @@
+/*
+ * 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 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,
+} 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('cancels managed approval owners and joiners with the canonical provider identity', async () => {
+ await withStore(async ({ store }) => {
+ const order: string[] = [];
+ const toolCallByInvocation = new Map<string, string>();
+ const cancelledToolCalls: string[] = [];
+ let acceptedCount = 0;
+ let resolveThirdAccepted!: () => void;
+ const thirdAccepted = new Promise<void>((resolve) => {
+ resolveThirdAccepted = resolve;
+ });
+ 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') {
+ toolCallByInvocation.set(frame.invocationId, frame.toolCallId);
+ order.push('accepted');
+ acceptedCount += 1;
+ if (acceptedCount === 3) resolveThirdAccepted();
+ 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.cancel') {
+ const toolCallId = toolCallByInvocation.get(frame.invocationId);
+ if (toolCallId) cancelledToolCalls.push(toolCallId);
+ } else if (frame.kind === 'client.capability.admitted') {
+ order.push('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);
+ 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 caller = new AbortController();
+ const cancelled = runtime.settleToolCall({
+ tool,
+ turnId: RUN.turnId,
+ stepId: 'step-1',
+ toolCallId: 'browser-call-1',
+ input: {},
+ abortSignal: caller.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;
+
+ 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);
+
+ 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 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,
+ interactionId: retryRequest.requestId,
+ answer: { kind: 'client_capability', decision: 'allow' },
+ },
+ connectionContext('desktop-ui'),
+ );
+ assert.equal(answered.ok, true);
+ order.push('approved');
+ const settlement = await settling;
+ const grant = await store.readClientCapabilitySessionGrant({
+ sessionId: RUN.sessionId,
+ ...retryRequest.request.target,
+ });
+ assert.equal(grant?.providerId, providerId);
+ assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] });
+ assert.deepEqual(order, ['accepted', 'accepted', 'accepted', 'approved', 'T1', 'admitted']);
+ } finally {
+ snapshot?.release();
+ await connection.close();
+ await capabilities.close();
+ await runOwner.close('turn_terminal');
+ runOwner.release();
+ await interactions.close();
+ }
+ });
+});
+
+function sessionHeader(): SessionHeader {
+ return {
+ id: RUN.sessionId,
+ workspaceRoot: '/tmp',
+ cwd: '/tmp',
+ 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',
+ 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<void>((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<void>): Promise<void> {
+ 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-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..c34e4611f8 100644
--- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts
@@ -23,14 +23,22 @@ 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,
+ 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,18 +48,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'}`),
});
- });
+ }
},
});
@@ -122,21 +139,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') return;
- observedCall = frame;
- connection.accept({
- kind: 'client.capability.accepted',
- invocationId: frame.invocationId,
- });
- 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',
@@ -187,17 +215,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,
- });
- 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'),
+ });
+ }
},
},
);
@@ -265,9 +296,184 @@ 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('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',
+ 'local_owner',
+ ),
+ { send: async () => undefined },
+ );
+ await registerSessionTools(
+ local,
+ 'local-connection',
+ 'local-native-registration',
+ 'desktop_browser',
+ ['browser_snapshot'],
+ 'cwd',
+ );
+ 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();
+ 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');
+ 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<
+ 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[] = [];
+ 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,
+ );
+ await registerSessionTools(
+ coordinator,
+ 'connection-a',
+ 'registration-browser',
+ 'desktop_browser',
+ ['browser_snapshot', 'browser_click', 'browser_navigate'],
+ );
+ 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();
+ const connection = attachAutoAdmittingConnection(
+ coordinator,
+ 'connection-a',
+ () => ({ kind: 'none' }),
+ 'settings',
+ );
+ await registerSessionTools(
+ coordinator,
+ 'connection-a',
+ 'registration-settings',
+ 'desktop_settings',
+ ['MakaClientSettingsGet'],
+ );
+ 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 () => {
@@ -879,31 +1085,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,
- });
- 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,
- });
- 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(
@@ -1090,23 +1302,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<void>((resolve) => {
- callArrived = resolve;
+ let admittedArrived!: () => void;
+ const receivedAdmission = new Promise<void>((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,
- });
- 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');
@@ -1126,7 +1341,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),
@@ -1161,6 +1376,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',
@@ -1169,12 +1385,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 });
connection.accept({
kind: 'client.capability.result_chunk',
invocationId,
@@ -1186,8 +1402,8 @@ describe('Host Client Capability coordinator', () => {
},
{
name: 'duplicate start',
+ afterAdmission: true,
transition: (connection: ClientCapabilityConnection, invocationId: string) => {
- connection.accept({ kind: 'client.capability.accepted', invocationId });
connection.accept({
kind: 'client.capability.result_start',
invocationId,
@@ -1201,12 +1417,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 });
connection.accept({
kind: 'client.capability.result_start',
invocationId,
@@ -1219,12 +1435,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 });
connection.accept({
kind: 'client.capability.result_start',
invocationId,
@@ -1248,12 +1464,25 @@ describe('Host Client Capability coordinator', () => {
const invocationArrived = new Promise<string>((resolve) => {
resolveInvocation = resolve;
});
+ let resolveAdmission!: () => void;
+ const admissionArrived = new Promise<void>((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();
}
},
},
@@ -1264,6 +1493,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(
@@ -1283,21 +1513,26 @@ describe('Host Client Capability coordinator', () => {
});
async function assertLossClassification(
- accept: boolean,
+ phase: 'before_acceptance' | 'after_admission',
expected: ClientCapabilityInvocationError['code'] | 'outcome_unknown',
): Promise<void> {
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');
@@ -1317,13 +1552,113 @@ 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<typeof toolAt>,
+ args: Record<string, unknown>,
+ 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,
+ };
+}
+
+function attachAutoAdmittingConnection(
+ coordinator: HostClientCapabilityCoordinator,
+ connectionId: string,
+ admissionEvidence: (frame: ClientCapabilityCallFrame) => ClientCapabilityAdmissionEvidence,
+ resultText: string,
+ sent?: unknown[],
+ principalKind: Parameters<typeof clientCapabilityConnectionIdentity>[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<void> {
+ 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,
@@ -1410,6 +1745,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
new file mode 100644
index 0000000000..01bab875c9
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts
@@ -0,0 +1,129 @@
+/*
+ * 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<Registration>;
+ 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: () => {},
+ });
+
+ 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('cancels accepted work without crossing admission', async () => {
+ const sent: ClientCapabilityHostFrame[] = [];
+ let broker!: ClientCapabilityInvocationBroker<Registration>;
+ 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<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
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-provider-id.test.ts b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts
new file mode 100644
index 0000000000..c532bf3d32
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts
@@ -0,0 +1,61 @@
+/*
+ * 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, safe, private, and sensitive to every authenticated identity field', () => {
+ 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' },
+ }),
+ );
+ 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), first);
+ }
+ });
+});
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..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,
});
@@ -162,7 +164,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 +269,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..9052f9cda6 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<Extract<ClientCapabilityHostFrame, { kind: 'client.capability.call' }>> = [];
const coordinator = new HostClientCapabilityCoordinator({
+ ...clientCapabilityCoordinatorTestAdmission(),
activation: new RuntimePolicyActivationGate(),
onModelToolsChanged: () => undefined,
});
@@ -1381,21 +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,
+ 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 }],
+ },
+ });
});
- });
+ }
},
},
);
@@ -1509,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);
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__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts
index 7fa6da931c..bae9e4b7fc 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';
@@ -119,6 +120,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/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts
index 69e222d419..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;
@@ -890,6 +893,7 @@ async function attachPresentation(
connection.accept({
kind: 'client.capability.accepted',
invocationId: frame.invocationId,
+ admissionEvidence: { kind: 'none' },
});
return;
}
@@ -1014,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 f74e18621d..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<void> {
const clientCapabilities = new HostClientCapabilityCoordinator({
+ ...clientCapabilityCoordinatorTestAdmission(),
activation: new RuntimePolicyActivationGate(),
onModelToolsChanged: () => undefined,
});
@@ -3662,6 +3672,7 @@ async function assertSessionSuccessorCapabilityDegradation(
previousProvider.accept({
kind: 'client.capability.accepted',
invocationId: frame.invocationId,
+ admissionEvidence: { kind: 'none' },
});
previousProvider.accept({
kind: 'client.capability.result',
@@ -3681,6 +3692,7 @@ async function assertSessionSuccessorCapabilityDegradation(
followupProvider.accept({
kind: 'client.capability.accepted',
invocationId: frame.invocationId,
+ admissionEvidence: { kind: 'none' },
});
followupProvider.accept({
kind: 'client.capability.result',
@@ -3831,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/__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/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<void>;
+ accept(evidence: ClientCapabilityAdmissionEvidence): Promise<void>;
progress(current: number, total: number): void;
}) => Promise<ReturnType<typeof decodeClientCapabilityResult>>,
): Promise<void> {
let accepted = false;
let accepting: Promise<void> | undefined;
- const accept = (): Promise<void> => {
+ const accept = (evidence: ClientCapabilityAdmissionEvidence): Promise<void> => {
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<void>;
+ accept(evidence: ClientCapabilityAdmissionEvidence): Promise<void>;
/** 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<void>;
+ accept(evidence: ClientCapabilityAdmissionEvidence): Promise<void>;
},
): Promise<Record<string, unknown>>;
/** 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/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
diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts
index f95b8d8c64..315c440086 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,23 @@ import type {
ClientCapabilityConnectionSender,
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.
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 +176,11 @@ type ClientCapabilityToolBinding = ClientCapabilityBoundTool['binding'];
export interface HostClientCapabilityCoordinatorOptions {
readonly activation: RuntimePolicyActivationGate;
readonly onModelToolsChanged: () => void;
+ readonly interactions: Pick<HostInteractionCoordinator, 'requestClientCapabilityApproval'>;
+ readonly grants: Pick<
+ InteractiveInteractionStoreWriterFacade,
+ 'readClientCapabilitySessionGrant'
+ >;
}
export interface ClientCapabilityServiceInvocationInput {
@@ -181,9 +210,12 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
readonly #activation: RuntimePolicyActivationGate;
readonly #onModelToolsChanged: () => void;
+ readonly #interactions: HostClientCapabilityCoordinatorOptions['interactions'];
+ readonly #grants: HostClientCapabilityCoordinatorOptions['grants'];
readonly #providers = new Map<string, ClientProviderState>();
readonly #connections = new Map<string, ClientProviderConnection>();
readonly #sessions = new Map<string, SessionCapabilityState>();
+ readonly #pendingApprovals = new Map<string, Promise<'allow' | 'deny'>>();
readonly #previewSessions = new AsyncLocalStorage<ReadonlyMap<string, SessionCapabilityState>>();
readonly #pendingConnectionReleases = new Set<Promise<void>>();
readonly #invocations: ClientCapabilityInvocationBroker<CapabilityRegistration>;
@@ -193,6 +225,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);
@@ -566,11 +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),
@@ -844,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',
@@ -983,29 +1020,86 @@ 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<McpPreparedToolCall> => {
+ 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,
+ providerSignal: prepared.providerSignal,
+ callerSignal: options.signal,
+ });
+ 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,10 +1113,59 @@ 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'];
+ readonly providerSignal: AbortSignal;
+ readonly callerSignal?: AbortSignal;
+ }): 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 waitForClientCapabilityApproval(existing, input.callerSignal);
+ const pending = this.#interactions
+ .requestClientCapabilityApproval({
+ sessionId: input.sessionId,
+ turnId: input.turnId,
+ runId: input.runId,
+ toolCallId: input.toolCallId,
+ target,
+ providerSignal: input.providerSignal,
+ ...(input.callerSignal ? { callerSignal: input.callerSignal } : {}),
+ })
+ .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';
- if (identity.capabilityOwner && !trustedProvider) {
+ const providerId = clientCapabilityProviderId(identity);
+ 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);
@@ -1333,8 +1476,53 @@ function freezeRegistration(
};
}
-function clientProviderId(principalId: string, clientInstanceId: string): string {
- return `${principalId}\0${clientInstanceId}`;
+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 serviceContract(serviceId: string, version: string): string {
@@ -1447,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);
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..a4c7db15b3 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,11 +76,16 @@ interface InvocationState<Registration extends ClientCapabilityInvocationRegistr
readonly registration: Registration;
readonly resolve: (result: ClientCapabilityCallResult) => void;
readonly reject: (error: Error) => void;
+ readonly resolveAccepted: (evidence: ClientCapabilityAdmissionEvidence) => 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';
+ onProgress?: (current: number, total: number) => void;
+ readonly timeoutMs: number;
+ readonly providerAvailability: AbortController;
+ timer: NodeJS.Timeout | undefined;
+ acceptedSettled: boolean;
+ phase: 'dispatched' | 'accepted' | 'admitted' | 'chunks';
progress?: { current: number; total: number };
chunks?: {
readonly byteLength: number;
@@ -89,6 +95,18 @@ interface InvocationState<Registration extends ClientCapabilityInvocationRegistr
};
}
+export interface PreparedClientCapabilityInvocation {
+ readonly invocationId: string;
+ /** Resolves once the provider has parsed the call and is waiting at its admission cut. */
+ waitUntilAccepted(): Promise<ClientCapabilityAdmissionEvidence>;
+ /** Crosses the admission cut and returns the provider result. */
+ admit(onProgress?: (current: number, total: number) => void): Promise<ClientCapabilityCallResult>;
+ /** 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<
Registration extends ClientCapabilityInvocationRegistration,
> {
@@ -109,7 +127,7 @@ export class ClientCapabilityInvocationBroker<
this.#onRegistrationIdle = options.onRegistrationIdle;
}
- invoke(
+ async invoke(
registration: Registration,
binding: ClientCapabilityInvocationBinding,
args: Record<string, unknown>,
@@ -118,7 +136,29 @@ export class ClientCapabilityInvocationBroker<
timeoutMs: number,
onProgress?: (current: number, total: number) => void,
): Promise<ClientCapabilityCallResult> {
- 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<string, unknown>,
+ 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 +173,7 @@ export class ClientCapabilityInvocationBroker<
}));
}
- invokeService(
+ async invokeService(
registration: Registration,
serviceId: string,
version: string,
@@ -142,7 +182,29 @@ export class ClientCapabilityInvocationBroker<
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<ClientCapabilityCallResult> {
- 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<string, unknown>,
+ 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 +215,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<ClientCapabilityCallResult> {
+ ): 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<ClientCapabilityCallResult>((resolve, reject) => {
+ let resolveAccepted!: (evidence: ClientCapabilityAdmissionEvidence) => void;
+ let rejectAccepted!: (error: Error) => void;
+ const accepted = new Promise<ClientCapabilityAdmissionEvidence>((resolve, reject) => {
+ resolveAccepted = resolve;
+ rejectAccepted = reject;
+ });
+ const result = new Promise<ClientCapabilityCallResult>((resolve, reject) => {
const onAbort = signal
? () => {
const invocation = this.#invocations.get(invocationId);
@@ -192,45 +256,33 @@ 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<Registration> = {
invocationId,
registration,
resolve,
reject,
+ resolveAccepted,
+ rejectAccepted,
signal,
onAbort,
onProgress,
- timer,
+ timeoutMs,
+ providerAvailability: new AbortController(),
+ 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 +298,71 @@ 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,
+ providerSignal:
+ this.#invocations.get(invocationId)?.providerAvailability.signal ?? AbortSignal.abort(),
+ waitUntilAccepted: () => accepted,
+ 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);
+ 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;
+ },
+ 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,
+ );
+ },
+ };
}
accept(connectionId: string, frame: ClientCapabilityClientFrame): void {
@@ -263,36 +380,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(frame.admissionEvidence);
return;
}
case 'client.capability.rejected':
@@ -307,8 +398,8 @@ export class ClientCapabilityInvocationBroker<
);
return;
case 'client.capability.failed':
- if (invocation.phase === 'dispatched') {
- throw new Error('Client Capability failure arrived before acceptance');
+ if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') {
+ throw new Error('Client Capability failure arrived before admission');
}
this.#settle(
invocation,
@@ -318,8 +409,8 @@ export class ClientCapabilityInvocationBroker<
);
return;
case 'client.capability.progress':
- if (invocation.phase !== 'accepted' && invocation.phase !== 'chunks') {
- throw new Error('Client Capability progress arrived before acceptance');
+ if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') {
+ throw new Error('Client Capability progress arrived before admission');
}
if (
invocation.progress &&
@@ -332,14 +423,14 @@ export class ClientCapabilityInvocationBroker<
invocation.onProgress?.(frame.current, frame.total);
return;
case 'client.capability.result':
- if (invocation.phase !== 'accepted') {
- throw new Error('Client Capability result arrived outside the accepted phase');
+ if (invocation.phase !== 'admitted') {
+ 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 !== 'accepted') {
- throw new Error('Client Capability result chunks started outside the accepted phase');
+ if (invocation.phase !== 'admitted') {
+ throw new Error('Client Capability result chunks started outside the admitted phase');
}
invocation.phase = 'chunks';
invocation.chunks = {
@@ -357,13 +448,21 @@ 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,
- 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 +514,29 @@ export class ClientCapabilityInvocationBroker<
this.#settle(invocation, decodeClientCapabilityResult(decoded), undefined, true);
}
+ #startTimeout(invocation: InvocationState<Registration>): 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<Registration>,
result: ClientCapabilityCallResult | undefined,
@@ -423,7 +545,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 +559,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'));
diff --git a/packages/runtime-host/src/test-only/client-capability-host.ts b/packages/runtime-host/src/server/client-capability-provider-id.ts
similarity index 55%
copy from packages/runtime-host/src/test-only/client-capability-host.ts
copy to packages/runtime-host/src/server/client-capability-provider-id.ts
index 757ce9625c..501513ae13 100644
--- a/packages/runtime-host/src/test-only/client-capability-host.ts
+++ b/packages/runtime-host/src/server/client-capability-provider-id.ts
@@ -17,8 +17,26 @@
* under the License.
*/
-export {
- HostClientCapabilityCoordinator,
- type ClientCapabilitySnapshot,
-} from '../server/client-capability-coordinator.js';
-export { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
+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}`;
+}
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/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts
index 4ef15e9442..68b36e0dc5 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<LiveQuestionEntry, 'run' | 'phase'>
+ | Omit<LiveClientCapabilityEntry, 'run' | 'phase'>;
interface CommittedEntry {
- readonly entry: LiveQuestionEntry;
+ readonly entry: LiveStoredEntry;
readonly outcome: StoredInteractionOutcome;
}
@@ -138,6 +158,23 @@ interface SettledSandboxBoundaryEntry {
readonly settlement: SandboxBoundarySettlement;
}
+export interface ClientCapabilityApprovalInput {
+ readonly sessionId: string;
+ readonly turnId: string;
+ readonly runId: string;
+ readonly toolCallId: string;
+ readonly target: ClientCapabilityGrantTarget;
+ readonly providerSignal?: AbortSignal;
+ readonly callerSignal?: AbortSignal;
+}
+
+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 +245,74 @@ 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,
+ });
+ 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,
+ reason: InteractionClosureReason,
+ ): Promise<void> {
+ 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,
+ committedAt: this.#now(),
+ });
+ await this.#refreshCanonicalContinuity(candidate.request.sessionId, admission);
+ this.#throwIfPoisoned();
+ await this.#applyAndDelete(candidate, outcome);
+ });
+ }
+
beginDrain(): void {
this.#accepting = false;
}
@@ -395,7 +500,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
}
}
- #accept(run: BoundRun, candidate: Omit<LiveQuestionEntry, 'run' | 'phase'>): Promise<void> {
+ #accept(run: BoundRun, candidate: LiveStoredCandidate): Promise<void> {
this.#throwIfPoisoned();
this.#assertRunOpen(run, candidate.request.requestId);
if (!this.#accepting) {
@@ -413,7 +518,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 +531,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
}
async #establishAdmitted(
- entry: LiveQuestionEntry,
+ entry: LiveStoredEntry,
admission: SessionAdmissionLease,
): Promise<void> {
this.#throwIfPoisoned();
@@ -636,7 +741,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 +827,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<InteractionCanonicalOutcome, { kind: 'question_answer' }>,
@@ -795,25 +935,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 +1084,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 +1159,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<StoredInteractionOutcome> {
+ 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<InteractionRecord | undefined> {
this.#throwIfPoisoned();
try {
@@ -1085,10 +1263,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
}
}
- async #applyAndDelete(
- entry: LiveQuestionEntry,
- outcome: StoredInteractionOutcome,
- ): Promise<void> {
+ async #applyAndDelete(entry: LiveStoredEntry, outcome: StoredInteractionOutcome): Promise<void> {
this.#throwIfPoisoned();
if (this.#live.get(entry.request.requestId) !== entry || entry.phase !== 'live') {
throw this.#poison(
@@ -1097,6 +1272,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 +1369,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 +1471,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 +1482,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) {
@@ -1319,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,
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<InteractionAnswer, { kind: 'client_capability' }>,
+ committedAt: number,
+): Extract<InteractionCanonicalOutcome, { kind: 'client_capability_decision' }> {
+ 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',
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/__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__/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/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts
index 65144a1b80..2540f554b2 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,115 @@ 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('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 () => {
@@ -535,7 +643,10 @@ describe('ToolRuntime settlement', () => {
function makeRuntime(
overrides: Partial<
- Pick<ToolRuntimeInput, 'readExecutionBoundary' | 'spawnChildSession' | 'runId'>
+ 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 fce515ad42..a7c48941db 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<string, unknown>,
+ options: Omit<McpToolCallOptions, 'emitProgress'>,
+ ): Promise<McpPreparedToolCall>;
callTool(
binding: McpToolBinding,
args: Record<string, unknown>,
@@ -48,6 +54,13 @@ export interface McpToolProvider {
): Promise<McpCallResult>;
}
+export interface McpPreparedToolCall {
+ execute(options?: {
+ readonly emitProgress?: (current: number, total: number) => void;
+ }): Promise<McpCallResult>;
+ cancel(): Promise<void> | void;
+}
+
export interface McpToolCallOptions {
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
@@ -57,14 +70,18 @@ export interface McpToolCallOptions {
export interface McpToolInvocationContext {
readonly sessionId: string;
+ readonly runId?: string;
readonly turnId: string;
readonly toolCallId: string;
readonly cwd: string;
+ readonly executionBoundary?: ExecutionBoundary;
+ readonly permissionMode?: PermissionMode;
}
export interface BuildMcpToolsOptions {
callTimeoutMs?: number;
categoryHint?: ToolCategory;
+ hostAdmission?: MakaTool['hostAdmission'];
recoveryMode?: ToolRecoveryMode;
executionLocation?: 'host' | 'remote';
activityKindForDescriptor?: (descriptor: McpToolDescriptor) => ToolActivityKind | undefined;
@@ -95,8 +112,37 @@ 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
+ ? {
+ prepareExecution: async (args: unknown, context) => {
+ const prepared = await provider.prepareTool!(binding, asArguments(args), {
+ signal: context.abortSignal,
+ timeoutMs: options.callTimeoutMs,
+ context: {
+ sessionId: context.sessionId,
+ runId: context.runId,
+ 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/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'
);
}
diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts
index 5fe9493be9..5214ffda49 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<R = unknown> {
+ execute(context: MakaToolContext): Promise<R> | R;
+ cancel(): Promise<void> | void;
+}
+
export interface MakaTool<P = any, R = unknown> {
/** Canonical (Claude-SDK-style) name. Pi adapter translates to canonical. */
name: string;
@@ -164,6 +181,8 @@ export interface MakaTool<P = any, R = unknown> {
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;
/**
@@ -193,6 +212,11 @@ export interface MakaTool<P = any, R = unknown> {
args: P,
context: Pick<MakaToolContext, 'sessionId' | 'turnId' | 'toolCallId'>,
) => unknown;
+ /** Optional preparation that settles before ToolRuntime crosses its durable T1 cut. */
+ prepareExecution?: (
+ args: P,
+ context: MakaToolPreparationContext,
+ ) => Promise<PreparedMakaToolExecution<R>>;
/**
* Real tool implementation. Implementations must observe `ctx.abortSignal` and
* settle promptly after it aborts. Runtime-owned nested calls await this
@@ -311,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,
@@ -1383,7 +1409,8 @@ export class ToolRuntime {
}
let clientCapabilityBoundary: ExecutionBoundary | undefined;
- if (tool.categoryHint === 'client_capability') {
+ let preparedExecution: PreparedMakaToolExecution | undefined;
+ if (tool.hostAdmission === 'client_capability') {
try {
clientCapabilityBoundary = await this.readExecutionBoundary();
} catch (error) {
@@ -1398,8 +1425,13 @@ export class ToolRuntime {
this.recordLoopGateOutcome(callSignature, true);
return this.errorReturn(reason);
}
- if (clientCapabilityBoundary.kind !== 'bypass') {
- 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',
});
@@ -1410,7 +1442,37 @@ export class ToolRuntime {
errorClass: 'ClientCapabilityBoundary',
});
this.recordLoopGateOutcome(callSignature, true);
- return this.errorReturn(CLIENT_CAPABILITY_BOUNDARY_MESSAGE);
+ return this.errorReturn(admissionFailure);
+ }
+ // 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();
}
}
@@ -1446,6 +1508,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 +1536,7 @@ export class ToolRuntime {
...(runId ? { runId } : {}),
});
} catch (error) {
+ await preparedExecution?.cancel();
if (reservedSubagentSlot) this.releaseSubagentSlot(tool);
await disposeManagedMutationAdmission(managedMutationAdmission);
throw error;
@@ -1514,82 +1578,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<string, unknown>,
+ ) =>
+ 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<string, unknown>,
- ) =>
- 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 (
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..888fecbbdb
--- /dev/null
+++ b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts
@@ -0,0 +1,105 @@
+/*
+ * 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 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(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);
+ assert.deepEqual(
+ await store.readClientCapabilitySessionGrant({
+ ...key,
+ toolName: 'browser_click',
+ }),
+ grant,
+ );
+
+ 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..d72040d5ce 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,
diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts
index 1b53713d51..53b1260793 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,
@@ -43,6 +50,7 @@ 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 interface InteractionIdentity {
readonly sessionId: string;
@@ -119,6 +127,9 @@ export interface InteractionStoreReader {
readInteraction(requestId: string): Promise<InteractionRecord | undefined>;
listSessionPending(sessionId: string): Promise<StoredInteractionRequest[]>;
listPending(filter?: PendingInteractionFilter): Promise<StoredInteractionRequest[]>;
+ readClientCapabilitySessionGrant(
+ key: ClientCapabilitySessionGrantKey,
+ ): Promise<ClientCapabilitySessionGrant | undefined>;
}
export interface InteractionStoreWriter extends InteractionStoreReader {
@@ -127,6 +138,17 @@ export interface InteractionStoreWriter extends InteractionStoreReader {
requestId: string,
outcome: InteractionCanonicalOutcome,
): Promise<CommitInteractionOutcomeResult>;
+ commitClientCapabilitySessionGrant(
+ grant: ClientCapabilitySessionGrant,
+ ): Promise<ClientCapabilitySessionGrant>;
+ commitClientCapabilityOutcome(
+ requestId: string,
+ outcome: Extract<
+ InteractionCanonicalOutcome,
+ { kind: 'client_capability_decision' | 'closure' }
+ >,
+ grant?: ClientCapabilitySessionGrant,
+ ): Promise<CommitInteractionOutcomeResult>;
}
export interface InteractiveInteractionStoreReaderFacade extends InteractionStoreReader {
@@ -176,6 +198,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 +230,22 @@ 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)),
+ 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);
@@ -337,6 +373,82 @@ class SqliteInteractionStore implements InteractionStoreWriter {
});
}
+ async commitClientCapabilityOutcome(
+ requestId: string,
+ outcome: Extract<
+ InteractionCanonicalOutcome,
+ { kind: 'client_capability_decision' | 'closure' }
+ >,
+ grant?: ClientCapabilitySessionGrant,
+ ): Promise<CommitInteractionOutcomeResult> {
+ 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<InteractionRecord | undefined> {
assertId(requestId);
return readSqliteInteraction(this.#lease, requestId);
@@ -372,11 +484,145 @@ class SqliteInteractionStore implements InteractionStoreWriter {
return sortPending(requests);
}
+ async readClientCapabilitySessionGrant(
+ key: ClientCapabilitySessionGrantKey,
+ ): Promise<ClientCapabilitySessionGrant | undefined> {
+ const candidate = decodeGrantKey(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 capability = ?
+ AND scope_kind = ?
+ AND scope_value = ?
+ `)
+ .get(
+ candidate.sessionId,
+ candidate.providerId,
+ candidate.contractId,
+ 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 = decodeGrant(
+ parseJsonRecord(row.record_json, 'Client Capability Session Grant'),
+ 'record',
+ );
+ if (!sameGrantAuthority(decodeGrantKey(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<ClientCapabilitySessionGrant> {
+ const candidate = decodeGrant(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.#insertClientCapabilitySessionGrant(candidate, scope, encoded);
+ const stored = this.#readClientCapabilitySessionGrant(candidate);
+ if (!stored) {
+ throw new InteractionStoreError(
+ 'io_failed',
+ 'Client Capability Session Grant publication produced no record',
+ );
+ }
+ return stored;
+ });
+ }
+
+ #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 {
+ 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 capability = ?
+ AND scope_kind = ? AND scope_value = ?
+ `)
+ .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');
+ }
+ return deepFreeze(
+ decodeGrant(parseJsonRecord(row.record_json, 'Client Capability Session Grant'), 'record'),
+ );
+ }
+
close(): void {
this.#lease.close();
}
}
+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<OperationalStateDatabaseLease, 'database'>,
requestId: string,
@@ -486,6 +732,37 @@ function normalizeOutcome(
return { ...identity(request), outcome };
}
+function decodeGrant(value: unknown, source: DecodeSource): ClientCapabilitySessionGrant {
+ try {
+ return decodeClientCapabilitySessionGrant(value);
+ } catch (error) {
+ decodeFailure(source, 'Invalid Client Capability Session Grant', error);
+ }
+}
+
+function decodeGrantKey(value: unknown, source: DecodeSource): ClientCapabilitySessionGrantKey {
+ try {
+ return decodeClientCapabilitySessionGrantKey(value);
+ } catch (error) {
+ decodeFailure(source, 'Invalid Client Capability Session Grant key', error);
+ }
+}
+
+function decodeClientCapabilityOutcome(
+ value: unknown,
+): Extract<InteractionCanonicalOutcome, { kind: 'client_capability_decision' | 'closure' }> {
+ 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,
diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts
index f6fea7c94a..9f49a23c19 100644
--- a/packages/storage/src/sqlite-core-execution-schema.ts
+++ b/packages/storage/src/sqlite-core-execution-schema.ts
@@ -112,6 +112,25 @@ 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, 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,
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/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<void>;
+}
+
+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<HTMLButtonElement>(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<void> {
+ 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 (
+ <section
+ className="maka-composer-interaction maka-sandbox-boundary-prompt composer"
+ aria-labelledby={titleId}
+ >
+ <div className="maka-composer-interaction-inner maka-sandbox-boundary-prompt-inner">
+ <div className="maka-sandbox-boundary-copy">
+ <h2 id={titleId}>{copy.title}</h2>
+ <p>{clientCapabilityLabel(request, copy)}</p>
+ <p>{copy.sessionNotice}</p>
+ </div>
+ <div className="maka-sandbox-boundary-actions">
+ <Button
+ ref={rejectButtonRef}
+ variant="secondary"
+ isDisabled={responsePending}
+ onClick={() => void respond('deny')}
+ label={copy.reject}
+ />
+ <Button
+ variant="primary"
+ isDisabled={responsePending}
+ onClick={() => void respond('allow')}
+ label={copy.allowSession}
+ />
+ </div>
+ </div>
+ </section>
+ );
+}
+
+function clientCapabilityLabel(
+ request: ClientCapabilityRequestEvent,
+ copy: ReturnType<typeof getConversationCopy>['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 e8d4c055be..bfd7ca1701 100644
--- a/packages/ui/src/components.tsx
+++ b/packages/ui/src/components.tsx
@@ -42,6 +42,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 502b9aeb50..1046134007 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;
@@ -499,6 +508,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: {
@@ -648,6 +666,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,
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<string, ComposerInteraction[]>;
@@ -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
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org