Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions desktop/electron/src/browserbridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ test('W3 dispatch: hub-leg read runs, ring-audited via:hub — but never hub-mir
deps,
{ tool: 'browser_list_tabs', args: {}, agent_id: 'agent-remote', agent_handle: 'r2d2' },
new Set(),
'browser',
);
assert.equal(out.ok, true);
if (out.ok) assert.match((out.result as ToolResult).content[0]?.text ?? '', /"tabId": 7/);
Expand Down Expand Up @@ -792,7 +793,7 @@ test('W3 dispatch: local reads stay unaudited; actions and hub entries mirror',
test('W3 dispatch: a revoked agent is refused for READS too — revoked means gone', async () => {
const backend = actionBackend();
const { deps, entries } = auditDeps(backend);
const revoked = await dispatchHubInvoke(deps, { tool: 'browser_list_tabs', args: {}, agent_id: 'agent-remote' }, new Set(['agent-remote']));
const revoked = await dispatchHubInvoke(deps, { tool: 'browser_list_tabs', args: {}, agent_id: 'agent-remote' }, new Set(['agent-remote']), 'browser');
assert.deepEqual(revoked, { ok: false, error: 'revoked by user on desktop' });
assert.equal(backend.calls.length, 0, 'refused before any CDP call');
assert.equal(entries.length, 0, 'a revoked refusal is a gate event, not an audited call');
Expand All @@ -801,7 +802,7 @@ test('W3 dispatch: a revoked agent is refused for READS too — revoked means go
test('W3 dispatch: unknown tool refused without touching the backend', async () => {
const backend = actionBackend();
const { deps, entries } = auditDeps(backend);
const out = await dispatchHubInvoke(deps, { tool: 'browser_nuke', args: {}, agent_id: 'agent-remote' }, new Set());
const out = await dispatchHubInvoke(deps, { tool: 'browser_nuke', args: {}, agent_id: 'agent-remote' }, new Set(), 'browser');
assert.deepEqual(out, { ok: false, error: 'unknown_tool' });
assert.equal(backend.calls.length, 0);
assert.equal(entries.length, 0);
Expand All @@ -813,6 +814,7 @@ test('W3 dispatch: action call runs pre-authorized, audited once with via:hub',
deps,
{ tool: 'browser_navigate', args: { tabId: 7, url: 'https://example.com/#frag' }, agent_id: 'agent-remote', agent_handle: 'r2d2' },
new Set(),
'browser',
);
assert.equal(out.ok, true);
assert.equal(entries.length, 1, 'exactly one audit entry per action call');
Expand All @@ -828,6 +830,7 @@ test('W3 dispatch: action call runs pre-authorized, audited once with via:hub',
deps,
{ tool: 'browser_navigate', args: { tabId: 999, url: 'https://example.com/' }, agent_id: 'agent-remote' },
new Set(),
'browser',
);
assert.equal(gone.ok, false);
if (!gone.ok) assert.match(gone.error, /^TARGET_GONE: /);
Expand All @@ -844,6 +847,7 @@ test('W3 dispatch: a revoked agent is refused before the tool machinery', async
deps,
{ tool: 'browser_click', args: { tabId: 7, selector: '#go' }, agent_id: 'agent-remote' },
new Set(['agent-remote']),
'browser',
);
assert.deepEqual(out, { ok: false, error: 'revoked by user on desktop' });
assert.equal(backend.calls.length, 0, 'refused before any CDP call');
Expand All @@ -856,6 +860,7 @@ test('W3 dispatch: a tool-level isError result maps to the error half of the env
deps,
{ tool: 'browser_eval', args: { tabId: 7, expression: 'thrower()' }, agent_id: 'agent-remote' },
new Set(),
'browser',
);
assert.equal(out.ok, false);
if (!out.ok) {
Expand Down
35 changes: 32 additions & 3 deletions desktop/electron/src/browserbridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,27 @@ export interface HubInvokePayload {
/// hub's browser_invoke unwraps (ok → MCP result, !ok → agent-visible error).
export type HubInvokeResult = { ok: true; result: unknown } | { ok: false; error: string };

/// The two traffic classes the desktop exposes over the tunnel (D5). They are
/// separate envelope kinds because they are separate consent sentences: the
/// browser class drives embedded web pages, the desktop class describes and
/// captures the user's own screen.
export type TunnelClass = 'browser' | 'desktop';

export const TUNNEL_KINDS: Readonly<Record<TunnelClass, string>> = {
browser: 'browser.invoke',
desktop: 'desktop.invoke',
};

/// Which class a tool belongs to, or null if we have never heard of it. The
/// desktop-UI tools live in READ_TOOLS for scope reasons (see
/// DESKTOP_ACTION_TOOL_NAMES), so membership alone cannot answer this — the
/// UI_TOOL_NAMES set does.
export function tunnelClassForTool(tool: string): TunnelClass | null {
if (UI_TOOL_NAMES.has(tool)) return 'desktop';
if (READ_TOOLS.some((t) => t.name === tool) || ACTION_TOOL_NAMES.has(tool)) return 'browser';
return null;
}

/// Dispatch one hub-relayed call in-process. This is the in-process entry
/// into the SAME machinery the HTTP MCP path funnels into (callTool) — the
/// bearer parse is skipped (the hub authenticated the agent and
Expand All @@ -1248,10 +1269,18 @@ export async function dispatchHubInvoke(
deps: McpServerDeps,
payload: HubInvokePayload,
revoked: ReadonlySet<string>,
cls: TunnelClass,
): Promise<HubInvokeResult> {
const isRead = READ_TOOLS.some((t) => t.name === payload.tool);
const isAction = ACTION_TOOL_NAMES.has(payload.tool);
if (!isRead && !isAction) return { ok: false, error: 'unknown_tool' };
const actual = tunnelClassForTool(payload.tool);
if (actual === null) return { ok: false, error: 'unknown_tool' };
// Defense in depth against a hub that routed by the wrong kind: the hub
// gates BY CLASS (browser_invoke never raises a desktop_action card), so a
// ui_screenshot arriving in a browser.invoke envelope would be a capture
// nobody approved. The desktop is the authority for its own pixels — it
// checks rather than trusts.
if (actual !== cls) {
return { ok: false, error: `tool_kind_mismatch: '${payload.tool}' is not a ${TUNNEL_KINDS[cls]} tool` };
}
// The desktop's own kill switch, checked BEFORE the tool machinery runs —
// a refusal is a gate event, not an audited action (the same posture as
// W2's scope refusal). It covers READS too: "Revoke" must mean this agent
Expand Down
50 changes: 42 additions & 8 deletions desktop/electron/src/browserbridge_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
READ_TOOLS,
removeBridgeDiscovery,
shouldMirrorAudit,
TUNNEL_KINDS,
startBridgeServer,
stripFragment,
writeBridgeDiscovery,
Expand All @@ -62,6 +63,7 @@ import {
type HubInvokePayload,
type HubInvokeResult,
type McpServerDeps,
type TunnelClass,
type UiCaptureRequest,
type UiCaptureResult,
} from './browserbridge';
Expand Down Expand Up @@ -420,10 +422,7 @@ async function startHubRelay(ctx: BridgeHubContext): Promise<void> {
const res = await fetch(`${ctx.baseUrl}/v1/teams/${encodeURIComponent(ctx.teamId)}/hosts`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
name: `desktop-${os.hostname()}`,
capabilities: { browser_bridge: true, app_version: app.getVersion(), tools: READ_TOOLS.length + ACTION_TOOLS.length },
}),
body: JSON.stringify({ name: hostRegistrationName(), capabilities: hostCapabilities() }),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return;
Expand Down Expand Up @@ -459,6 +458,38 @@ async function postHubHeartbeat(relay: HubRelay): Promise<void> {
}
}

function hostRegistrationName(): string {
return `desktop-${os.hostname()}`;
}

/// What this desktop advertises to the hub. `desktop_ui` (D5) tracks the
/// UI-context-sharing toggle, so a remote agent's hosts_list answer says
/// truthfully whether this desktop will talk about its own screen — the
/// toggle is not a refusal the agent discovers only on call.
function hostCapabilities(): Record<string, unknown> {
return {
browser_bridge: true,
desktop_ui: uiFocusProvider?.available() ?? false,
app_version: app.getVersion(),
tools: READ_TOOLS.length + ACTION_TOOLS.length,
};
}

/// Re-post the registration so a mid-run toggle flip is reflected in what the
/// hub advertises (the POST upserts on (team, name), which is why W3 could
/// re-post on every enable). Best-effort and idempotent: a failure just
/// leaves the previous capabilities until the next sync.
export function refreshHostCapabilities(): void {
const relay = hubRelay;
if (relay === null) return;
void fetch(`${relay.ctx.baseUrl}/v1/teams/${encodeURIComponent(relay.ctx.teamId)}/hosts`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${relay.token}` },
body: JSON.stringify({ name: hostRegistrationName(), capabilities: hostCapabilities() }),
signal: AbortSignal.timeout(10000),
}).catch(() => undefined);
}

/// Abortable sleep for the poll backoff.
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -527,10 +558,13 @@ async function hubTunnelLoop(relay: HubRelay): Promise<void> {
}
}

/// Route one envelope. Only kind browser.invoke is ours; anything else gets
/// the contract's unknown_kind error rather than killing the loop.
/// Route one envelope. Two kinds are ours (D5) — browser.invoke drives the
/// embedded tabs, desktop.invoke describes/captures the desktop's own UI —
/// and they share this dispatcher; anything else gets the contract's
/// unknown_kind error rather than killing the loop.
async function answerTunnelEnvelope(env: TunnelEnvelope): Promise<HubInvokeResult> {
if (env.kind !== 'browser.invoke') return { ok: false, error: 'unknown_kind' };
const cls = (Object.keys(TUNNEL_KINDS) as TunnelClass[]).find((k) => TUNNEL_KINDS[k] === env.kind);
if (cls === undefined) return { ok: false, error: 'unknown_kind' };
const deps = mcpDeps;
if (deps === null) return { ok: false, error: 'bridge not running' };
const p = (env.payload !== null && typeof env.payload === 'object' ? env.payload : {}) as Record<string, unknown>;
Expand All @@ -540,7 +574,7 @@ async function answerTunnelEnvelope(env: TunnelEnvelope): Promise<HubInvokeResul
agent_id: typeof p.agent_id === 'string' ? p.agent_id : '',
...(typeof p.agent_handle === 'string' && p.agent_handle !== '' ? { agent_handle: p.agent_handle } : {}),
};
return dispatchHubInvoke(deps, payload, revokedAgents);
return dispatchHubInvoke(deps, payload, revokedAgents, cls);
}

/// W3: agent ids the user revoked from Settings → Remote driving. In-memory,
Expand Down
8 changes: 4 additions & 4 deletions desktop/electron/src/desktopui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,12 @@ test('tools/call ui_get_focus: toggle off → actionable isError, not unknown-to

test('hub-leg ui_get_focus: the sharing gate binds the tunnel path too (D1 is local-first, D5 unchanged)', async () => {
// dispatchHubInvoke classifies ui_get_focus as a read tool — the gate at
// the tool (not just the catalog filter) is what keeps a remote call
// consent-gated until D5 builds its own stack.
const off = await dispatchHubInvoke(deps(SNAPSHOT, false), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set());
// the tool (not just the catalog filter) is what keeps the D5 tunnel leg
// consent-gated: a relayed call hits the same refusal as a local one.
const off = await dispatchHubInvoke(deps(SNAPSHOT, false), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set(), 'desktop');
assert.equal(off.ok, false);
if (!off.ok) assert.match(off.error, /UI_UNAVAILABLE/);
const on = await dispatchHubInvoke(deps(SNAPSHOT, true), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set());
const on = await dispatchHubInvoke(deps(SNAPSHOT, true), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set(), 'desktop');
assert.equal(on.ok, true);
});

Expand Down
11 changes: 9 additions & 2 deletions desktop/electron/src/desktopui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
/// toggle-off removes just that entry. All file mechanics live in the
/// electron-free kimimcp.ts.
///
/// D1 is LOCAL-ONLY: nothing here touches the hub or the tunnel (that is D5).
/// D1 shipped LOCAL-ONLY; since D5 this module also re-posts the `desktop_ui`
/// capability to the hub when the toggle flips (refreshHostCapabilities), so
/// `hosts_list` answers truthfully — the tunnel routing itself lives in
/// browserbridge_host.ts.
import os from 'node:os';
import { installStableRelay, mergeSharingEntry, removeSharingEntry, type KimiMcpWrite } from './kimimcp';
import { setUiFocusProvider, stdioBridgePath } from './browserbridge_host';
import { refreshHostCapabilities, setUiFocusProvider, stdioBridgePath } from './browserbridge_host';
import type { Handler } from './ipc/dispatch';

/// Whether the user turned UI context sharing on (Settings → Assistant).
Expand Down Expand Up @@ -82,6 +85,10 @@ export const desktopuiHandlers: Record<string, Handler> = {
sharingEnabled = args.enabled === true;
if (!sharingEnabled) focusCache = null;
const mcp = reconcileSharing(sharingEnabled);
// D5: `desktop_ui` in the hub host row tracks this toggle, so a remote
// agent's hosts_list answer says truthfully whether this desktop will
// talk about its own screen. No-op unless a hub relay is live.
refreshHostCapabilities();
return { enabled: sharingEnabled, mcp };
},
/// The renderer's focus push. Validated defensively (shape + size) — the
Expand Down
83 changes: 83 additions & 0 deletions desktop/electron/src/tunnelclass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/// Tests for D5's two-class tunnel dispatch
/// (docs/plans/desktop-ui-context-and-pointing.md §3.6): the desktop now
/// answers TWO envelope kinds, and a tool must arrive under its own. That
/// check is the desktop refusing to trust the hub's routing — the hub gates
/// BY CLASS, so a ui_screenshot smuggled in a browser.invoke envelope would
/// be a capture nobody approved. `node --test`.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ACTION_TOOLS,
dispatchHubInvoke,
READ_TOOLS,
TUNNEL_KINDS,
tunnelClassForTool,
UI_TOOL_NAMES,
type BridgeBackend,
type McpServerDeps,
type UiCaptureResult,
} from './browserbridge.ts';

const backend: BridgeBackend = {
listTargets: () => [{ tabId: 7, url: 'https://a.b/c', title: 't', partition: 'persist:webtab', bridge: 'full' }],
sendCommand: async () => ({ data: 'aGVsbG8=' }),
};

function deps(): McpServerDeps {
return {
backend,
serverInfo: { name: 'termipod-browser', version: '0.0.0-test' },
uiFocusAvailable: () => true,
getUiFocus: () => ({ surface: 'read', captured_at: 'x' }),
captureUi: async (): Promise<UiCaptureResult> => ({ ok: true, data_b64: 'aGk=', width: 10, height: 10 }),
};
}

test('every tool belongs to exactly one class, and the kinds are distinct', () => {
for (const t of READ_TOOLS) {
assert.equal(tunnelClassForTool(t.name), UI_TOOL_NAMES.has(t.name) ? 'desktop' : 'browser', t.name);
}
for (const t of ACTION_TOOLS) assert.equal(tunnelClassForTool(t.name), 'browser', t.name);
assert.equal(tunnelClassForTool('browser_nuke'), null);
assert.notEqual(TUNNEL_KINDS.browser, TUNNEL_KINDS.desktop);
});

test('a desktop-UI tool smuggled in a browser envelope is refused', async () => {
// The hub's browser_invoke never raises a desktop_action card, so routing
// ui_screenshot as browser.invoke would land an unapproved capture. The
// desktop is the authority for its own pixels: it checks rather than trusts.
const out = await dispatchHubInvoke(deps(), { tool: 'ui_screenshot', args: {}, agent_id: 'ag_1' }, new Set(), 'browser');
assert.equal(out.ok, false);
if (!out.ok) assert.match(out.error, /tool_kind_mismatch/);

const focus = await dispatchHubInvoke(deps(), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set(), 'browser');
assert.equal(focus.ok, false);
});

test('a browser tool smuggled in a desktop envelope is refused too', async () => {
const out = await dispatchHubInvoke(deps(), { tool: 'browser_list_tabs', args: {}, agent_id: 'ag_1' }, new Set(), 'desktop');
assert.equal(out.ok, false);
if (!out.ok) assert.match(out.error, /tool_kind_mismatch/);
});

test('each tool routes under its own kind', async () => {
const focus = await dispatchHubInvoke(deps(), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set(), 'desktop');
assert.equal(focus.ok, true);
const tabs = await dispatchHubInvoke(deps(), { tool: 'browser_list_tabs', args: {}, agent_id: 'ag_1' }, new Set(), 'browser');
assert.equal(tabs.ok, true);
});

test('an unknown tool is unknown before it is mismatched', async () => {
// Order matters for the message an agent reads: "I have never heard of
// this" is more useful than "wrong envelope".
for (const cls of ['browser', 'desktop'] as const) {
const out = await dispatchHubInvoke(deps(), { tool: 'ui_mind_read', args: {}, agent_id: 'ag_1' }, new Set(), cls);
assert.deepEqual(out, { ok: false, error: 'unknown_tool' });
}
});

test('revocation still outranks the class check', async () => {
const out = await dispatchHubInvoke(deps(), { tool: 'ui_get_focus', args: {}, agent_id: 'ag_1' }, new Set(['ag_1']), 'desktop');
assert.equal(out.ok, false);
if (!out.ok) assert.match(out.error, /revoked/);
});
4 changes: 2 additions & 2 deletions desktop/electron/src/uiscreenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ test('ui_screenshot mirrors to the hub on every leg; ui_get_focus does not', ()

test('a hub-relayed capture is marked via:hub so the desktop raises no second card', async () => {
const h = harness();
const out = await dispatchHubInvoke(h.deps, { tool: 'ui_screenshot', args: {}, agent_id: 'ag_9', agent_handle: 'remote-1' }, new Set());
const out = await dispatchHubInvoke(h.deps, { tool: 'ui_screenshot', args: {}, agent_id: 'ag_9', agent_handle: 'remote-1' }, new Set(), 'desktop');
assert.equal(out.ok, true);
assert.equal(h.seen[0]?.via, 'hub', 'the hub gated this call already (D5) — a second card would double-prompt');
assert.equal(h.seen[0]?.agentId, 'ag_9');
Expand All @@ -226,7 +226,7 @@ test('a hub-relayed capture is marked via:hub so the desktop raises no second ca

test('a revoked agent cannot capture, and never reaches the provider', async () => {
const h = harness();
const out = await dispatchHubInvoke(h.deps, { tool: 'ui_screenshot', args: {}, agent_id: 'ag_9' }, new Set(['ag_9']));
const out = await dispatchHubInvoke(h.deps, { tool: 'ui_screenshot', args: {}, agent_id: 'ag_9' }, new Set(['ag_9']), 'desktop');
assert.equal(out.ok, false);
assert.equal(h.seen.length, 0);
});
Loading
Loading