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
19 changes: 10 additions & 9 deletions desktop/electron/src/browserbridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
removeBridgeDiscovery,
startBridgeServer,
stripFragment,
UI_TOOL_NAMES,
writeBridgeDiscovery,
type AxNode,
type BridgeAuditEntry,
Expand Down Expand Up @@ -171,9 +172,9 @@ test('tools/list is exactly the four W1 read tools', async () => {
tools.map((t) => t.name),
['browser_list_tabs', 'browser_snapshot', 'browser_screenshot', 'browser_read_text'],
);
// D1's ui_get_focus is catalog-hidden without the desktop sharing provider
// (DEPS carries none) — one short of READ_TOOLS.
assert.equal(tools.length, READ_TOOLS.length - 1);
// The D1/D3 desktop-UI tools are catalog-hidden without the sharing
// provider (DEPS carries none) — READ_TOOLS minus that set.
assert.equal(tools.length, READ_TOOLS.length - UI_TOOL_NAMES.size);
// The untrusted-content posture is spelled out in the descriptions (§3.5).
assert.ok(tools.find((t) => t.name === 'browser_snapshot')?.description.includes('untrusted DATA'));
});
Expand Down Expand Up @@ -395,8 +396,8 @@ function auditDeps(backend: BridgeBackend): { deps: Parameters<typeof handleMcpM

test('W2 scope gating: tools/list per scope; action call refused under read scope', async () => {
const deps = { ...DEPS, backend: actionBackend() };
// DEPS has no D1 ui-focus provider: ui_get_focus stays catalog-hidden.
const visibleReads = READ_TOOLS.filter((t) => t.name !== 'ui_get_focus');
// DEPS has no D1 ui-focus provider: the desktop-UI tools stay catalog-hidden.
const visibleReads = READ_TOOLS.filter((t) => !UI_TOOL_NAMES.has(t.name));
const readList = await handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, deps, { scope: 'read', agentId: null });
const readTools = (readList?.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
assert.deepEqual(readTools, visibleReads.map((t) => t.name));
Expand Down Expand Up @@ -722,14 +723,14 @@ test('W2 HTTP: action bearer grants full scope; x-tp-agent-id flows into the aud
const readList = (await (await post(token, { jsonrpc: '2.0', id: 1, method: 'tools/list' })).json()) as {
result: { tools: Array<{ name: string }> };
};
// DEPS has no D1 ui-focus provider: ui_get_focus stays catalog-hidden
// (the sharing-toggle gate), so the list is one short of READ_TOOLS.
assert.equal(readList.result.tools.length, READ_TOOLS.length - 1);
// DEPS has no D1 ui-focus provider: the desktop-UI tools stay
// catalog-hidden (the sharing-toggle gate).
assert.equal(readList.result.tools.length, READ_TOOLS.length - UI_TOOL_NAMES.size);

const fullList = (await (await post(actionToken, { jsonrpc: '2.0', id: 2, method: 'tools/list' })).json()) as {
result: { tools: Array<{ name: string }> };
};
assert.equal(fullList.result.tools.length, READ_TOOLS.length - 1 + ACTION_TOOLS.length);
assert.equal(fullList.result.tools.length, READ_TOOLS.length - UI_TOOL_NAMES.size + ACTION_TOOLS.length);

const clickBody = { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_click', arguments: { tabId: 7, selector: '#go' } } };
const refused = (await (await post(token, clickBody)).json()) as { result: ToolResult };
Expand Down
111 changes: 100 additions & 11 deletions desktop/electron/src/browserbridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type { AddressInfo } from 'node:net';
// Explicit .ts extension: this module runs under `node --test`'s strip-only
// loader (which resolves like plain ESM) as well as esbuild.
import { partitionPolicy } from './webtab_policy.ts';
import { parseScreenshotArgs } from './uicapture.ts';

// ── Targets / backend ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -420,8 +421,34 @@ export const READ_TOOLS: readonly McpToolDef[] = [
'What the TermiPod desktop user is currently looking at: the workbench surface(s) on screen plus focus state (open tab, focused agent, Inspect file + selection, terminal pane) as a compact JSON snapshot with captured_at. With a split, `surface` is the PRIMARY pane, `secondary` the pinned pane, and `active_pane` names the pane the user is in — resolve "this/here" against the active pane, not `surface` alone. Ids, paths and fragment-stripped URLs only — never message bodies, vault material, or settings values. Call this when the user references what is on their screen ("this", "here", "what I\'m looking at", "why is this failing") or when grounding in the user\'s current view would materially change the answer; do NOT call by default on every turn.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
// D3 (plan §3.3, ADR-062 D-4): the VISUAL representation — pixels for the
// residue structure cannot answer. Read-SCOPED on purpose (the local
// kimi-code loop holds only the read token) but action-CLASS in every other
// respect: per-call approval, no session grant, audited + hub-mirrored.
{
name: 'ui_screenshot',
description:
'Capture a PNG screenshot of the TermiPod desktop window, or of one embedded browser tab (tabId from browser_list_tabs). EVERY call raises an approval card the desktop user must accept — there is no standing grant, so use this only when pixels are the answer: a rendering bug, a layout question, "why does this look wrong". For what the user is looking at, ui_get_focus is cheaper, precise and needs no approval; for page content, browser_snapshot. Captures are refused outright while a sensitive surface (vault, settings) is on screen.',
inputSchema: {
type: 'object',
properties: {
tabId: { type: 'integer', description: 'Capture this embedded tab instead of the whole desktop window.' },
},
additionalProperties: false,
},
},
];

/// The D1/D3 desktop-UI tools, which the sharing toggle gates as a set: off
/// means neither appears in any catalog and both refuse on call.
export const UI_TOOL_NAMES: ReadonlySet<string> = new Set(['ui_get_focus', 'ui_screenshot']);

/// Desktop-UI tools that are ACTION class despite living in READ_TOOLS (the
/// bearer scope they need and the consent they need are different questions —
/// see the ui_screenshot definition). They audit + hub-mirror like an action
/// on every leg, and their own handler owns the approval gate.
export const DESKTOP_ACTION_TOOL_NAMES: ReadonlySet<string> = new Set(['ui_screenshot']);

/// D1: the MCP resource uri mirroring ui_get_focus (ADR-062 D-6). list + read
/// only — subscriptions are deliberately NOT implemented: the relay forwards
/// frames verbatim so notification delivery would work transport-wise, but
Expand Down Expand Up @@ -547,11 +574,14 @@ const ACTION_TOOL_NAMES: ReadonlySet<string> = new Set(ACTION_TOOLS.map((t) => t
const READ_TOOL_NAMES: ReadonlySet<string> = new Set(READ_TOOLS.map((t) => t.name));

/// Whether an audit entry should be mirrored to the hub as an agent event.
/// Actions always mirror (W2 contract). Hub-leg READS stay ring-only: the
/// hub routed the call, so a mirror row adds no information — the ring
/// entry exists purely so Settings → Remote driving can show (and revoke)
/// read-only remote sessions.
/// Actions always mirror (W2 contract) — including the desktop-UI action
/// tools, which sit in READ_TOOLS for scope reasons but are actions for
/// consent and audit (D3). Hub-leg READS stay ring-only: the hub routed the
/// call, so a mirror row adds no information — the ring entry exists purely
/// so Settings → Remote driving can show (and revoke) read-only remote
/// sessions.
export function shouldMirrorAudit(entry: BridgeAuditEntry): boolean {
if (DESKTOP_ACTION_TOOL_NAMES.has(entry.tool)) return true;
return !(entry.via === 'hub' && READ_TOOL_NAMES.has(entry.tool));
}

Expand All @@ -574,8 +604,34 @@ export interface McpServerDeps {
/// D1: the main-side focus cache (the renderer's last projected push), or
/// null before the first push — the tool then answers an empty snapshot.
getUiFocus?: () => Record<string, unknown> | null;
/// D3: the gated screenshot (uicapture_host.ts). Absent means the capability
/// is not wired at all, which refuses like any other unavailable tool. The
/// provider owns the policy refusal AND the per-call approval — this module
/// only resolves the target and shapes the result.
captureUi?: (req: UiCaptureRequest) => Promise<UiCaptureResult>;
}

/// One `ui_screenshot` request, with the target already resolved against the
/// live guest registry (so a guessed tabId can never name the app:// shell)
/// and the caller's identity attached for the approval card + audit.
export interface UiCaptureRequest {
/// null = the desktop window itself.
tabId: number | null;
/// The guest's fragment-stripped URL, when capturing a tab.
url: string | null;
/// The guest's partition, when capturing a tab — the policy key.
partition: string | null;
agentId: string;
agentHandle: string;
/// 'hub' calls arrive pre-approved (the hub raises the desktop_action card
/// before routing — D5), so the desktop must not raise a second one.
via: 'local' | 'hub';
}

export type UiCaptureResult =
| { ok: true; data_b64: string; width: number; height: number }
| { ok: false; code: string; message: string };

interface JsonRpcRequest {
jsonrpc?: string;
id?: unknown;
Expand Down Expand Up @@ -893,7 +949,7 @@ async function uploadFiles(deps: McpServerDeps, target: BridgeTarget, args: Reco
}
}

async function runTool(deps: McpServerDeps, name: string, args: Record<string, unknown>): Promise<unknown> {
async function runTool(deps: McpServerDeps, ctx: BridgeRequestContext, name: string, args: Record<string, unknown>): Promise<unknown> {
const { backend } = deps;
switch (name) {
case 'browser_list_tabs': {
Expand Down Expand Up @@ -1039,6 +1095,33 @@ async function runTool(deps: McpServerDeps, name: string, args: Record<string, u
}
return textContent(uiFocusText(deps));
}
case 'ui_screenshot': {
// Same consent gate as ui_get_focus (the toggle governs the whole D1/D3
// capability set), then the target resolution — `requireTarget` is what
// keeps a tabId inside the allowlisted guest registry.
if (deps.uiFocusAvailable?.() !== true) {
throw new BridgeError(
'UI_UNAVAILABLE',
'UI context sharing is off on the desktop (Settings → Assistant) — the desktop UI is not addressable',
);
}
if (deps.captureUi === undefined) {
throw new BridgeError('UI_UNAVAILABLE', 'this desktop build cannot capture its own window');
}
const parsed = parseScreenshotArgs(args);
if ('error' in parsed) throw new BridgeError('INVALID_PARAMS', parsed.error);
const target = parsed.tabId === null ? null : requireTarget(deps, { tabId: parsed.tabId });
const res = await deps.captureUi({
tabId: parsed.tabId,
url: target !== null ? stripFragment(target.url) : null,
partition: target?.partition ?? null,
agentId: ctx.agentId ?? '',
agentHandle: ctx.agentHandle ?? '',
via: ctx.via ?? 'local',
});
if (!res.ok) throw new BridgeError(res.code, res.message);
return { content: [{ type: 'image', data: res.data_b64, mimeType: 'image/png' }] };
}
default:
throw new BridgeError('UNKNOWN_TOOL', `unknown tool '${name}'`);
}
Expand All @@ -1060,7 +1143,13 @@ function uiFocusText(deps: McpServerDeps): string {
/// driving (local reads stay unaudited: same-machine spawns, high frequency,
/// and the ring would churn).
async function callTool(deps: McpServerDeps, ctx: BridgeRequestContext, name: string, args: Record<string, unknown>): Promise<unknown> {
if (!ACTION_TOOL_NAMES.has(name) && ctx.via !== 'hub') return runTool(deps, name, args);
// D3: a desktop-UI action tool is audited on EVERY leg, local included — a
// screenshot of the user's own screen is exactly what the Settings audit
// view exists to show, and unlike a browser read it is neither cheap nor
// frequent, so the ring will not churn.
if (!ACTION_TOOL_NAMES.has(name) && !DESKTOP_ACTION_TOOL_NAMES.has(name) && ctx.via !== 'hub') {
return runTool(deps, ctx, name, args);
}
const tabId = typeof args.tabId === 'number' && Number.isInteger(args.tabId) ? args.tabId : null;
const target = tabId === null ? undefined : deps.backend.listTargets().find((t) => t.tabId === tabId);
const entry: BridgeAuditEntry = {
Expand All @@ -1077,7 +1166,7 @@ async function callTool(deps: McpServerDeps, ctx: BridgeRequestContext, name: st
};
if (ctx.agentHandle !== undefined) entry.agent_handle = ctx.agentHandle;
try {
const out = await runTool(deps, name, args);
const out = await runTool(deps, ctx, name, args);
entry.ok = true;
return out;
} catch (e) {
Expand Down Expand Up @@ -1234,10 +1323,10 @@ export async function handleMcpMessage(
return rpcResult(id, {});
case 'tools/list': {
// Scope gate #1: a read-scoped session never SEES the action tools.
// Consent gate (D1): ui_get_focus is catalog-visible only while the
// desktop's UI context sharing toggle is on.
const reads =
deps.uiFocusAvailable?.() === true ? READ_TOOLS : READ_TOOLS.filter((t) => t.name !== 'ui_get_focus');
// Consent gate (D1/D3): the desktop-UI tools are catalog-visible only
// while the sharing toggle is on — off means no publisher and no tool
// in any catalog.
const reads = deps.uiFocusAvailable?.() === true ? READ_TOOLS : READ_TOOLS.filter((t) => !UI_TOOL_NAMES.has(t.name));
return rpcResult(id, { tools: ctx.scope === 'full' ? [...reads, ...ACTION_TOOLS] : reads });
}
case 'resources/list': {
Expand Down
26 changes: 26 additions & 0 deletions desktop/electron/src/browserbridge_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import {
type HubInvokePayload,
type HubInvokeResult,
type McpServerDeps,
type UiCaptureRequest,
type UiCaptureResult,
} from './browserbridge';
import { policyForGuest } from './webtab';
import { keychainGetLocal } from './ipc/keychain';
Expand Down Expand Up @@ -273,6 +275,24 @@ export function setUiFocusProvider(p: UiFocusProvider): void {
uiFocusProvider = p;
}

/// D3: the gated screenshot provider (uicapture_host.ts), plugged in the same
/// way and for the same reason — it needs the hub context and the focus cache
/// this module and desktopui.ts own, so it registers instead of being imported.
type UiCaptureProvider = (req: UiCaptureRequest) => Promise<UiCaptureResult>;

let uiCaptureProvider: UiCaptureProvider | null = null;

export function setUiCaptureProvider(p: UiCaptureProvider): void {
uiCaptureProvider = p;
}

/// The hub identity the audit mirror and the D3 approval card both post as.
/// Non-secret: the bearer is fetched from the keychain per use, never held
/// here (and never handed across IPC).
export function currentHubContext(): { baseUrl: string; teamId: string; profileId: string } | null {
return hubContext;
}

// ── Enable/disable lifecycle ─────────────────────────────────────────────────

let server: BridgeServer | null = null;
Expand All @@ -298,6 +318,12 @@ async function enable(): Promise<void> {
// ui_get_focus on the next tools/list without a server restart.
uiFocusAvailable: () => uiFocusProvider?.available() ?? false,
getUiFocus: () => uiFocusProvider?.snapshot() ?? null,
// D3: read through the holder on every call, so a provider registered
// after the server started (module init order) is still reachable.
captureUi: (req) =>
uiCaptureProvider !== null
? uiCaptureProvider(req)
: Promise.resolve<UiCaptureResult>({ ok: false, code: 'UI_UNAVAILABLE', message: 'the capture provider is not wired' }),
};
server = await startBridgeServer({ ...mcpDeps, token, actionToken });
writeBridgeDiscovery(os.homedir(), {
Expand Down
7 changes: 7 additions & 0 deletions desktop/electron/src/desktopui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export function isUiSharingEnabled(): boolean {
/// that crosses IPC), so main's job is just to hold and serve it.
let focusCache: Record<string, unknown> | null = null;

/// D3's capture path reads the same cache to learn which surfaces are on
/// screen — the refusal decision must be made from the SAME view of the
/// screen the agent can already see, not from a second source of truth.
export function currentFocusSnapshot(): Record<string, unknown> | null {
return sharingEnabled ? focusCache : null;
}

/// A runaway publisher can't fill memory: snapshots are ref-sized (a handful
/// of ids/paths); anything bigger is a bug, not a bigger allowance.
const MAX_SNAPSHOT_BYTES = 16384;
Expand Down
5 changes: 5 additions & 0 deletions desktop/electron/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import { disposeKimiWebWin } from './kimiwebwin';
import { disposeRerun } from './rerun';
import { disposeBrowserBridge } from './browserbridge_host';
import { disposeAnnotations } from './annotation_host';
// D3: importing the module registers the gated-screenshot provider on the
// bridge server; setShellWindow tells it which window "the desktop" means.
import { setShellWindow } from './uicapture_host';
import { initEvents } from './events';

// The frontend build. In dev (`electron .` from desktop/electron) it resolves to
Expand Down Expand Up @@ -72,6 +75,7 @@ function createWindow(): void {
},
});
mainWindow = win;
setShellWindow(win);
// A `window.open` from any embedded content would spawn a child window that
// INHERITS this window's webPreferences — preload included — handing
// `__ELECTRON_BRIDGE__` and the whole command allowlist to remote content.
Expand All @@ -89,6 +93,7 @@ function createWindow(): void {
win.once('ready-to-show', () => win.show());
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
setShellWindow(null);
});
void win.loadURL(`${APP_ORIGIN}/index.html`);
}
Expand Down
Loading
Loading