From 915f64473160ef4a74bab10535fa93f8a5d1d572 Mon Sep 17 00:00:00 2001 From: Sauransh Bhardwaj Date: Thu, 8 Oct 2026 02:24:51 -0400 Subject: [PATCH 1/5] feat(desktop): build per-invocation approval requests in the device tool policy A dispatch_required decision for a sensitive on-device tool now has a policy-owned shape for what the user is asked: the capability, operation and resource the grant would be matched against, a bounded display preview of the input (attachment paths reduced to a file name, text and scripts capped at the 4 KiB card bound with a truncation flag the broker fails closed on), and the answers. Deny is the default; allow_once is always offered; allow_session is offered only when there is an exact resource for a grant to cover and carries plain words for what it would cover, so a script grant is shown as covering only that exact script. The 180s expiry matches the mobile device-tool transport and stays under the pi-mono long tool wait minus the Swift executor timeout, so a late answer cannot run an effect the model already reported as failed. Only this module decides the question and the options; adapters and the model never choose allow or deny. --- .../agent/src/runtime/desktop-tool-policy.ts | 197 ++++++++++++++++- .../agent/tests/desktop-tool-policy.test.ts | 198 +++++++++++++++++- 2 files changed, 391 insertions(+), 4 deletions(-) diff --git a/desktop/macos/agent/src/runtime/desktop-tool-policy.ts b/desktop/macos/agent/src/runtime/desktop-tool-policy.ts index eb2a5466c22..821ccb5f5ff 100644 --- a/desktop/macos/agent/src/runtime/desktop-tool-policy.ts +++ b/desktop/macos/agent/src/runtime/desktop-tool-policy.ts @@ -1,5 +1,8 @@ +import { basename } from "node:path"; + import { agentControlCapabilityManifest } from "./control-tool-manifest.js"; import { toolManifestEntry, type OmiToolManifestEntry } from "./omi-tool-manifest.js"; +import { utf8Excerpt } from "./tool-result-projector.js"; export type DesktopCoordinatorBundle = | "desktop.agent_control.read" @@ -87,6 +90,10 @@ const MEMORY_WRITE_TOOLS = new Set(["create_memory"]); const LEDGER_WRITE_TOOLS = new Set(["save_playbook", "create_standing_trigger", "close_fact"]); const SCREEN_IMAGE_TOOLS = new Set(["get_screenshot", "look_at_frame", "capture_screen"]); const SCREEN_SUMMARY_TOOLS = new Set(["semantic_search", "get_work_context"]); +// A live screenshot holds whatever is on screen at that moment, including +// windows the person keeps out of capture elsewhere, so each one is asked for +// on its own: the card never offers a session grant and no grant covers one. +const ALLOW_ONCE_ONLY_TOOLS = new Set(["capture_screen"]); // Coordinator policy classifies this as a production user-approved operation; // ChatToolExecutor independently enforces the current-turn consent at execution. const PERMISSION_REQUEST_TOOLS = new Set(["request_permission"]); @@ -236,13 +243,20 @@ function descriptorFromBundles(bundles: readonly DesktopCoordinatorBundle[]): De }; } +/// Bundles whose grants must name the exact resource they cover; an unscoped +/// or `*` row never authorizes them. +const RESOURCE_SCOPED_GRANT_BUNDLES: ReadonlySet = new Set([ + "desktop.messaging.send", + "desktop.automation.act", + "desktop.context.screenshot_image", +]); + function hasAllowGrant(request: DesktopToolPolicyRequest, bundle: DesktopCoordinatorBundle): boolean { const nowMs = request.nowMs ?? Date.now(); return (request.grants ?? []).some((grant) => { if (grant.effect !== "allow" || grant.bundle !== bundle || grant.expiresAtMs <= nowMs) return false; - if ((bundle === "desktop.messaging.send" || bundle === "desktop.automation.act") && !grant.resourceRef) { - return false; - } + if (RESOURCE_SCOPED_GRANT_BUNDLES.has(bundle) && !grant.resourceRef) return false; + if (ALLOW_ONCE_ONLY_TOOLS.has(request.toolName ?? request.operation ?? "")) return false; if (grant.operation && grant.operation !== request.operation) return false; if (grant.resourceRef && grant.resourceRef !== request.resourceRef) return false; return true; @@ -319,6 +333,183 @@ export const desktopToolPolicyInternals = { descriptorFromToolName, }; +// --- Per-invocation user approval ------------------------------------------- +// +// A `dispatch_required` decision for a sensitive device tool becomes an +// `approval` dispatch bound to one prepared invocation. The user resolves it +// through signed direct control; the model never chooses allow or deny. Only +// this module decides what the user is asked and which answers exist. + +export const DESKTOP_APPROVAL_POLICY = "default_user_approval" as const; + +/** + * How long a parked invocation waits for the user before it fails closed as + * denied. Matches the mobile device-tool transport's 180s: roughly how long a + * person plausibly takes to read a card and answer it. It must stay below the + * pi-mono `long` tool wait (10 min) minus the Swift executor timeout (120s), + * or a late approval could run an effect the model already reported as failed. + */ +export const DESKTOP_APPROVAL_TTL_MS = 180_000; + +export type DesktopApprovalOptionId = "allow_once" | "allow_session" | "deny"; +export type DesktopApprovalDecision = "allow" | "deny" | "expired" | "cancelled"; + +export interface DesktopApprovalOption { + id: DesktopApprovalOptionId; + effect: "allow" | "deny"; + scope: "run" | "session" | "request"; + /** For `allow_session`: plain words for what the grant would cover, so the card never over-promises. */ + covers?: string; +} + +export const DESKTOP_APPROVAL_ALLOW_ONCE_OPTION: DesktopApprovalOption = Object.freeze({ id: "allow_once", effect: "allow", scope: "run" }); +export const DESKTOP_APPROVAL_DENY_OPTION: DesktopApprovalOption = Object.freeze({ id: "deny", effect: "deny", scope: "request" }); + +export const DESKTOP_APPROVAL_DEFAULT_OPTION_ID: DesktopApprovalOptionId = "deny"; + +/** + * What an `allow_session` grant would actually cover, per tool. The grant is + * matched on the exact resource ref, so for a script it covers only that + * identical script, never "AppleScript in general". + */ +const APPROVAL_SESSION_GRANT_COVERS: Record = { + send_message: "any message or attachment to this recipient", + run_applescript: "only this exact script", + read_message_history: "this conversation", + list_message_chats: "your recent Messages conversations", + list_mail_messages: "your Mail inbox headers", +}; + +/** + * `allow_session` is the only answer that mints a grant, and a grant needs the + * exact resource it covers. Without one (a thread read with neither chat_id + * nor handle), and for a live screenshot, the card offers only allow once and + * deny. + */ +export function desktopApprovalOptions(toolName: string, resourceRef: string | null): readonly DesktopApprovalOption[] { + if (resourceRef === null || ALLOW_ONCE_ONLY_TOOLS.has(toolName)) { + return Object.freeze([DESKTOP_APPROVAL_ALLOW_ONCE_OPTION, DESKTOP_APPROVAL_DENY_OPTION]); + } + return Object.freeze([ + DESKTOP_APPROVAL_ALLOW_ONCE_OPTION, + { + id: "allow_session", + effect: "allow", + scope: "session", + covers: APPROVAL_SESSION_GRANT_COVERS[toolName] ?? "only this exact request", + }, + DESKTOP_APPROVAL_DENY_OPTION, + ]); +} + +export type DesktopApprovalPreviewValue = string | number | boolean; + +export interface DesktopToolApprovalRequest { + policy: typeof DESKTOP_APPROVAL_POLICY; + capability: DesktopCoordinatorBundle; + operation: string; + resourceRef: string | null; + title: string; + decisionPrompt: string; + /** Bounded, display-safe projection of the tool input; never the raw input. */ + preview: Record; + previewTruncated: boolean; + options: readonly DesktopApprovalOption[]; + defaultOptionId: DesktopApprovalOptionId; + reason: string; + requestedAtMs: number; + expiresAtMs: number; +} + +/** Fields each tool may show on its card. Anything else in the input stays out of the preview. */ +const APPROVAL_PREVIEW_FIELDS: Record = { + send_message: ["to", "text", "service", "file_path"], + run_applescript: ["script", "timeout_seconds"], + read_message_history: ["chat_id", "handle", "limit"], + list_message_chats: ["limit"], + list_mail_messages: ["limit"], + // Takes no input: the card's question says everything the capture does. + capture_screen: [], +}; +/** Paths are redacted to their final component: the card needs the file name, not the user's directory layout. */ +const APPROVAL_PREVIEW_PATH_FIELDS = new Set(["file_path"]); +/** The card shows message text and scripts verbatim, so the bound is generous; the input hash binds the exact content. */ +const APPROVAL_PREVIEW_FIELD_BYTES = 4_096; + +function approvalCopy(toolName: string, preview: Record): { title: string; decisionPrompt: string } { + switch (toolName) { + case "send_message": + return { title: "Send a message", decisionPrompt: `Send this message to ${preview.to ?? "the recipient"}?` }; + case "run_applescript": + return { title: "Run an AppleScript", decisionPrompt: "Run this AppleScript on your Mac?" }; + case "read_message_history": + return { + title: "Read a Messages conversation", + decisionPrompt: preview.handle !== undefined + ? `Read recent messages with ${preview.handle}?` + : preview.chat_id !== undefined + ? `Read recent messages in chat ${preview.chat_id}?` + : "Read recent messages from this conversation?", + }; + case "list_message_chats": + return { title: "List Messages conversations", decisionPrompt: "List your recent Messages conversations?" }; + case "list_mail_messages": + return { title: "List Mail messages", decisionPrompt: "List recent Mail messages (headers only, no bodies)?" }; + case "capture_screen": + return { title: "Take a screenshot", decisionPrompt: "Let Omi take a screenshot of your whole screen?" }; + default: + return { title: `Allow ${toolName}`, decisionPrompt: `Allow the agent to run ${toolName}?` }; + } +} + +export function buildDesktopToolApprovalRequest(input: { + toolName: string; + toolInput: Record; + policy: DesktopToolPolicyResult; + resourceRef: string | undefined; + nowMs: number; + ttlMs?: number; +}): DesktopToolApprovalRequest { + if (input.policy.decision !== "dispatch_required") { + throw new Error("Approval requests are built only for dispatch_required policy decisions"); + } + const capability = input.policy.requiredBundles.find(isSensitiveBundle) ?? input.policy.requiredBundles[0]; + if (!capability) throw new Error("Approval requests require at least one capability bundle"); + const ttlMs = input.ttlMs ?? DESKTOP_APPROVAL_TTL_MS; + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new Error("Approval TTL must be a positive integer"); + + const preview: Record = {}; + let previewTruncated = false; + for (const field of APPROVAL_PREVIEW_FIELDS[input.toolName] ?? []) { + const value = input.toolInput[field]; + if (typeof value === "number" || typeof value === "boolean") { + preview[field] = value; + } else if (typeof value === "string" && value.trim()) { + const shown = APPROVAL_PREVIEW_PATH_FIELDS.has(field) ? basename(value) : value; + const bounded = utf8Excerpt(shown, APPROVAL_PREVIEW_FIELD_BYTES); + if (bounded !== shown) previewTruncated = true; + preview[field] = bounded; + } + } + const copy = approvalCopy(input.toolName, preview); + const resourceRef = input.resourceRef ?? null; + return { + policy: DESKTOP_APPROVAL_POLICY, + capability, + operation: input.toolName, + resourceRef, + title: copy.title, + decisionPrompt: copy.decisionPrompt, + preview, + previewTruncated, + options: desktopApprovalOptions(input.toolName, resourceRef), + defaultOptionId: DESKTOP_APPROVAL_DEFAULT_OPTION_ID, + reason: input.policy.reason, + requestedAtMs: input.nowMs, + expiresAtMs: input.nowMs + ttlMs, + }; +} + export interface AcpPermissionOption { kind: string; optionId: string; diff --git a/desktop/macos/agent/tests/desktop-tool-policy.test.ts b/desktop/macos/agent/tests/desktop-tool-policy.test.ts index 031f28b7763..4b901225abf 100644 --- a/desktop/macos/agent/tests/desktop-tool-policy.test.ts +++ b/desktop/macos/agent/tests/desktop-tool-policy.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { evaluateDesktopToolPolicy } from "../src/runtime/desktop-tool-policy.js"; +import { + DESKTOP_APPROVAL_TTL_MS, + buildDesktopToolApprovalRequest, + evaluateDesktopToolPolicy, +} from "../src/runtime/desktop-tool-policy.js"; describe("desktop tool policy", () => { it("allows selected read-only local context tools", () => { @@ -288,6 +292,198 @@ describe("desktop tool policy", () => { }); }); +describe("desktop tool approval requests", () => { + const nowMs = 1_700_000_000_000; + + function dispatchRequired(toolName: string, resourceRef?: string) { + const policy = evaluateDesktopToolPolicy({ + toolName, + operation: toolName, + resourceRef, + selectedBundles: toolName === "send_message" + ? ["desktop.messaging.send"] + : toolName === "run_applescript" + ? ["desktop.automation.act"] + : toolName === "list_mail_messages" + ? ["desktop.mail.read"] + : toolName === "capture_screen" + ? ["desktop.context.screenshot_image"] + : ["desktop.messaging.read"], + nowMs, + }); + expect(policy.decision).toBe("dispatch_required"); + return policy; + } + + it("asks a yes/no question bound to the exact send with deny as the default", () => { + const request = buildDesktopToolApprovalRequest({ + toolName: "send_message", + toolInput: { to: "+15551234567", text: "Running 10 minutes late", service: "auto", file_path: "/Users/me/Documents/receipt.pdf" }, + policy: dispatchRequired("send_message", "+15551234567"), + resourceRef: "+15551234567", + nowMs, + }); + + expect(request).toMatchObject({ + policy: "default_user_approval", + capability: "desktop.messaging.send", + operation: "send_message", + resourceRef: "+15551234567", + defaultOptionId: "deny", + requestedAtMs: nowMs, + expiresAtMs: nowMs + DESKTOP_APPROVAL_TTL_MS, + previewTruncated: false, + }); + expect(request.options.map((option) => option.id)).toEqual(["allow_once", "allow_session", "deny"]); + expect(request.options.find((option) => option.id === "allow_session")).toEqual({ + id: "allow_session", + effect: "allow", + scope: "session", + covers: "any message or attachment to this recipient", + }); + expect(request.decisionPrompt).toContain("+15551234567"); + // The recipient and the exact text are what the user approves; the + // attachment path is reduced to its file name. + expect(request.preview).toEqual({ + to: "+15551234567", + text: "Running 10 minutes late", + service: "auto", + file_path: "receipt.pdf", + }); + }); + + it("offers a session grant only when there is an exact resource for it to cover", () => { + const unscopedRead = buildDesktopToolApprovalRequest({ + toolName: "read_message_history", + toolInput: { limit: 5 }, + policy: dispatchRequired("read_message_history"), + resourceRef: undefined, + nowMs, + }); + const script = "tell application \"Finder\" to activate"; + const scriptRun = buildDesktopToolApprovalRequest({ + toolName: "run_applescript", + toolInput: { script }, + policy: dispatchRequired("run_applescript", script), + resourceRef: script, + nowMs, + }); + + // Nothing to mint a grant against: allow once or deny. + expect(unscopedRead.options.map((option) => option.id)).toEqual(["allow_once", "deny"]); + // A script grant covers that script byte for byte, and the card says so. + expect(scriptRun.options.find((option) => option.id === "allow_session")?.covers).toBe("only this exact script"); + }); + + it("keeps fields outside the tool's allowlist out of the preview and bounds long values", () => { + const script = "tell application \"Finder\"\n".repeat(400); + const request = buildDesktopToolApprovalRequest({ + toolName: "run_applescript", + toolInput: { script, timeout_seconds: 10, secret_context: "never shown", nested: { token: "x" } }, + policy: dispatchRequired("run_applescript", script), + resourceRef: script, + nowMs, + }); + + expect(Object.keys(request.preview).sort()).toEqual(["script", "timeout_seconds"]); + expect(request.previewTruncated).toBe(true); + expect(Buffer.byteLength(String(request.preview.script), "utf8")).toBeLessThanOrEqual(4_096); + expect(String(request.preview.script).endsWith("…")).toBe(true); + expect(request.title).toBe("Run an AppleScript"); + }); + + it("names the thread for reads and never asks the model's question for it", () => { + const byHandle = buildDesktopToolApprovalRequest({ + toolName: "read_message_history", + toolInput: { handle: "alice@example.com", limit: 20 }, + policy: dispatchRequired("read_message_history", "alice@example.com"), + resourceRef: "alice@example.com", + nowMs, + }); + const mail = buildDesktopToolApprovalRequest({ + toolName: "list_mail_messages", + toolInput: { limit: 30 }, + policy: dispatchRequired("list_mail_messages", "mail:inbox"), + resourceRef: "mail:inbox", + nowMs, + }); + + expect(byHandle.capability).toBe("desktop.messaging.read"); + expect(byHandle.decisionPrompt).toContain("alice@example.com"); + expect(mail.capability).toBe("desktop.mail.read"); + expect(mail.resourceRef).toBe("mail:inbox"); + expect(mail.decisionPrompt).toContain("headers only"); + }); + + it("asks in plain words before a live screenshot, with nothing technical on the card", () => { + const request = buildDesktopToolApprovalRequest({ + toolName: "capture_screen", + toolInput: {}, + policy: dispatchRequired("capture_screen", "screen"), + resourceRef: "screen", + nowMs, + }); + + expect(request).toMatchObject({ + capability: "desktop.context.screenshot_image", + operation: "capture_screen", + resourceRef: "screen", + title: "Take a screenshot", + decisionPrompt: "Let Omi take a screenshot of your whole screen?", + preview: {}, + previewTruncated: false, + defaultOptionId: "deny", + }); + // Each screenshot is asked for on its own: no "Allow for This Chat". + expect(request.options.map((option) => option.id)).toEqual(["allow_once", "deny"]); + }); + + it("never lets a grant cover a live screenshot, and needs an exact resource for any screen-image grant", () => { + const grant = { + bundle: "desktop.context.screenshot_image" as const, + operation: "capture_screen", + resourceRef: "screen", + expiresAtMs: nowMs + 60_000, + effect: "allow" as const, + }; + const capture = evaluateDesktopToolPolicy({ + toolName: "capture_screen", + operation: "capture_screen", + resourceRef: "screen", + selectedBundles: ["desktop.context.screenshot_image"], + grants: [grant], + nowMs, + }); + expect(capture.decision).toBe("dispatch_required"); + + const storedFrame = (resourceRef: string | undefined) => evaluateDesktopToolPolicy({ + toolName: "get_screenshot", + operation: "get_screenshot", + resourceRef: "frame:42", + selectedBundles: ["desktop.context.screenshot_image"], + grants: [{ ...grant, operation: "get_screenshot", resourceRef }], + nowMs, + }); + expect(storedFrame(undefined).decision).toBe("dispatch_required"); + expect(storedFrame("frame:42").decision).toBe("allow"); + }); + + it("refuses to build a request for a decision that was not dispatch_required", () => { + const allowed = evaluateDesktopToolPolicy({ + toolName: "get_memories", + selectedBundles: ["desktop.context.local_read"], + }); + expect(allowed.decision).toBe("allow"); + expect(() => buildDesktopToolApprovalRequest({ + toolName: "get_memories", + toolInput: {}, + policy: allowed, + resourceRef: undefined, + nowMs, + })).toThrow("dispatch_required"); + }); +}); + describe("ACP permission policy", () => { it("preserves ACP high-trust auto approval preference order", async () => { const { resolveAcpPermission } = await import("../src/runtime/desktop-tool-policy.js"); From 10ef09e38aa7db28a757db61893ef01372645184 Mon Sep 17 00:00:00 2001 From: Sauransh Bhardwaj Date: Thu, 8 Oct 2026 02:24:52 -0400 Subject: [PATCH 2/5] feat(desktop): park sensitive tool calls in waiting_approval and emit approval.requested send_message, run_applescript, read_message_history, list_message_chats and list_mail_messages used to fail with approval_required whenever no scoped grant existed, and nothing in the app could create one. capture_screen did not fail at all: it took and shared a full-screen screenshot with no approval, although its own description promised one, so it now parks the same way under the stable resource "screen". A screenshot holds whatever is on screen, including windows the person keeps out of capture elsewhere, so each one is allowed once: its card offers no session grant, no grant row covers a capture, and the kernel refuses to mint a grant the card did not offer. The realtime voice screenshot tool is no longer advertised to pi-mono, where it sat ungated in every typed chat, pill and workstream run; realtime voice runs still get it through their surface projection. The capability broker now prepares the ledger row and inserts one approval dispatch bound to that exact invocation (id, input hash, daemon boot epoch) in the same transaction, the kernel moves the run and attempt to waiting_approval and appends run.waiting_approval plus approval.requested, and the broker refuses to dispatch the invocation until the dispatch is resolved. resolve_desktop_dispatch gains the two user answers: decision allow without a grant admits exactly this invocation once and mints nothing; allow with a grant whose runId is null mints a session-scoped grant through the existing insertGrant path, capped at 24 hours. Deny, cancellation, expiry, run termination and owner change fail the prepared invocation closed as failed, never outcome_unknown, each with its own approval.resolved, and the run resumes so the model receives approval_denied. A new kernel operation, endChatApprovalScope, ends what a chat granted when the person clears it: the main chat keeps one kernel session across clears, so it closes every approval still parked in that session and revokes every grant the person minted for it, with one grants.revoked audit event. Startup reconciliation expires any pending bound approval and records approval.resolved, so a restart never replays a parked call. Every relay-callable tool is now classified on purpose (named in a policy set or declared unbundled, never by the read-only default), and a test holds the approval set to every relay-callable tool in a sensitive bundle, with each deliberate exception named and justified, so a new tool cannot ship unclassified or ungated by omission. Grants for desktop.context.screenshot_image must name their exact resource. The gated tools are held to their manifest schema at the kernel boundary and the resource a grant covers is derived only from the fields the tool acts on, so a model-supplied resource_ref can no longer become the grant pattern. A dispatch created through create_desktop_dispatch has its invocation payload key stripped and can never pose as a parked call. Authority is revalidated before a resolution is admitted and that check runs before the resolving transaction, so a revocation it triggers commits on its own; a rolled-back decision restores the broker's memory. The model still cannot resolve its own dispatch: the relay path keeps returning policy_denied without trustedUserControl. Parking sits behind a kernel switch, desktopToolApprovalsEnabled, that defaults to off and is not reachable from the daemon in this change, so runtime behavior is unchanged until a client that renders approval cards turns it on. The Swift card PR flips the default and deletes the switch. --- .../Generated/GeneratedToolExecutors.swift | 4 +- .../agent/src/runtime/desktop-tool-policy.ts | 42 +- .../agent/src/runtime/kernel-coordinator.ts | 423 +++++++++-- .../macos/agent/src/runtime/kernel-core.ts | 94 ++- .../macos/agent/src/runtime/kernel-types.ts | 9 + .../agent/src/runtime/omi-tool-manifest.ts | 12 +- .../agent/src/runtime/run-tool-capability.ts | 443 ++++++++++- .../macos/agent/src/runtime/sqlite-store.ts | 67 ++ desktop/macos/agent/src/runtime/types.ts | 2 + .../macos/agent/tests/control-tools.test.ts | 702 ++++++++++++++++++ .../agent/tests/fixtures/tool-manifest.json | 6 +- .../agent/tests/omi-tool-manifest.test.ts | 9 +- .../agent/tests/run-tool-capability.test.ts | 645 ++++++++++++++++ .../macos/agent/tests/sqlite-store.test.ts | 70 ++ .../20261008-device-tool-approval-kernel.json | 3 + 15 files changed, 2455 insertions(+), 76 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20261008-device-tool-approval-kernel.json diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index d6ac14fab49..2995fd53ca7 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -62,8 +62,8 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 - static let manifestDigest = "sha256:4091ca56341e6b1ad3383255cae1299ad2c283052826bf2c19b70447cdc3200e" - static let chatFirstManifestDigest = "sha256:fe76965fa939c00fc301084866afa42c96459d648cf72cace5b5cbde8eaacc4d" + static let manifestDigest = "sha256:971efde6c3c6543f006d3d22fa279c5d19c40af6c5b6ae88ba6ff431295ed754" + static let chatFirstManifestDigest = "sha256:5806dd22b1ca32df6ac0fdb1e209f9ff23742a1ffe1e27585e715cf47860e957" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/agent/src/runtime/desktop-tool-policy.ts b/desktop/macos/agent/src/runtime/desktop-tool-policy.ts index 821ccb5f5ff..2ad3feba026 100644 --- a/desktop/macos/agent/src/runtime/desktop-tool-policy.ts +++ b/desktop/macos/agent/src/runtime/desktop-tool-policy.ts @@ -74,6 +74,7 @@ export interface DesktopToolPolicyResult { const EXTERNAL_SEND_TOOLS = new Set(["fill_cloud_connector_form"]); const TASK_WRITE_TOOLS = new Set([ + "create_canonical_goal", "complete_task", "delete_task", "create_action_item", @@ -88,8 +89,14 @@ const MEMORY_WRITE_TOOLS = new Set(["create_memory"]); // close_fact) mutate the same backend memory/knowledge store as create_memory, // so they share its bundle rather than inventing a new one. const LEDGER_WRITE_TOOLS = new Set(["save_playbook", "create_standing_trigger", "close_fact"]); -const SCREEN_IMAGE_TOOLS = new Set(["get_screenshot", "look_at_frame", "capture_screen"]); -const SCREEN_SUMMARY_TOOLS = new Set(["semantic_search", "get_work_context"]); +// `screenshot` is the realtime voice capture; it is offered only to realtime +// voice runs, never to a chat relay. +const SCREEN_IMAGE_TOOLS = new Set(["get_screenshot", "look_at_frame", "capture_screen", "screenshot"]); +// show_rewind_evidence returns the model only the stored frame's title, app +// and OCR excerpt (the same text a screen-history search already returned for +// that screenshot_id); the pixels go to the person's own Chat turn as +// evidence, behind the screenshot-sharing setting, and never to the model. +const SCREEN_SUMMARY_TOOLS = new Set(["semantic_search", "get_work_context", "show_rewind_evidence"]); // A live screenshot holds whatever is on screen at that moment, including // windows the person keeps out of capture elsewhere, so each one is asked for // on its own: the card never offers a session grant and no grant covers one. @@ -126,7 +133,23 @@ const LOCAL_READ_TOOLS = new Set([ "read_playbook", "search_historical_facts", "get_entity_timeline_tool", + "read_conversation_evidence", + "search_conversation_evidence", + "search_chat_history", + "get_canonical_goals", + "scan_files", ]); +/** + * Relay tools declared to need no capability bundle: they read or touch none + * of the person's local data. Declaring them keeps every relay-callable tool + * explicitly classified (a test enforces it); an undeclared tool no longer + * slides into `local_read` by default unnoticed. + */ +const UNBUNDLED_TOOLS: Readonly> = { + web_search: "a public web search; only the query leaves the Mac", + render_chat_blocks: "renders the model's own reply as blocks in the current turn", + ask_followup: "shows the person an onboarding question with quick replies", +}; function isSqlWrite(sql: string): boolean { const stripped = sql @@ -178,6 +201,19 @@ function bundlesForOmiTool(tool: OmiToolManifestEntry): DesktopCoordinatorBundle return [...bundles]; } +/// Whether a tool is classified on purpose: named in one of the sets above, +/// a runtime-control tool with declared bundles, or declared unbundled. The +/// read-only fallback in `bundlesForOmiTool` does not count. +function isExplicitlyClassified(toolName: string): boolean { + if (Object.hasOwn(UNBUNDLED_TOOLS, toolName)) return true; + if ((controlDescriptor(toolName)?.bundles.length ?? 0) > 0) return true; + return [ + LOCAL_READ_TOOLS, SCREEN_SUMMARY_TOOLS, SCREEN_IMAGE_TOOLS, TASK_WRITE_TOOLS, MEMORY_WRITE_TOOLS, + LEDGER_WRITE_TOOLS, AUTOMATION_READ_TOOLS, CONTACTS_READ_TOOLS, MESSAGING_READ_TOOLS, MAIL_READ_TOOLS, + MESSAGING_SEND_TOOLS, AUTOMATION_ACT_TOOLS, PERMISSION_REQUEST_TOOLS, EXTERNAL_SEND_TOOLS, + ].some((set) => set.has(toolName)); +} + /// Bundles whose data or effects are sensitive enough that the request is /// classified high-risk and can never resolve without a dispatch or grant. const SENSITIVE_BUNDLES: readonly DesktopCoordinatorBundle[] = [ @@ -331,6 +367,8 @@ export function evaluateDesktopToolPolicy(request: DesktopToolPolicyRequest): De export const desktopToolPolicyInternals = { isSqlWrite, descriptorFromToolName, + isSensitiveBundle, + isExplicitlyClassified, }; // --- Per-invocation user approval ------------------------------------------- diff --git a/desktop/macos/agent/src/runtime/kernel-coordinator.ts b/desktop/macos/agent/src/runtime/kernel-coordinator.ts index 8c2259b2445..975aca9b675 100644 --- a/desktop/macos/agent/src/runtime/kernel-coordinator.ts +++ b/desktop/macos/agent/src/runtime/kernel-coordinator.ts @@ -20,6 +20,7 @@ import type { AdapterBinding, AgentArtifact, AgentDelegation, + AgentEvent, AgentRun, AgentSession, AgentStore, @@ -46,6 +47,14 @@ import { type DesktopIntentTarget, } from "./desktop-intent-router.js"; import { OmiArtifactStorage } from "./artifact-storage.js"; +import { DESKTOP_APPROVAL_POLICY, type DesktopApprovalDecision } from "./desktop-tool-policy.js"; +import { + toolApprovalInvocationBinding, + toolApprovalOffersSessionGrant, + type ReleasedToolApproval, + type RunToolAuthorizationOutcome, + type ToolApprovalDenialCode, +} from "./run-tool-capability.js"; import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { @@ -156,6 +165,16 @@ import { type WorkstreamSessionInput, } from "./workstream-continuity.js"; +/** + * Longest grant a user answer may mint through a dispatch. "Allow for this + * chat" is meant in hours, not forever; a longer request is a configuration + * mistake, never a durable broad grant (control-plane spec section 17). + */ +const MAX_DESKTOP_DISPATCH_GRANT_TTL_MS = 24 * 60 * 60_000; + +/** The only payload key a model-created dispatch may never carry: it would impersonate a parked tool invocation. */ +const TOOL_APPROVAL_BINDING_PAYLOAD_KEY = "invocation"; + export class AgentRuntimeKernel extends KernelSessions { private readonly desktopIntentRouter = new DesktopIntentRouter(); @@ -496,62 +515,368 @@ export class AgentRuntimeKernel extends KernelSessions { } } + /** + * Dispatches created through the control tool are ordinary decision items. + * Only the capability broker binds a dispatch to a prepared tool invocation, + * so the binding key is stripped here: a forged one would otherwise be + * treated as a tool approval that no live invocation can ever resolve. + */ createDesktopDispatch(input: NewDesktopCoordinatorDispatch): DesktopCoordinatorDispatch { + const payload = parseJsonObject(input.payloadJson); + if (TOOL_APPROVAL_BINDING_PAYLOAD_KEY in payload) { + const { [TOOL_APPROVAL_BINDING_PAYLOAD_KEY]: _binding, ...rest } = payload; + return this.store.insertDesktopDispatch({ ...input, payloadJson: JSON.stringify(rest) }); + } return this.store.insertDesktopDispatch(input); } - resolveDesktopDispatch(dispatchId: string, input: ResolveDesktopDispatchInput): ResolveDesktopDispatchResult { + /** + * Interim gate, see `AgentRuntimeKernelOptions.desktopToolApprovalsEnabled`. + * The relay turns it on when a connected client declares it can render the + * approval card. + */ + setDesktopToolApprovalsEnabled(enabled: boolean): void { + this.desktopToolApprovalsEnabled = enabled; + } + + /** + * Relay authorization for the stdio tool lane. A sensitive device tool + * without a covering grant is parked: its `prepared` ledger row, the + * approval dispatch, the run/attempt `waiting_approval` transition, and the + * `run.waiting_approval` + `approval.requested` events commit together. + * With the gate off, this is exactly `authorizeRelayedRunToolInvocation`. + */ + authorizeRelayedRunToolInvocationOrRequestApproval(input: { + capabilityRef: string; + invocationId: string; + toolName: string; + toolInput: Record; + activeOwnerId: string; + }): RunToolAuthorizationOutcome & { event?: AgentEvent } { + if (!this.desktopToolApprovalsEnabled) { + return { kind: "authorized", invocation: this.toolCapabilities.authorizeRelayInvocation(input) }; + } return this.withTransaction(() => { - const dispatch = this.store.resolveDesktopDispatch(dispatchId, input); - let grant: AgentGrant | null = null; - if (input.status === "resolved" && input.grant && input.grant.effect === "allow") { - const resolution = parseJsonObject(input.resolutionJson); - if (dispatch.kind !== "approval") { - throw new Error("Only approval dispatches can mint grants"); - } - if (resolution.decision !== "allow") { - throw new Error("Resolved dispatch grants require an allow resolution"); - } - if (!dispatch.capability || input.grant.capability !== dispatch.capability) { - throw new Error("Resolved dispatch grant capability must match the approval request"); - } - if (!dispatch.operation || input.grant.operation !== dispatch.operation) { - throw new Error("Resolved dispatch grant operation must match the approval request"); - } - if (!dispatch.resourceRef || input.grant.resourcePattern !== dispatch.resourceRef) { - throw new Error("Resolved dispatch grant resource must match the approval request"); - } - if (!Number.isFinite(input.grant.expiresAtMs)) { - throw new Error("Resolved dispatch grants require a finite expiry"); + const outcome = this.toolCapabilities.authorizeRelayInvocationOrRequestApproval(input); + if (outcome.kind === "authorized") return outcome; + const { invocation, dispatch, request } = outcome; + const now = dispatch.createdAtMs; + this.updateRun(invocation.runId, { status: "waiting_approval", updatedAtMs: now }); + this.updateAttempt(invocation.attemptId, { status: "waiting_approval", updatedAtMs: now }); + this.appendEvent({ + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + type: "run.waiting_approval", + payload: { runId: invocation.runId, attemptId: invocation.attemptId, approvalId: dispatch.dispatchId }, + }); + const event = this.appendEvent({ + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + type: "approval.requested", + payload: { + approvalId: dispatch.dispatchId, + dispatchId: dispatch.dispatchId, + policy: request.policy, + adapterId: invocation.adapterId, + surfaceKind: invocation.surfaceKind, + invocationId: invocation.invocationId, + toolName: invocation.canonicalToolName, + capability: request.capability, + operation: request.operation, + resourceRef: request.resourceRef, + resourcePattern: request.resourceRef, + inputHash: invocation.inputHash, + effectClass: invocation.effectClass, + title: request.title, + decisionPrompt: request.decisionPrompt, + preview: request.preview, + previewTruncated: request.previewTruncated, + reason: request.reason, + options: request.options, + defaultOptionId: request.defaultOptionId, + requestedAtMs: request.requestedAtMs, + expiresAtMs: request.expiresAtMs, + }, + }); + return { ...outcome, event }; + }); + } + + resolveDesktopDispatch(dispatchId: string, input: ResolveDesktopDispatchInput): ResolveDesktopDispatchResult { + const resolution = parseJsonObject(input.resolutionJson); + // Decide the tool-approval question before any row changes. An authority + // failure here may revoke the capability, and that revocation (which + // cancels the dispatch row) must commit on its own rather than roll back + // together with the resolution it refused. + const pendingRow = this.store.getOptionalRow( + "SELECT * FROM desktop_dispatches WHERE dispatch_id = ? AND status = 'pending'", + [dispatchId], + ); + const pendingBinding = pendingRow ? toolApprovalInvocationBinding(desktopDispatchFromRow(pendingRow)) : null; + let decision: DesktopApprovalDecision | null = null; + if (pendingBinding) { + // An invocation-bound approval is a yes/no about one physical effect; + // a free-form resolution cannot leave the parked invocation undecided. + decision = input.status === "cancelled" + ? "deny" + : resolution.decision === "allow" || resolution.decision === "deny" + ? resolution.decision + : null; + if (!decision) { + throw new Error("Tool approval resolution requires resolution.decision of allow or deny"); + } + if (decision === "allow") { + this.toolCapabilities.assertApprovalAuthority({ dispatchId, binding: pendingBinding, activeOwnerId: input.ownerId }); + } + } + let released: ReleasedToolApproval | null = null; + try { + return this.withTransaction(() => { + const dispatch = this.store.resolveDesktopDispatch(dispatchId, input); + const binding = toolApprovalInvocationBinding(dispatch); + let grant: AgentGrant | null = null; + if (input.status === "resolved" && input.grant && input.grant.effect === "allow") { + if (dispatch.kind !== "approval") { + throw new Error("Only approval dispatches can mint grants"); + } + if (resolution.decision !== "allow") { + throw new Error("Resolved dispatch grants require an allow resolution"); + } + if (binding && !toolApprovalOffersSessionGrant(dispatch)) { + throw new Error("This approval offers no session grant; it can only be allowed once"); + } + if (!dispatch.capability || input.grant.capability !== dispatch.capability) { + throw new Error("Resolved dispatch grant capability must match the approval request"); + } + if (!dispatch.operation || input.grant.operation !== dispatch.operation) { + throw new Error("Resolved dispatch grant operation must match the approval request"); + } + if (!dispatch.resourceRef || input.grant.resourcePattern !== dispatch.resourceRef) { + throw new Error("Resolved dispatch grant resource must match the approval request"); + } + const grantExpiresAtMs = input.grant.expiresAtMs; + if (typeof grantExpiresAtMs !== "number" || !Number.isFinite(grantExpiresAtMs)) { + throw new Error("Resolved dispatch grants require a finite expiry"); + } + const resolvedAtMs = dispatch.resolvedAtMs ?? Date.now(); + if (grantExpiresAtMs <= resolvedAtMs) { + throw new Error("Resolved dispatch grants must expire in the future"); + } + if (grantExpiresAtMs - resolvedAtMs > MAX_DESKTOP_DISPATCH_GRANT_TTL_MS) { + throw new Error("Resolved dispatch grants may last at most 24 hours"); + } + const sessionId = input.grant.sessionId ?? dispatch.sourceSessionId; + if (!sessionId) { + throw new Error("Resolved dispatch grants require a session scope"); + } + this.assertSessionOwner(this.readSession(sessionId), input.ownerId); + grant = this.store.insertGrant({ + ...input.grant, + sessionId, + // An explicit null scopes the grant to the whole session ("allow for + // this chat"); an omitted runId keeps the historical run-scoped default. + runId: input.grant.runId !== undefined ? input.grant.runId : dispatch.sourceRunId, + source: input.grant.source ?? "user", + }); } - const sessionId = input.grant.sessionId ?? dispatch.sourceSessionId; - if (!sessionId) { - throw new Error("Resolved dispatch grants require a session scope"); + let selectedOptionId: string | null = null; + if (binding && decision) { + if (decision === "allow") { + released = this.toolCapabilities.approveInvocation({ dispatchId, binding, activeOwnerId: input.ownerId }).released; + selectedOptionId = grant ? "allow_session" : "allow_once"; + } else { + released = this.toolCapabilities.denyInvocation({ dispatchId, binding, code: "approval_denied" }).released; + selectedOptionId = "deny"; + } } - this.assertSessionOwner(this.readSession(sessionId), input.ownerId); - grant = this.store.insertGrant({ - ...input.grant, - sessionId, - runId: input.grant.runId ?? dispatch.sourceRunId, - source: input.grant.source ?? "user", - }); - } - const event = dispatch.sourceSessionId - ? this.appendEvent({ - sessionId: dispatch.sourceSessionId, - runId: dispatch.sourceRunId, - attemptId: dispatch.sourceAttemptId, - type: "approval.resolved", - payload: { - dispatchId: dispatch.dispatchId, - status: dispatch.status, - resolvedBy: dispatch.resolvedBy, - resolution: parseJsonObject(dispatch.resolutionJson), - grantId: grant?.grantId ?? null, - }, - }) - : null; - return { dispatch, grant, event }; + const event = dispatch.sourceSessionId + ? this.appendEvent({ + sessionId: dispatch.sourceSessionId, + runId: dispatch.sourceRunId, + attemptId: dispatch.sourceAttemptId, + type: "approval.resolved", + payload: { + dispatchId: dispatch.dispatchId, + status: dispatch.status, + resolvedBy: dispatch.resolvedBy, + resolution: parseJsonObject(dispatch.resolutionJson), + grantId: grant?.grantId ?? null, + ...(binding + ? { + approvalId: dispatch.dispatchId, + policy: DESKTOP_APPROVAL_POLICY, + adapterId: binding.adapterId, + invocationId: binding.invocationId, + toolName: binding.toolName, + decision, + selectedOptionId, + automatic: false, + resolvedAtMs: dispatch.resolvedAtMs, + } + : {}), + }, + }) + : null; + // The decision is recorded first; the run resuming is its consequence. + if (binding && decision) this.resumeRunAfterApproval(dispatch); + return { dispatch, grant, event }; + }); + } catch (error) { + // The rows rolled back; the broker's memory must say "still parked" too. + if (released) this.toolCapabilities.restorePendingApproval(released); + throw error; + } + } + + /** + * Close a parked approval without a user decision: the wait expired, or the + * relay client that was waiting for the result went away. The invocation + * fails closed and the run resumes so the model receives `approval_denied`. + */ + terminateDesktopToolApproval(input: { + dispatchId: string; + status: "expired" | "cancelled"; + reason: string; + nowMs?: number; + }): ResolveDesktopDispatchResult { + let released: ReleasedToolApproval | null = null; + try { + return this.withTransaction(() => { + const row = this.store.getOptionalRow( + "SELECT * FROM desktop_dispatches WHERE dispatch_id = ? AND status = 'pending'", + [input.dispatchId], + ); + if (!row) throw new Error(`Desktop dispatch ${input.dispatchId} is not pending`); + const pending = desktopDispatchFromRow(row); + const binding = toolApprovalInvocationBinding(pending); + if (!binding) throw new Error(`Desktop dispatch ${input.dispatchId} is not bound to a tool invocation`); + const now = input.nowMs ?? Date.now(); + const decision: DesktopApprovalDecision = input.status; + const resolutionJson = JSON.stringify({ decision, reason: input.reason }); + const changed = this.store.execute( + `UPDATE desktop_dispatches + SET status = ?, resolved_at_ms = ?, resolved_by = 'system', resolution_json = ? + WHERE dispatch_id = ? AND status = 'pending'`, + [input.status, now, resolutionJson, input.dispatchId], + ); + if (changed !== 1) throw new Error(`Desktop dispatch ${input.dispatchId} is not pending`); + const code: ToolApprovalDenialCode = input.status === "expired" ? "approval_expired" : "approval_cancelled"; + released = this.toolCapabilities.denyInvocation({ dispatchId: input.dispatchId, binding, code }).released; + const dispatch = desktopDispatchFromRow( + this.store.getRow("SELECT * FROM desktop_dispatches WHERE dispatch_id = ?", [input.dispatchId]), + ); + const event = dispatch.sourceSessionId + ? this.appendEvent({ + sessionId: dispatch.sourceSessionId, + runId: dispatch.sourceRunId, + attemptId: dispatch.sourceAttemptId, + type: "approval.resolved", + payload: { + approvalId: dispatch.dispatchId, + dispatchId: dispatch.dispatchId, + policy: DESKTOP_APPROVAL_POLICY, + adapterId: binding.adapterId, + invocationId: binding.invocationId, + toolName: binding.toolName, + status: dispatch.status, + decision, + selectedOptionId: null, + grantId: null, + automatic: true, + resolvedBy: dispatch.resolvedBy, + resolvedAtMs: dispatch.resolvedAtMs, + resolution: parseJsonObject(dispatch.resolutionJson), + }, + }) + : null; + this.resumeRunAfterApproval(dispatch); + return { dispatch, grant: null, event }; + }); + } catch (error) { + if (released) this.toolCapabilities.restorePendingApproval(released); + throw error; + } + } + + /** + * The person cleared this chat, so an "Allow for This Chat" grant must not + * outlive it: the main chat keeps one kernel session across clears, and a + * session-scoped grant would otherwise cover the new conversation with no + * card. Every approval still parked in the session is closed first (the + * invocation fails closed and never runs), then every grant the person + * minted for the session is revoked, with one `grants.revoked` audit event. + * Idempotent: with nothing to close or revoke it writes nothing. + */ + endChatApprovalScope(input: { ownerId: string; sessionId: string; reason: string; nowMs?: number }): { + closedApprovalIds: string[]; + revokedGrantIds: string[]; + } { + this.assertSessionOwner(this.readSession(input.sessionId), input.ownerId); + const closedApprovalIds: string[] = []; + const pending = this.store.allRows( + `SELECT * FROM desktop_dispatches + WHERE kind = 'approval' AND status = 'pending' AND source_session_id = ? + ORDER BY created_at_ms`, + [input.sessionId], + ); + for (const row of pending) { + const dispatch = desktopDispatchFromRow(row); + if (!toolApprovalInvocationBinding(dispatch)) continue; + this.terminateDesktopToolApproval({ + dispatchId: dispatch.dispatchId, + status: "cancelled", + reason: input.reason, + nowMs: input.nowMs, + }); + closedApprovalIds.push(dispatch.dispatchId); + } + const revokedGrantIds = this.withTransaction(() => { + const now = input.nowMs ?? Date.now(); + const grantIds = this.store.allRows( + `SELECT grant_id FROM grants + WHERE session_id = ? AND source = 'user' AND revoked_at_ms IS NULL + ORDER BY created_at_ms`, + [input.sessionId], + ).map((row) => String(row.grant_id)); + if (grantIds.length === 0) return grantIds; + this.store.execute( + `UPDATE grants SET revoked_at_ms = ? + WHERE session_id = ? AND source = 'user' AND revoked_at_ms IS NULL`, + [now, input.sessionId], + ); + this.appendEvent({ + sessionId: input.sessionId, + type: "grants.revoked", + payload: { grantIds, source: "user", reason: input.reason, revokedAtMs: now }, + }); + return grantIds; }); + return { closedApprovalIds, revokedGrantIds }; + } + + /** The run was parked only for approvals; once none remain it is running again. */ + private resumeRunAfterApproval(dispatch: DesktopCoordinatorDispatch): void { + if (!dispatch.sourceRunId || !dispatch.sourceAttemptId) return; + if (this.toolCapabilities.hasPendingApprovals(dispatch.sourceRunId)) return; + const run = this.store.getOptionalRow("SELECT status FROM runs WHERE run_id = ?", [dispatch.sourceRunId]); + const attempt = this.store.getOptionalRow( + "SELECT status FROM run_attempts WHERE attempt_id = ?", + [dispatch.sourceAttemptId], + ); + if (run?.status !== "waiting_approval" || attempt?.status !== "waiting_approval") return; + const now = dispatch.resolvedAtMs ?? Date.now(); + this.updateRun(dispatch.sourceRunId, { status: "running", updatedAtMs: now }); + this.updateAttempt(dispatch.sourceAttemptId, { status: "running", updatedAtMs: now }); + if (dispatch.sourceSessionId) { + this.appendEvent({ + sessionId: dispatch.sourceSessionId, + runId: dispatch.sourceRunId, + attemptId: dispatch.sourceAttemptId, + type: "run.running", + payload: { runId: dispatch.sourceRunId, attemptId: dispatch.sourceAttemptId, resumedAfterApprovalId: dispatch.dispatchId }, + }); + } } } diff --git a/desktop/macos/agent/src/runtime/kernel-core.ts b/desktop/macos/agent/src/runtime/kernel-core.ts index 1c7a1818606..23d01f0308a 100644 --- a/desktop/macos/agent/src/runtime/kernel-core.ts +++ b/desktop/macos/agent/src/runtime/kernel-core.ts @@ -185,6 +185,7 @@ function runtimeAdapterMetadata(input: ExecuteAgentRunInput, session: AgentSessi import { RunToolCapabilityBroker, type AuthorizedRunToolInvocation, + type CancelledToolApproval, type RunToolExecutionLease, type RunToolCapabilityRevocationReason, } from "./run-tool-capability.js"; @@ -236,8 +237,17 @@ export class KernelCore { * `render_chat_blocks` at all. */ protected readonly chatFirstCapabilities = new Map(); + protected desktopToolApprovalsEnabled: boolean; private transactionDepth = 0; private pendingSubscriberEvents: AgentEvent[] = []; + /** + * `approval.resolved` rows the broker's cancellation callback wrote while a + * kernel operation was still in progress. They are notified after the event + * that caused them, so a card always ends after the thing that ended it. + */ + private derivedApprovalEvents: AgentEvent[] = []; + /** True while `appendEvent` is letting the broker react, so derived events publish after the cause. */ + private deferDerivedApprovalEvents = false; protected chatFirstCapability( sessionId: string, @@ -254,9 +264,11 @@ export class KernelCore { this.runtimeNodeId = options.runtimeNodeId ?? "desktop-local"; this.artifactStorage = options.artifactStorage; this.recoverRunInput = options.recoverRunInput; + this.desktopToolApprovalsEnabled = options.desktopToolApprovalsEnabled === true; this.toolCapabilities = new RunToolCapabilityBroker({ store: this.store, onRejected: options.onToolCapabilityRejected, + onApprovalsCancelled: (cancelled) => this.recordCancelledToolApprovals(cancelled), profileForSession: options.toolCapabilityProfileForSession ?? ((sessionId) => { const profile = readSessionExecutionProfile(this.store, sessionId); return { @@ -523,6 +535,76 @@ export class KernelCore { return this.toolCapabilities.revokeForOwner(ownerId, reason); } + /** + * The broker closed parked approvals because their authority ended. Record + * one `approval.resolved` per card and, when the run itself is still alive + * and only waiting on approvals, let it run again so the model receives the + * failed tool result instead of a run stuck in `waiting_approval`. + */ + private recordCancelledToolApprovals(cancelled: readonly CancelledToolApproval[]): void { + const now = Date.now(); + for (const approval of cancelled) { + this.derivedApprovalEvents.push(this.store.appendEvent({ + sessionId: approval.sessionId, + runId: approval.runId, + attemptId: approval.attemptId, + type: "approval.resolved", + retentionClass: "core", + visibility: "ui", + payloadJson: JSON.stringify({ + approvalId: approval.dispatchId, + dispatchId: approval.dispatchId, + invocationId: approval.invocationId, + status: "cancelled", + decision: "cancelled", + selectedOptionId: null, + grantId: null, + automatic: true, + resolvedBy: "system", + resolvedAtMs: now, + resolution: { decision: "cancelled", reason: `run_tool_${approval.reason}` }, + }), + })); + if (this.toolCapabilities.hasPendingApprovals(approval.runId)) continue; + const run = this.store.getOptionalRow("SELECT status FROM runs WHERE run_id = ?", [approval.runId]); + const attempt = this.store.getOptionalRow( + "SELECT status FROM run_attempts WHERE attempt_id = ?", + [approval.attemptId], + ); + if (run?.status !== "waiting_approval" || attempt?.status !== "waiting_approval") continue; + this.updateRun(approval.runId, { status: "running", updatedAtMs: now }); + this.updateAttempt(approval.attemptId, { status: "running", updatedAtMs: now }); + this.derivedApprovalEvents.push(this.store.appendEvent({ + sessionId: approval.sessionId, + runId: approval.runId, + attemptId: approval.attemptId, + type: "run.running", + retentionClass: "core", + visibility: "ui", + payloadJson: JSON.stringify({ + runId: approval.runId, + attemptId: approval.attemptId, + resumedAfterApprovalId: approval.dispatchId, + }), + })); + } + // Inside appendEvent the cause publishes first; anywhere else (owner + // revocation, an authority check that revoked) publish right away. + if (!this.deferDerivedApprovalEvents) this.publishDerivedApprovalEvents(); + } + + /** Hand queued derived events to subscribers, or to the open transaction's queue. */ + private publishDerivedApprovalEvents(): void { + if (this.derivedApprovalEvents.length === 0) return; + const events = this.derivedApprovalEvents; + this.derivedApprovalEvents = []; + if (this.transactionDepth > 0) { + this.pendingSubscriberEvents.push(...events); + return; + } + for (const event of events) this.notifySubscribers(event); + } + beginExternalSurfaceRun(input: BeginExternalSurfaceRunInput): BeginExternalSurfaceRunResult { const ownerId = requiredExternalIdentity(input.ownerId, "ownerId"); const sessionId = requiredExternalIdentity(input.sessionId, "sessionId"); @@ -2708,12 +2790,22 @@ export class KernelCore { visibility: input.visibility ?? "ui", payloadJson: JSON.stringify(input.payload ?? {}), }); - this.toolCapabilities.handleKernelEvent(event); + // A terminal run/attempt event closes any approval still parked on that + // authority; the broker reports each closure and the kernel records it as + // its own `approval.resolved`, published after the event that ended it. + this.deferDerivedApprovalEvents = true; + try { + this.toolCapabilities.handleKernelEvent(event); + } finally { + this.deferDerivedApprovalEvents = false; + } if (this.transactionDepth > 0) { this.pendingSubscriberEvents.push(event); + this.publishDerivedApprovalEvents(); return event; } this.notifySubscribers(event); + this.publishDerivedApprovalEvents(); return event; } diff --git a/desktop/macos/agent/src/runtime/kernel-types.ts b/desktop/macos/agent/src/runtime/kernel-types.ts index 6a75f65da89..471da32d383 100644 --- a/desktop/macos/agent/src/runtime/kernel-types.ts +++ b/desktop/macos/agent/src/runtime/kernel-types.ts @@ -486,6 +486,15 @@ export interface AgentRuntimeKernelOptions { artifactStorage?: OmiArtifactStorage; recoverRunInput?: KernelRunRecoveryPolicy; onToolCapabilityRejected?: (code: RunToolCapabilityRejectCode) => void; + /** + * Temporary interim gate for the desktop tool approval flow. Until the + * Swift approval card ships, a connected client declares that it can render + * `approval_requested`; with no such client the relay keeps today's + * immediate `approval_required` rejection instead of parking a call nobody + * can answer. Default off. The Swift card PR flips the default and deletes + * this switch. + */ + desktopToolApprovalsEnabled?: boolean; /** * Canonical execution-profile repository. Production uses the immutable * SQLite profile reader; tests with synthetic adapters may inject an diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index 0fca6838f97..9a32d4a3325 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -2236,13 +2236,11 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ executor: { kind: "swiftTool", executorName: "realtimeHub" }, intendedForAgents: true, runtimePreconditions: ["Realtime voice only; requires Screen Recording permission."], - // Realtime voice invokes this through the same pi-mono runtime capability - // fence as other kernel-authorized tools. The surface still limits the - // Swift executor to realtime voice; without this projection the runtime - // rejects every provider screenshot call as tool_not_allowed. - adapters: { - "pi-mono": { advertised: true }, - }, + // Realtime voice runs are authorized through their surface projection + // (`toolsForSurface("realtime_voice")` in the run capability), like + // report_screen_observation. Advertising it to pi-mono would put an + // ungated full-screen capture in every typed chat, pill and workstream run. + adapters: {}, }, { name: "report_screen_observation", diff --git a/desktop/macos/agent/src/runtime/run-tool-capability.ts b/desktop/macos/agent/src/runtime/run-tool-capability.ts index ff3a49029df..36fcce12630 100644 --- a/desktop/macos/agent/src/runtime/run-tool-capability.ts +++ b/desktop/macos/agent/src/runtime/run-tool-capability.ts @@ -9,13 +9,17 @@ import { toolsForSurface, } from "./omi-tool-manifest.js"; import { executionRoleAllowsTool, type AgentExecutionRole } from "./execution-policy.js"; +import { validateRuntimeContractSchema, type RuntimeContractSchema } from "./contract-schema.js"; import { + buildDesktopToolApprovalRequest, desktopToolPolicyInternals, evaluateDesktopToolPolicy, type DesktopCoordinatorBundle, + type DesktopToolApprovalRequest, type DesktopToolGrant, + type DesktopToolPolicyResult, } from "./desktop-tool-policy.js"; -import type { AgentEvent, AgentStore, AttemptStatus, RunStatus } from "./types.js"; +import type { AgentEvent, AgentStore, AttemptStatus, DesktopCoordinatorDispatch, RunStatus } from "./types.js"; import type { RunMode } from "./types.js"; import { canonicalInputHash, @@ -27,6 +31,7 @@ import { terminalizeRevokedToolInvocation, type ToolInvocationEffectClass, type ToolInvocationIdentity, + type ToolInvocationLedgerRecord, type ToolInvocationRetryPolicy, } from "./tool-invocation-ledger.js"; @@ -111,6 +116,8 @@ export type RunToolCapabilityRejectCode = | "tool_not_manifested" | "tool_not_allowed" | "approval_required" + | "invalid_tool_input" + | "input_too_large_to_approve" | "invocation_replayed"; export class RunToolCapabilityRejectedError extends Error { @@ -156,11 +163,70 @@ export interface RunToolExecutionLease { release(): void; } +/** + * Relay authorization either admits the invocation or parks it behind one + * durable approval dispatch. A parked invocation already holds its `prepared` + * ledger row, so a restart fails it exactly like any other undispatched claim; + * the broker refuses to dispatch it until the user resolves the dispatch. + */ +export type RunToolAuthorizationOutcome = + | { kind: "authorized"; invocation: AuthorizedRunToolInvocation } + | { + kind: "approval_required"; + invocation: AuthorizedRunToolInvocation; + dispatch: DesktopCoordinatorDispatch; + request: DesktopToolApprovalRequest; + }; + +/** Durable binding stored in the approval dispatch payload under `invocation`. */ +export interface ToolApprovalInvocationBinding { + invocationId: string; + toolName: string; + inputHash: string; + daemonBootEpoch: string; + executionGeneration: number; + adapterId: string; + surfaceKind: string; +} + +export interface CancelledToolApproval { + dispatchId: string; + invocationId: string; + sessionId: string; + runId: string; + attemptId: string; + reason: RunToolCapabilityRevocationReason; +} + +export type ToolApprovalDenialCode = "approval_denied" | "approval_expired" | "approval_cancelled"; + +/** + * What `approveInvocation` / `denyInvocation` took out of the broker's memory. + * The kernel hands it back through `restorePendingApproval` when the SQLite + * transaction around the decision rolls back, so memory never says "decided" + * while the dispatch row still says "pending". + */ +export interface ReleasedToolApproval { + capabilityRef: string; + invocationId: string; + dispatchId: string; + ledgerWasActive: boolean; +} + export interface RunToolCapabilityBrokerOptions { store: AgentStore; nowMs?: () => number; daemonBootEpoch?: string; onRejected?: (code: RunToolCapabilityRejectCode) => void; + /** + * Parked approvals the broker closed because their authority ended + * (terminal run, owner change, runtime stop). The dispatch rows are already + * cancelled when this fires; the kernel records the matching + * `approval.resolved` so every card ends with exactly one resolution. + */ + onApprovalsCancelled?: (cancelled: CancelledToolApproval[]) => void; + /** Policy seam for tests; production always uses `evaluateDesktopToolPolicy`. */ + desktopToolPolicy?: typeof evaluateDesktopToolPolicy; /** * Profiles are authoritative once the profile migration is installed. This * seam keeps the broker independently testable and makes legacy session @@ -180,6 +246,23 @@ interface CapabilityState { activeInvocationIds: Set; completedInvocationIds: Set; executionLeases: Map; + /** invocationId → approval dispatchId for prepared invocations the user has not resolved. */ + pendingApprovals: Map; +} + +interface PendingApproval { + capabilityRef: string; + invocationId: string; + dispatchId: string; +} + +interface EvaluatedInvocation { + state: CapabilityState; + tool: OmiToolManifestEntry; + inputHash: string; + effectClass: ToolInvocationEffectClass; + retryPolicy: ToolInvocationRetryPolicy; + approval: { policy: DesktopToolPolicyResult; resourceRef: string | undefined } | null; } function rejectCodeForRevocation(reason: RunToolCapabilityRevocationReason): RunToolCapabilityRejectCode { @@ -216,17 +299,29 @@ function number(value: unknown, fallback = 0): number { return Number.isFinite(parsed) ? parsed : fallback; } -const DESKTOP_APPROVAL_TOOLS = new Set([ +/** + * Tools the broker parks behind a per-invocation approval card. A test holds + * this set to every relay-callable tool the policy classifies into a sensitive + * bundle, so a new sensitive tool cannot ship ungated by omission. + */ +export const DESKTOP_APPROVAL_TOOLS: ReadonlySet = new Set([ "list_message_chats", "read_message_history", "list_mail_messages", "send_message", "run_applescript", + "capture_screen", ]); +/** + * The resource a scoped grant is matched against, derived only from the + * fields the tool actually acts on. The model never names the resource + * itself: a caller-supplied ref would let one approval cover a different + * recipient or script. Tools without a natural target get a stable ref so an + * `allow_session` grant can name exactly that surface; the legacy `*` pattern + * keeps matching them too. + */ function toolResourceRef(toolName: string, input: Record): string | undefined { - const explicit = typeof input.resource_ref === "string" ? input.resource_ref.trim() : ""; - if (explicit) return explicit; if (toolName === "send_message") { const recipient = typeof input.to === "string" ? input.to.trim() : ""; return recipient || undefined; @@ -242,9 +337,74 @@ function toolResourceRef(toolName: string, input: Record): stri const handle = typeof input.handle === "string" ? input.handle.trim() : ""; return chatId || handle || undefined; } + if (toolName === "list_message_chats") return "messages:chats"; + if (toolName === "list_mail_messages") return "mail:inbox"; + if (toolName === "capture_screen") return "screen"; return undefined; } +/** + * Approval-gated tools are held to their manifest schema at the kernel + * boundary. Neither relay validates inputs, so without this an unknown key + * would ride into the ledger hash and the approval preview unexamined. + */ +function gatedToolInputErrors(tool: OmiToolManifestEntry, toolInput: Record): string[] { + return validateRuntimeContractSchema(toolInput, tool.inputSchema as RuntimeContractSchema); +} + +export function toolApprovalInvocationBinding(dispatch: Pick): ToolApprovalInvocationBinding | null { + if (dispatch.kind !== "approval") return null; + let payload: unknown; + try { + payload = JSON.parse(dispatch.payloadJson); + } catch { + return null; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const binding = (payload as Record).invocation; + if (!binding || typeof binding !== "object" || Array.isArray(binding)) return null; + const record = binding as Record; + if ( + typeof record.invocationId !== "string" || !record.invocationId + || typeof record.toolName !== "string" || !record.toolName + || typeof record.inputHash !== "string" || !record.inputHash + || typeof record.daemonBootEpoch !== "string" || !record.daemonBootEpoch + || !Number.isSafeInteger(record.executionGeneration) + || typeof record.adapterId !== "string" + || typeof record.surfaceKind !== "string" + ) { + return null; + } + return { + invocationId: record.invocationId, + toolName: record.toolName, + inputHash: record.inputHash, + daemonBootEpoch: record.daemonBootEpoch, + executionGeneration: record.executionGeneration as number, + adapterId: record.adapterId, + surfaceKind: record.surfaceKind, + }; +} + +/** + * Whether the card for a bound tool approval offered "Allow for This Chat". + * The broker writes the offered options into the dispatch payload when it + * parks the call; a grant the card never offered (a live screenshot, a thread + * read with no exact resource) is refused rather than minted. + */ +export function toolApprovalOffersSessionGrant(dispatch: Pick): boolean { + let payload: unknown; + try { + payload = JSON.parse(dispatch.payloadJson); + } catch { + return false; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const options = (payload as Record).options; + return Array.isArray(options) && options.some((option) => + !!option && typeof option === "object" && (option as Record).id === "allow_session"); +} + /** * Ephemeral capability authority for Swift-backed/runtime-control tools. * @@ -259,10 +419,14 @@ export class RunToolCapabilityBroker { private readonly nowMs: () => number; readonly daemonBootEpoch: string; private readonly onRejected: (code: RunToolCapabilityRejectCode) => void; + private readonly onApprovalsCancelled: (cancelled: CancelledToolApproval[]) => void; + private readonly desktopToolPolicy: typeof evaluateDesktopToolPolicy; private readonly profileForSession: RunToolCapabilityBrokerOptions["profileForSession"]; private readonly states = new Map(); private readonly activeByAttempt = new Map(); private readonly activeByRun = new Map>(); + /** dispatchId → parked invocation; process-local like the capabilities themselves. */ + private readonly pendingApprovalsByDispatch = new Map(); private executionGeneration = 0; constructor(options: RunToolCapabilityBrokerOptions) { @@ -270,6 +434,8 @@ export class RunToolCapabilityBroker { this.nowMs = options.nowMs ?? Date.now; this.daemonBootEpoch = options.daemonBootEpoch ?? `boot_${randomUUID().replaceAll("-", "")}`; this.onRejected = options.onRejected ?? (() => undefined); + this.onApprovalsCancelled = options.onApprovalsCancelled ?? (() => undefined); + this.desktopToolPolicy = options.desktopToolPolicy ?? evaluateDesktopToolPolicy; if (typeof options.profileForSession !== "function") { throw new Error("Run tool capability broker requires a canonical session profile reader"); } @@ -379,6 +545,7 @@ export class RunToolCapabilityBroker { activeInvocationIds: new Set(), completedInvocationIds: new Set(), executionLeases: new Map(), + pendingApprovals: new Map(), }); this.activeByAttempt.set(capability.attemptId, capability.capabilityRef); const runRefs = this.activeByRun.get(capability.runId) ?? new Set(); @@ -396,6 +563,102 @@ export class RunToolCapabilityBroker { toolInput: Record; activeOwnerId: string; }): AuthorizedRunToolInvocation { + const evaluated = this.evaluate(input); + if (evaluated.approval) { + this.reject("approval_required", `Desktop tool approval was not granted: ${evaluated.approval.policy.reason}`); + } + return this.prepare(evaluated, input); + } + + /** + * Relay authorization that parks a sensitive invocation instead of rejecting + * it. The ledger row and the approval dispatch are written together so a + * restart cannot observe one without the other; the caller commits the + * run/attempt transition and the lifecycle events in the same transaction. + */ + authorizeRelayInvocationOrRequestApproval(input: { + capabilityRef: string; + invocationId: string; + toolName: string; + toolInput: Record; + activeOwnerId: string; + }): RunToolAuthorizationOutcome { + const state = this.states.get(input.capabilityRef); + if (!state) this.reject("capability_missing", "Unknown run tool capability"); + const scoped = { ...input, runId: state.capability.runId, attemptId: state.capability.attemptId }; + const evaluated = this.evaluate(scoped); + if (!evaluated.approval) return { kind: "authorized", invocation: this.prepare(evaluated, scoped) }; + + const nowMs = this.nowMs(); + const request = buildDesktopToolApprovalRequest({ + toolName: evaluated.tool.name, + toolInput: input.toolInput, + policy: evaluated.approval.policy, + resourceRef: evaluated.approval.resourceRef, + nowMs, + }); + // The user approves what the card shows. If the card cannot show all of + // it, nothing is prepared: the model is told to shorten the input. + if (request.previewTruncated) { + this.reject( + "input_too_large_to_approve", + "The tool input is too large to show in an approval card; shorten it and call again", + ); + } + const invocation = this.prepare(evaluated, scoped); + const binding: ToolApprovalInvocationBinding = { + invocationId: invocation.invocationId, + toolName: invocation.canonicalToolName, + inputHash: invocation.inputHash, + daemonBootEpoch: invocation.daemonBootEpoch, + executionGeneration: invocation.executionGeneration, + adapterId: invocation.adapterId, + surfaceKind: invocation.surfaceKind, + }; + const dispatch = this.store.insertDesktopDispatch({ + ownerId: invocation.ownerId, + kind: "approval", + priority: 100, + title: request.title, + decisionPrompt: request.decisionPrompt, + recommendedDefault: request.defaultOptionId, + sourceSessionId: invocation.sessionId, + sourceRunId: invocation.runId, + sourceAttemptId: invocation.attemptId, + capability: request.capability, + operation: request.operation, + resourceRef: request.resourceRef, + payloadJson: JSON.stringify({ + policy: request.policy, + invocation: binding, + preview: request.preview, + previewTruncated: request.previewTruncated, + options: request.options, + defaultOptionId: request.defaultOptionId, + reason: request.reason, + requestedAtMs: request.requestedAtMs, + }), + createdAtMs: nowMs, + expiresAtMs: request.expiresAtMs, + }); + state.pendingApprovals.set(invocation.invocationId, dispatch.dispatchId); + this.pendingApprovalsByDispatch.set(dispatch.dispatchId, { + capabilityRef: state.capability.capabilityRef, + invocationId: invocation.invocationId, + dispatchId: dispatch.dispatchId, + }); + return { kind: "approval_required", invocation, dispatch, request }; + } + + private evaluate(input: { + capabilityRef: string; + invocationId: string; + runId: string; + attemptId: string; + toolName: string; + toolInput: Record; + activeOwnerId: string; + }): EvaluatedInvocation { const state = this.states.get(input.capabilityRef); if (!state) this.reject("capability_missing", "Unknown run tool capability"); if (state.revoked) this.reject("capability_revoked", "Run tool capability has been revoked"); @@ -452,20 +715,30 @@ export class RunToolCapabilityBroker { this.reject("tool_not_allowed", "Execution role cannot invoke this tool"); } + let approval: EvaluatedInvocation["approval"] = null; if (DESKTOP_APPROVAL_TOOLS.has(tool.name)) { + const inputErrors = gatedToolInputErrors(tool, input.toolInput); + if (inputErrors.length > 0) { + this.reject("invalid_tool_input", `Tool input does not match the ${tool.name} schema: ${inputErrors[0]!.slice(0, 200)}`); + } const descriptor = desktopToolPolicyInternals.descriptorFromToolName(tool.name); if (!descriptor) this.reject("tool_not_manifested", "Desktop approval metadata is missing"); const grants = this.desktopToolGrants(capability.sessionId, capability.runId); - const policy = evaluateDesktopToolPolicy({ + const resourceRef = toolResourceRef(tool.name, input.toolInput); + const policy = this.desktopToolPolicy({ toolName: tool.name, selectedBundles: descriptor.bundles, operation: tool.name, - resourceRef: toolResourceRef(tool.name, input.toolInput), + resourceRef, grants, + nowMs: this.nowMs(), }); - if (policy.decision !== "allow") { + // A hard policy deny is not something the user can approve away, so it + // keeps the pre-existing rejection instead of parking the invocation. + if (policy.decision === "deny") { this.reject("approval_required", `Desktop tool approval was not granted: ${policy.reason}`); } + if (policy.decision === "dispatch_required") approval = { policy, resourceRef }; } const inputHash = canonicalInputHash(input.toolInput); @@ -477,6 +750,15 @@ export class RunToolCapabilityBroker { const retryPolicy: ToolInvocationRetryPolicy = effectClass === "non_idempotent_write" ? "never_auto_retry" : "safe_retry"; + return { state, tool, inputHash, effectClass, retryPolicy, approval }; + } + + private prepare( + evaluated: EvaluatedInvocation, + input: { invocationId: string }, + ): AuthorizedRunToolInvocation { + const { state, tool, inputHash, effectClass, retryPolicy } = evaluated; + const capability = state.capability; try { prepareToolInvocation(this.store, { invocationId: input.invocationId, @@ -576,13 +858,136 @@ export class RunToolCapabilityBroker { } markInvocationDispatched(invocation: AuthorizedRunToolInvocation): void { + this.assertNotAwaitingApproval(invocation); markToolInvocationDispatched(this.store, this.ledgerIdentity(invocation), this.nowMs()); } + /** + * Admit a parked invocation after the user allowed its dispatch. The caller + * has already moved the dispatch row out of `pending`; this re-reads the + * ledger and live run authority so a stale or tampered dispatch cannot admit + * a different invocation, and the invocation stays single use. + */ + approveInvocation(input: { + dispatchId: string; + binding: ToolApprovalInvocationBinding; + activeOwnerId: string; + }): { record: ToolInvocationLedgerRecord; released: ReleasedToolApproval } { + const { state, record, pending } = this.readPendingApproval(input.dispatchId, input.binding); + // Revalidate before forgetting the parking: a failure here may revoke the + // capability, and revocation must still find and close the dispatch row. + this.assertLiveCapabilityAuthority(state, input.activeOwnerId); + return { record, released: this.releasePendingApproval(state, pending, false) }; + } + + /** + * Everything `approveInvocation` checks, without taking anything. The kernel + * runs this before it opens the transaction that resolves the dispatch, so a + * revocation triggered by a stale owner, run, or attempt commits on its own + * instead of being rolled back together with the failed resolution. + */ + assertApprovalAuthority(input: { + dispatchId: string; + binding: ToolApprovalInvocationBinding; + activeOwnerId: string; + }): void { + const { state } = this.readPendingApproval(input.dispatchId, input.binding); + this.assertLiveCapabilityAuthority(state, input.activeOwnerId); + } + + /** Fail a parked invocation closed; it never crossed the dispatch boundary, so it is `failed`, never `outcome_unknown`. */ + denyInvocation(input: { + dispatchId: string; + binding: ToolApprovalInvocationBinding; + code: ToolApprovalDenialCode; + }): { record: ToolInvocationLedgerRecord; released: ReleasedToolApproval } { + const { state, record, pending } = this.readPendingApproval(input.dispatchId, input.binding); + const terminal = terminalizeRevokedToolInvocation(this.store, record, input.code, this.nowMs()); + const released = this.releasePendingApproval(state, pending, state.activeInvocationIds.has(record.invocationId)); + state.activeInvocationIds.delete(record.invocationId); + state.completedInvocationIds.add(record.invocationId); + return { record: terminal, released }; + } + + /** Undo a release whose surrounding transaction rolled back; a revoked capability already closed its dispatch durably. */ + restorePendingApproval(released: ReleasedToolApproval): void { + const state = this.states.get(released.capabilityRef); + if (!state || state.revoked) return; + state.pendingApprovals.set(released.invocationId, released.dispatchId); + this.pendingApprovalsByDispatch.set(released.dispatchId, { + capabilityRef: released.capabilityRef, + invocationId: released.invocationId, + dispatchId: released.dispatchId, + }); + if (released.ledgerWasActive) { + state.completedInvocationIds.delete(released.invocationId); + state.activeInvocationIds.add(released.invocationId); + } + } + + hasPendingApprovals(runId: string): boolean { + for (const ref of this.activeByRun.get(runId) ?? []) { + const state = this.states.get(ref); + if (state && !state.revoked && state.pendingApprovals.size > 0) return true; + } + return false; + } + + private readPendingApproval( + dispatchId: string, + binding: ToolApprovalInvocationBinding, + ): { state: CapabilityState; record: ToolInvocationLedgerRecord; pending: PendingApproval } { + const pending = this.pendingApprovalsByDispatch.get(dispatchId); + const state = pending ? this.states.get(pending.capabilityRef) : undefined; + if (!pending || !state || state.pendingApprovals.get(pending.invocationId) !== dispatchId) { + this.reject("capability_revoked", "Approval dispatch is not bound to a live pending invocation"); + } + if (pending.invocationId !== binding.invocationId) { + this.reject("invocation_replayed", "Approval dispatch binding names a different invocation"); + } + const record = readToolInvocation(this.store, pending.invocationId); + if ( + record.status !== "prepared" + || record.inputHash !== binding.inputHash + || record.toolName !== binding.toolName + || record.daemonBootEpoch !== binding.daemonBootEpoch + || record.daemonBootEpoch !== this.daemonBootEpoch + || record.executionGeneration !== binding.executionGeneration + || record.runId !== state.capability.runId + || record.attemptId !== state.capability.attemptId + ) { + this.reject("invocation_replayed", "Approval dispatch binding does not match the prepared invocation"); + } + return { state, record, pending }; + } + + private releasePendingApproval( + state: CapabilityState, + pending: PendingApproval, + ledgerWasActive: boolean, + ): ReleasedToolApproval { + state.pendingApprovals.delete(pending.invocationId); + this.pendingApprovalsByDispatch.delete(pending.dispatchId); + return { + capabilityRef: state.capability.capabilityRef, + invocationId: pending.invocationId, + dispatchId: pending.dispatchId, + ledgerWasActive, + }; + } + + private assertNotAwaitingApproval(invocation: AuthorizedRunToolInvocation): void { + const state = this.states.get(invocation.capabilityRef); + if (state?.pendingApprovals.has(invocation.invocationId)) { + this.reject("approval_required", "Tool invocation is waiting for user approval"); + } + } + acquireExecutionLease( invocation: AuthorizedRunToolInvocation, activeOwnerId: () => string, ): RunToolExecutionLease { + this.assertNotAwaitingApproval(invocation); this.assertCurrentExecutionAuthority(invocation, activeOwnerId()); const state = this.states.get(invocation.capabilityRef)!; const existing = state.executionLeases.get(invocation.invocationId); @@ -643,6 +1048,7 @@ export class RunToolCapabilityBroker { revoke(capabilityRef: string, reason: RunToolCapabilityRevocationReason = "explicit"): boolean { const state = this.states.get(capabilityRef); if (!state || state.revoked) return false; + const cancelledApprovals: CancelledToolApproval[] = []; state.revoked = true; state.revocationReason = reason; for (const controller of state.executionLeases.values()) { @@ -652,6 +1058,28 @@ export class RunToolCapabilityBroker { )); } state.executionLeases.clear(); + // A parked approval cannot outlive the authority it would admit. Close the + // dispatch row here, before the ledger row below becomes `failed`, so no + // later resolution can find a pending card for a dead invocation. + const nowMs = this.nowMs(); + for (const [invocationId, dispatchId] of state.pendingApprovals) { + this.store.execute( + `UPDATE desktop_dispatches + SET status = 'cancelled', resolved_at_ms = ?, resolved_by = 'system', resolution_json = ? + WHERE dispatch_id = ? AND status = 'pending'`, + [nowMs, JSON.stringify({ decision: "cancelled", reason: `run_tool_${reason}` }), dispatchId], + ); + this.pendingApprovalsByDispatch.delete(dispatchId); + cancelledApprovals.push({ + dispatchId, + invocationId, + sessionId: state.capability.sessionId, + runId: state.capability.runId, + attemptId: state.capability.attemptId, + reason, + }); + } + state.pendingApprovals.clear(); for (const invocationId of state.activeInvocationIds) { const invocation = readToolInvocation(this.store, invocationId); if (invocation.status === "prepared" || invocation.status === "dispatched") { @@ -671,6 +1099,7 @@ export class RunToolCapabilityBroker { const runRefs = this.activeByRun.get(state.capability.runId); runRefs?.delete(capabilityRef); if (runRefs?.size === 0) this.activeByRun.delete(state.capability.runId); + if (cancelledApprovals.length > 0) this.onApprovalsCancelled(cancelledApprovals); return true; } diff --git a/desktop/macos/agent/src/runtime/sqlite-store.ts b/desktop/macos/agent/src/runtime/sqlite-store.ts index 3c0a831f5e3..e876433ccbd 100644 --- a/desktop/macos/agent/src/runtime/sqlite-store.ts +++ b/desktop/macos/agent/src/runtime/sqlite-store.ts @@ -747,6 +747,53 @@ export class SqliteAgentStore implements AgentStore { this.db.prepare("DELETE FROM workstream_continuation_checkpoints WHERE expires_at_ms <= ?").run(now); } + // An approval parked on a prepared tool invocation cannot survive the + // daemon: the ledger row above just became `failed`, so a later user + // answer must find nothing to admit. Expire it regardless of its TTL + // and record the closure so the card ends with an audit event. + const expiredToolApprovalRows = this.allRows( + `SELECT dispatch_id, source_session_id, source_run_id, source_attempt_id, payload_json + FROM desktop_dispatches + WHERE status = 'pending' + AND kind = 'approval' + AND json_extract(payload_json, '$.invocation.invocationId') IS NOT NULL`, + ); + const expiredToolApprovalDispatchIds = expiredToolApprovalRows.map((row) => text(row.dispatch_id)); + if (expiredToolApprovalDispatchIds.length > 0) { + this.db.prepare( + `UPDATE desktop_dispatches + SET status = 'expired', resolved_at_ms = ?, resolved_by = 'daemon_startup_reconciliation', resolution_json = ? + WHERE status = 'pending' + AND kind = 'approval' + AND json_extract(payload_json, '$.invocation.invocationId') IS NOT NULL`, + ).run(now, JSON.stringify({ decision: "expired", reason: "daemon_startup_reconciliation" })); + } + for (const row of expiredToolApprovalRows) { + if (row.source_session_id == null) continue; + const invocation = toolApprovalBindingFromPayload(row.payload_json); + eventIds.push(this.appendReconciliationEvent({ + sessionId: text(row.source_session_id), + runId: row.source_run_id == null ? null : text(row.source_run_id), + attemptId: row.source_attempt_id == null ? null : text(row.source_attempt_id), + type: "approval.resolved", + payload: { + approvalId: text(row.dispatch_id), + dispatchId: text(row.dispatch_id), + invocationId: invocation?.invocationId ?? null, + toolName: invocation?.toolName ?? null, + status: "expired", + decision: "expired", + selectedOptionId: null, + grantId: null, + automatic: true, + resolvedBy: "daemon_startup_reconciliation", + resolvedAtMs: now, + resolution: { decision: "expired", reason: "daemon_startup_reconciliation" }, + }, + createdAtMs: now, + })); + } + this.db.prepare( `UPDATE desktop_dispatches SET status = ?, resolved_at_ms = COALESCE(resolved_at_ms, ?), resolved_by = COALESCE(resolved_by, ?), resolution_json = COALESCE(resolution_json, ?) @@ -892,6 +939,7 @@ export class SqliteAgentStore implements AgentStore { requeuedBackendConversationDeleteIds, failedPreparedToolInvocationIds, outcomeUnknownToolInvocationIds, + expiredToolApprovalDispatchIds, repairedSessionProfileIds, repairedRunProfileReferenceIds: repairedProfileReferences.runIds, repairedAttemptProfileReferenceIds: repairedProfileReferences.attemptIds, @@ -3547,6 +3595,25 @@ function runTransaction(db: Pick, wor } } +/** The `invocation` binding an approval dispatch carries, or null when the payload is not one. */ +function toolApprovalBindingFromPayload(payloadJson: unknown): { invocationId: string | null; toolName: string | null } | undefined { + if (typeof payloadJson !== "string") return undefined; + try { + const parsed = JSON.parse(payloadJson) as unknown; + const invocation = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).invocation + : undefined; + if (!invocation || typeof invocation !== "object" || Array.isArray(invocation)) return undefined; + const record = invocation as Record; + return { + invocationId: typeof record.invocationId === "string" ? record.invocationId : null, + toolName: typeof record.toolName === "string" ? record.toolName : null, + }; + } catch { + return undefined; + } +} + function sessionIdForRun(db: Pick, runId: string): string { const row = db.prepare("SELECT session_id FROM runs WHERE run_id = ?").get(runId); if (!row) { diff --git a/desktop/macos/agent/src/runtime/types.ts b/desktop/macos/agent/src/runtime/types.ts index 8fd8472ef17..5e1d0733157 100644 --- a/desktop/macos/agent/src/runtime/types.ts +++ b/desktop/macos/agent/src/runtime/types.ts @@ -691,6 +691,8 @@ export interface StartupReconciliationResult { requeuedBackendConversationDeleteIds: string[]; failedPreparedToolInvocationIds: string[]; outcomeUnknownToolInvocationIds: string[]; + /** Pending tool approvals whose waiting invocation died with the daemon; never replayed. */ + expiredToolApprovalDispatchIds: string[]; repairedSessionProfileIds: string[]; repairedRunProfileReferenceIds: string[]; repairedAttemptProfileReferenceIds: string[]; diff --git a/desktop/macos/agent/tests/control-tools.test.ts b/desktop/macos/agent/tests/control-tools.test.ts index e2ae5dc49cd..3c8e62cc591 100644 --- a/desktop/macos/agent/tests/control-tools.test.ts +++ b/desktop/macos/agent/tests/control-tools.test.ts @@ -962,6 +962,708 @@ describe("agent control tools", () => { store.close(); }); + describe("per-invocation device tool approvals", () => { + const sendInput = { to: "+15551234567", text: "Running late" }; + + /** A live acp-lane run whose adapter is mid-turn, exactly when the model would call send_message. */ + async function liveSendMessageRun(invocationId = "send-1", screenAdmitted = false) { + const harness = createKernelHarness(newDatabasePath(), "acp"); + const { store, adapter, kernel } = harness; + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "approvals" }, + defaultAdapterId: "acp", + }); + adapter.deferResult(); + const runPromise = kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "approvals", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "approval-client", + requestId: `approval-request-${invocationId}`, + prompt: "Tell Alice I'm running late", + cwd: "/tmp/approvals", + admittedContextSnapshot: screenAdmitted + ? { + ...kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"), + sourceOutcomes: [{ source: "screen", outcome: "available" }], + } + : kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"), + }); + await waitUntil(() => adapter.executed.length === 1); + const capabilityRef = adapter.executed[0]!.toolCapabilityRef; + const finish = async () => { + adapter.resolveDeferred({ terminalStatus: "completed", text: "done" }); + await runPromise; + }; + return { ...harness, sessionId: resolved.agentSessionId, capabilityRef, finish }; + } + + async function parkedSendMessage(invocationId = "send-1") { + const live = await liveSendMessageRun(invocationId); + // The interim gate is off by default; a card-capable client turns it on. + live.kernel.setDesktopToolApprovalsEnabled(true); + const outcome = live.kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef: live.capabilityRef, + invocationId, + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: "owner", + }); + if (outcome.kind !== "approval_required") throw new Error("expected send_message to park"); + return { ...live, outcome, runId: outcome.invocation.runId }; + } + + it("keeps today's immediate approval_required while no client can render the card", async () => { + const { store, kernel, capabilityRef, finish } = await liveSendMessageRun(); + + expect(() => kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef, + invocationId: "gate-off", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: "owner", + })).toThrow(expect.objectContaining({ code: "approval_required" })); + expect(store.getRow("SELECT COUNT(*) AS count FROM desktop_dispatches").count).toBe(0); + expect(store.getRow("SELECT COUNT(*) AS count FROM tool_invocation_ledger").count).toBe(0); + expect(store.getRow("SELECT COUNT(*) AS count FROM events WHERE type = 'run.waiting_approval'").count).toBe(0); + await finish(); + store.close(); + }); + + it("a model-created approval dispatch can never pose as a parked tool invocation", async () => { + const { store, kernel } = createKernelHarness(newDatabasePath()); + const session = store.insertSession({ ownerId: "owner", surfaceKind: "main_chat", defaultAdapterId: "fake" }); + + const created = parseToolResult( + await handleAgentControlToolCall(ownerContext(kernel), "create_desktop_dispatch", { + kind: "approval", + priority: 100, + title: "Looks like a send", + decisionPrompt: "Allow?", + sourceSessionId: session.sessionId, + capability: "desktop.messaging.send", + operation: "send_message", + resourceRef: "+15551234567", + payload: { + invocation: { + invocationId: "forged", + toolName: "send_message", + inputHash: "sha256:forged", + daemonBootEpoch: "boot", + executionGeneration: 1, + adapterId: "acp", + surfaceKind: "main_chat", + }, + note: "kept", + }, + }), + ); + const dispatchId = created.dispatch.dispatchId as string; + + expect(JSON.parse(String(store.getRow("SELECT payload_json FROM desktop_dispatches WHERE dispatch_id = ?", [dispatchId]).payload_json))) + .toEqual({ note: "kept" }); + // Without a binding it is an ordinary decision item the user can still dismiss. + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId, + status: "cancelled", + resolution: {}, + }), + ); + expect(resolved).toMatchObject({ ok: true, dispatch: { status: "cancelled" } }); + store.close(); + }); + + it("an owner change closes the open card with an audited resolution and frees the run", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + kernel.revokeRunToolCapabilitiesForOwner("owner", "owner_changed"); + + expect(store.getRow("SELECT status, resolved_by FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId])) + .toEqual({ status: "cancelled", resolved_by: "system" }); + expect(readToolInvocation(store, "send-1")).toMatchObject({ status: "failed", errorCode: "run_tool_owner_changed" }); + expect(eventTypes(store, runId).slice(-2)).toEqual(["approval.resolved", "run.running"]); + const resolution = store.allRows("SELECT payload_json FROM events WHERE type = 'approval.resolved' AND run_id = ?", [runId]); + expect(resolution).toHaveLength(1); + expect(JSON.parse(String(resolution[0]!.payload_json))).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + invocationId: "send-1", + decision: "cancelled", + automatic: true, + resolution: { reason: "run_tool_owner_changed" }, + }); + expect(runStatus(store, runId)).toEqual({ run: "running", attempt: "running" }); + await finish(); + store.close(); + }); + + it("a resolution refused for stale authority still closes the card durably", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + // The resolving owner is not the owner the card was parked for. The + // refusal revokes the capability; that revocation must commit even + // though the resolution itself is rolled back. + expect(() => kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner-2", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + })).toThrow(expect.objectContaining({ code: "owner_mismatch" })); + + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("cancelled"); + expect(readToolInvocation(store, "send-1").status).toBe("failed"); + expect(store.getRow("SELECT COUNT(*) AS count FROM events WHERE type = 'approval.resolved' AND run_id = ?", [runId]).count).toBe(1); + expect(runStatus(store, runId).run).toBe("running"); + await finish(); + store.close(); + }); + + it("refuses to mint a session grant for a live screenshot and keeps the card answerable", async () => { + const live = await liveSendMessageRun("screen-1", true); + live.kernel.setDesktopToolApprovalsEnabled(true); + const outcome = live.kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef: live.capabilityRef, + invocationId: "screen-1", + toolName: "capture_screen", + toolInput: {}, + activeOwnerId: "owner", + }); + if (outcome.kind !== "approval_required") throw new Error("expected capture_screen to park"); + const { store, kernel, finish } = live; + + const refused = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + grant: { + runId: null, + capability: "desktop.context.screenshot_image", + operation: "capture_screen", + resourcePattern: "screen", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 60 * 60_000, + }, + }), + ); + expect(refused.ok).toBe(false); + expect(String(refused.error.message)).toContain("allowed once"); + expect(store.getRow("SELECT COUNT(*) AS count FROM grants").count).toBe(0); + expect(readToolInvocation(store, "screen-1").status).toBe("prepared"); + + const allowedOnce = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + }), + ); + expect(allowedOnce).toMatchObject({ ok: true, dispatch: { status: "resolved" }, grant: null }); + expect(store.getRow("SELECT COUNT(*) AS count FROM grants").count).toBe(0); + await finish(); + store.close(); + }); + + it("clearing the chat closes its parked card and revokes only that session's user grants", async () => { + const { store, kernel, outcome, runId, sessionId, capabilityRef, finish } = await parkedSendMessage(); + const grant = (session: string, source: "user" | "policy", resourcePattern = "+15551234567") => store.insertGrant({ + sessionId: session, + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern, + effect: "allow", + source, + expiresAtMs: Date.now() + 60 * 60_000, + }).grantId; + const otherSession = store.insertSession({ ownerId: "owner", surfaceKind: "main_chat", defaultAdapterId: "acp" }).sessionId; + const thisChat = grant(sessionId, "user"); + const policyGrant = grant(sessionId, "policy", "+15550000000"); + const otherChat = grant(otherSession, "user"); + + expect(() => kernel.endChatApprovalScope({ ownerId: "owner-2", sessionId, reason: "chat_cleared" })).toThrow(); + const ended = kernel.endChatApprovalScope({ ownerId: "owner", sessionId, reason: "chat_cleared" }); + + expect(ended).toEqual({ closedApprovalIds: [outcome.dispatch.dispatchId], revokedGrantIds: [thisChat] }); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("cancelled"); + expect(readToolInvocation(store, "send-1")).toMatchObject({ status: "failed", errorCode: "approval_cancelled" }); + expect(runStatus(store, runId).run).toBe("running"); + const revoked = (id: string) => store.getRow("SELECT revoked_at_ms FROM grants WHERE grant_id = ?", [id]).revoked_at_ms; + expect(revoked(thisChat)).not.toBeNull(); + expect(revoked(policyGrant)).toBeNull(); + expect(revoked(otherChat)).toBeNull(); + const audit = store.allRows("SELECT payload_json FROM events WHERE type = 'grants.revoked' AND session_id = ?", [sessionId]); + expect(audit).toHaveLength(1); + expect(JSON.parse(String(audit[0]!.payload_json))).toMatchObject({ grantIds: [thisChat], source: "user", reason: "chat_cleared" }); + + // Idempotent: nothing left to close or revoke, and no second audit event. + expect(kernel.endChatApprovalScope({ ownerId: "owner", sessionId, reason: "chat_cleared" })) + .toEqual({ closedApprovalIds: [], revokedGrantIds: [] }); + expect(store.getRow("SELECT COUNT(*) AS count FROM events WHERE type = 'grants.revoked'").count).toBe(1); + + // The same send in the new conversation asks again instead of riding the old grant. + const again = kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef, + invocationId: "send-2", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: "owner", + }); + expect(again.kind).toBe("approval_required"); + await finish(); + store.close(); + }); + + it("caps a session grant at 24 hours", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + grant: { + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "+15551234567", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 25 * 60 * 60_000, + }, + }), + ); + + expect(resolved.ok).toBe(false); + expect(String(resolved.error.message)).toContain("24 hours"); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + expect(runStatus(store, runId).run).toBe("waiting_approval"); + expect(store.getRow("SELECT COUNT(*) AS count FROM grants").count).toBe(0); + // The card is still answerable after the refused attempt. + const denied = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "deny" }, + }), + ); + expect(denied.ok).toBe(true); + await finish(); + store.close(); + }); + + it("a decision whose transaction fails leaves the card parked in memory as well as in the store", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + // Fail the store write that records the decision, once per arming. The + // kernel appends through this same store instance, so the throw lands + // inside the resolving transaction after the broker has already taken + // the invocation out of its parked set. + const realAppendEvent = store.appendEvent.bind(store); + let failNext = false; + store.appendEvent = (input) => { + if (failNext && input.type === "approval.resolved") { + failNext = false; + throw new Error("injected approval.resolved failure"); + } + return realAppendEvent(input); + }; + + // An allow whose transaction rolls back: the rows are back to pending / + // prepared, and the broker must still treat the invocation as parked. + failNext = true; + expect(() => kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + })).toThrow("injected"); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + expect(runStatus(store, runId).run).toBe("waiting_approval"); + expect(() => kernel.markRunToolInvocationDispatched(outcome.invocation)).toThrow(expect.objectContaining({ code: "approval_required" })); + + // The same for a system termination, whose rolled-back deny had already + // moved the invocation out of the active set in memory. + failNext = true; + expect(() => kernel.terminateDesktopToolApproval({ + dispatchId: outcome.dispatch.dispatchId, + status: "expired", + reason: "approval_wait_expired", + })).toThrow("injected"); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + expect(() => kernel.markRunToolInvocationDispatched(outcome.invocation)).toThrow(expect.objectContaining({ code: "approval_required" })); + + // Once the store cooperates, the card resolves normally and the + // invocation is admitted exactly once. + const resolved = kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + }); + expect(resolved.dispatch.status).toBe("resolved"); + kernel.markRunToolInvocationDispatched(outcome.invocation); + expect(readToolInvocation(store, "send-1").status).toBe("dispatched"); + expect(runStatus(store, runId)).toEqual({ run: "running", attempt: "running" }); + await finish(); + store.close(); + }); + + function runStatus(store: SqliteAgentStore, runId: string) { + return { + run: store.getRow("SELECT status FROM runs WHERE run_id = ?", [runId]).status, + attempt: store.getRow("SELECT status FROM run_attempts WHERE run_id = ? ORDER BY attempt_no DESC LIMIT 1", [runId]).status, + }; + } + + function eventTypes(store: SqliteAgentStore, runId: string): string[] { + return store.allRows("SELECT type FROM events WHERE run_id = ? ORDER BY event_seq", [runId]).map((row) => String(row.type)); + } + + it("parks the run in waiting_approval and records approval.requested in the UX-doc shape", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + expect(runStatus(store, runId)).toEqual({ run: "waiting_approval", attempt: "waiting_approval" }); + expect(eventTypes(store, runId).slice(-2)).toEqual(["run.waiting_approval", "approval.requested"]); + expect(outcome.event?.type).toBe("approval.requested"); + expect(JSON.parse(outcome.event!.payloadJson)).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + policy: "default_user_approval", + adapterId: "acp", + invocationId: "send-1", + toolName: "send_message", + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "+15551234567", + inputHash: outcome.invocation.inputHash, + preview: sendInput, + options: [ + { id: "allow_once", effect: "allow", scope: "run" }, + { id: "allow_session", effect: "allow", scope: "session" }, + { id: "deny", effect: "deny", scope: "request" }, + ], + defaultOptionId: "deny", + }); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + // The parked run still accepts ordinary tool calls while it waits. + const unrelated = kernel.authorizeRelayedRunToolInvocation({ + capabilityRef: outcome.invocation.capabilityRef, + invocationId: "send-1-unrelated", + toolName: "get_memories", + toolInput: {}, + activeOwnerId: "owner", + }); + expect(unrelated.canonicalToolName).toBe("get_memories"); + + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "cancelled", + resolution: {}, + }); + await finish(); + store.close(); + }); + + it("allow once admits exactly this invocation, mints no grant, and resumes the run", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + }), + ); + + expect(resolved).toMatchObject({ ok: true, dispatch: { status: "resolved" }, grant: null }); + expect(resolved.event.payload).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + invocationId: "send-1", + decision: "allow", + selectedOptionId: "allow_once", + grantId: null, + automatic: false, + resolvedBy: "user", + }); + expect(runStatus(store, runId)).toEqual({ run: "running", attempt: "running" }); + expect(eventTypes(store, runId).slice(-2)).toEqual(["approval.resolved", "run.running"]); + expect(store.getRow("SELECT COUNT(*) AS count FROM grants").count).toBe(0); + + kernel.markRunToolInvocationDispatched(outcome.invocation); + expect(readToolInvocation(store, "send-1").status).toBe("dispatched"); + + // The resolved dispatch is spent; a second answer admits nothing. + const replay = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + }), + ); + expect(replay.ok).toBe(false); + + // The same message to the same recipient is a new invocation and asks again. + const again = kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef: outcome.invocation.capabilityRef, + invocationId: "send-2", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: "owner", + }); + expect(again.kind).toBe("approval_required"); + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: again.kind === "approval_required" ? again.dispatch.dispatchId : "", + status: "resolved", + resolution: { decision: "deny" }, + }); + await finish(); + store.close(); + }); + + it("allow for this chat mints a session-scoped grant for exactly that recipient", async () => { + const { store, kernel, sessionId, outcome, runId, finish } = await parkedSendMessage(); + const expiresAtMs = Date.now() + 60 * 60_000; + + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + grant: { + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "+15551234567", + effect: "allow", + source: "user", + expiresAtMs, + }, + }), + ); + + expect(resolved).toMatchObject({ + ok: true, + grant: { + sessionId, + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "+15551234567", + source: "user", + expiresAtMs, + }, + }); + expect(resolved.event.payload).toMatchObject({ + decision: "allow", + selectedOptionId: "allow_session", + grantId: resolved.grant.grantId, + }); + expect(runStatus(store, runId)).toEqual({ run: "running", attempt: "running" }); + kernel.markRunToolInvocationDispatched(outcome.invocation); + + const covered = kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef: outcome.invocation.capabilityRef, + invocationId: "send-covered", + toolName: "send_message", + toolInput: { ...sendInput, text: "Make that 15" }, + activeOwnerId: "owner", + }); + const otherRecipient = kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef: outcome.invocation.capabilityRef, + invocationId: "send-other", + toolName: "send_message", + toolInput: { ...sendInput, to: "+15550000000" }, + activeOwnerId: "owner", + }); + expect(covered.kind).toBe("authorized"); + expect(otherRecipient.kind).toBe("approval_required"); + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: otherRecipient.kind === "approval_required" ? otherRecipient.dispatch.dispatchId : "", + status: "resolved", + resolution: { decision: "deny" }, + }); + await finish(); + store.close(); + }); + + it("deny fails the invocation closed and lets the run continue", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "deny" }, + }), + ); + + expect(resolved).toMatchObject({ ok: true, dispatch: { status: "resolved" }, grant: null }); + expect(resolved.event.payload).toMatchObject({ decision: "deny", selectedOptionId: "deny" }); + expect(readToolInvocation(store, "send-1")).toMatchObject({ + status: "failed", + errorCode: "approval_denied", + dispatchedAtMs: null, + }); + expect(runStatus(store, runId)).toEqual({ run: "running", attempt: "running" }); + expect(() => kernel.markRunToolInvocationDispatched(outcome.invocation)).toThrow(); + await finish(); + store.close(); + }); + + it("a resolution without a decision cannot leave a parked invocation undecided", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { note: "looks fine" }, + }), + ); + + expect(resolved.ok).toBe(false); + expect(String(resolved.error.message)).toContain("allow or deny"); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + expect(runStatus(store, runId).run).toBe("waiting_approval"); + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "cancelled", + resolution: {}, + }); + await finish(); + store.close(); + }); + + it("the model cannot approve its own invocation through the control relay", async () => { + const { store, kernel, sessionId, outcome, runId, finish } = await parkedSendMessage(); + + const fromAdapter = parseToolResult( + await handleAgentControlToolCall( + { kernel, getOwnerId: () => "owner", callerSessionId: sessionId, executionRole: "coordinator" }, + "resolve_desktop_dispatch", + { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + }, + ), + ); + + expect(fromAdapter).toMatchObject({ ok: false, error: { code: "policy_denied" } }); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + expect(runStatus(store, runId).run).toBe("waiting_approval"); + expect(store.getRow("SELECT COUNT(*) AS count FROM events WHERE type = 'approval.resolved'").count).toBe(0); + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "cancelled", + resolution: {}, + }); + await finish(); + store.close(); + }); + + it("a tampered dispatch payload admits nothing", async () => { + const { store, kernel, outcome, finish } = await parkedSendMessage(); + const payload = JSON.parse(outcome.dispatch.payloadJson); + payload.invocation.inputHash = "sha256:somebody-elses-message"; + store.execute("UPDATE desktop_dispatches SET payload_json = ? WHERE dispatch_id = ?", [ + JSON.stringify(payload), + outcome.dispatch.dispatchId, + ]); + + const resolved = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + }), + ); + + expect(resolved.ok).toBe(false); + expect(store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + expect(() => kernel.markRunToolInvocationDispatched(outcome.invocation)).toThrow(); + store.execute("UPDATE desktop_dispatches SET payload_json = ? WHERE dispatch_id = ?", [ + outcome.dispatch.payloadJson, + outcome.dispatch.dispatchId, + ]); + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "deny" }, + }); + await finish(); + store.close(); + }); + + it("expiry fails the invocation closed as a system decision and resumes the run", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + const result = kernel.terminateDesktopToolApproval({ + dispatchId: outcome.dispatch.dispatchId, + status: "expired", + reason: "approval_wait_expired", + }); + + expect(result.dispatch).toMatchObject({ status: "expired", resolvedBy: "system" }); + expect(JSON.parse(result.event!.payloadJson)).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + invocationId: "send-1", + decision: "expired", + automatic: true, + resolvedBy: "system", + }); + expect(readToolInvocation(store, "send-1")).toMatchObject({ status: "failed", errorCode: "approval_expired" }); + expect(runStatus(store, runId)).toEqual({ run: "running", attempt: "running" }); + expect(() => kernel.terminateDesktopToolApproval({ + dispatchId: outcome.dispatch.dispatchId, + status: "expired", + reason: "approval_wait_expired", + })).toThrow("not pending"); + // A late user answer after expiry has nothing left to admit. + const late = parseToolResult( + await handleAgentControlToolCall(trustedOwnerContext(kernel), "resolve_desktop_dispatch", { + dispatchId: outcome.dispatch.dispatchId, + status: "resolved", + resolution: { decision: "allow" }, + }), + ); + expect(late.ok).toBe(false); + await finish(); + store.close(); + }); + + it("cancelling the run closes the pending approval with an audited resolution", async () => { + const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); + + await kernel.cancelRun(runId, { ownerId: "owner" }); + await finish(); + + expect(store.getRow("SELECT status, resolved_by FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId])) + .toEqual({ status: "cancelled", resolved_by: "system" }); + expect(readToolInvocation(store, "send-1").status).toBe("failed"); + const resolution = store.allRows( + "SELECT payload_json FROM events WHERE type = 'approval.resolved' AND run_id = ?", + [runId], + ); + expect(resolution).toHaveLength(1); + expect(JSON.parse(String(resolution[0]!.payload_json))).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + invocationId: "send-1", + decision: "cancelled", + automatic: true, + }); + store.close(); + }); + }); + it("requires verified approved dispatches for sensitive context packet snippets", async () => { const { store, kernel } = createKernelHarness(newDatabasePath()); const dispatch = kernel.createDesktopDispatch({ diff --git a/desktop/macos/agent/tests/fixtures/tool-manifest.json b/desktop/macos/agent/tests/fixtures/tool-manifest.json index 03a2ba3b85d..6e32561b29c 100644 --- a/desktop/macos/agent/tests/fixtures/tool-manifest.json +++ b/desktop/macos/agent/tests/fixtures/tool-manifest.json @@ -6184,11 +6184,7 @@ "runtimePreconditions": [ "Realtime voice only; requires Screen Recording permission." ], - "adapters": { - "pi-mono": { - "advertised": true - } - }, + "adapters": {}, "surfaces": [ "realtime_voice" ], diff --git a/desktop/macos/agent/tests/omi-tool-manifest.test.ts b/desktop/macos/agent/tests/omi-tool-manifest.test.ts index 465bcfe6c46..8a3cccb8bdb 100644 --- a/desktop/macos/agent/tests/omi-tool-manifest.test.ts +++ b/desktop/macos/agent/tests/omi-tool-manifest.test.ts @@ -118,7 +118,6 @@ describe("omi tool manifest", () => { "request_permission", "get_product_kb", "web_search", - "screenshot", "search_contacts", "list_message_chats", "read_message_history", @@ -129,8 +128,12 @@ describe("omi tool manifest", () => { expect(toolNamesForAdapter("pi-mono")).not.toContain("resolve_desktop_dispatch"); }); - it("keeps the realtime screenshot executor capability-registered", () => { - const screenshot = toolsForAdapter("pi-mono").find((tool) => tool.name === "screenshot"); + it("keeps the realtime screenshot executor on the realtime voice surface and off the chat adapters", () => { + // Realtime voice runs authorize it through their surface projection; a + // chat adapter advertising it would hand typed chat an ungated capture. + expect(toolsForAdapter("pi-mono").some((tool) => tool.name === "screenshot")).toBe(false); + expect(toolsForAdapter("omi-tools-stdio", { screenContext: true }).some((tool) => tool.name === "screenshot")).toBe(false); + const screenshot = toolsForSurface("realtime_voice").find((tool) => tool.name === "screenshot"); expect(screenshot?.surfaces).toEqual(["realtime_voice"]); expect(screenshot?.executor).toEqual({ kind: "swiftTool", executorName: "realtimeHub" }); diff --git a/desktop/macos/agent/tests/run-tool-capability.test.ts b/desktop/macos/agent/tests/run-tool-capability.test.ts index 0fdd9ea0a7c..575ce8b49b0 100644 --- a/desktop/macos/agent/tests/run-tool-capability.test.ts +++ b/desktop/macos/agent/tests/run-tool-capability.test.ts @@ -5,10 +5,14 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + DESKTOP_APPROVAL_TOOLS, RunToolCapabilityBroker, RunToolCapabilityRejectedError, + toolApprovalInvocationBinding, type AuthorizedRunToolInvocation, } from "../src/runtime/run-tool-capability.js"; +import { desktopToolPolicyInternals, evaluateDesktopToolPolicy } from "../src/runtime/desktop-tool-policy.js"; +import { allOmiToolManifest } from "../src/runtime/omi-tool-manifest.js"; import { SqliteAgentStore } from "../src/runtime/sqlite-store.js"; import { readToolInvocation } from "../src/runtime/tool-invocation-ledger.js"; import { readSessionExecutionProfile } from "../src/runtime/session-execution-profile.js"; @@ -591,6 +595,58 @@ describe("RunToolCapabilityBroker", () => { chat.store.close(); }); + it("offers the realtime voice screenshot only to realtime voice runs", () => { + const allowedFor = (surfaceKind: string) => { + const root = mkdtempSync(join(tmpdir(), "omi-capability-")); + roots.push(root); + const store = new SqliteAgentStore({ databasePath: join(root, "agent.sqlite"), reconcileOnOpen: false }); + const voice = surfaceKind === "realtime_voice"; + const session = store.insertSession({ + ownerId: "owner-1", + surfaceKind: voice ? "main_chat" : surfaceKind, + defaultAdapterId: "pi-mono", + executionRole: "coordinator", + }); + const run = store.insertRun({ + sessionId: session.sessionId, + clientId: "screen-client", + requestId: `screen-${surfaceKind}`, + status: "running", + mode: "act", + inputJson: JSON.stringify({ + prompt: "What is on my screen?", + admittedContextSnapshot: { sourceOutcomes: [{ source: "screen", outcome: "available" }] }, + ...(voice ? { metadata: { externalSurface: { authority: "swift_realtime", turnId: "turn-1" } } } : {}), + }), + }); + const attempt = store.insertAttempt({ + runId: run.runId, + attemptNo: 1, + status: "running", + adapterId: "pi-mono", + adapterInstanceId: "worker", + }); + const capability = createBroker(store).register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + store.close(); + return capability; + }; + + for (const surfaceKind of ["main_chat", "floating_bar", "floating_pill", "task_chat", "workstream"]) { + const capability = allowedFor(surfaceKind); + expect(capability.adapterId, surfaceKind).toBe("pi-mono"); + expect(capability.allowedToolNames, surfaceKind).toContain("capture_screen"); + expect(capability.allowedToolNames, surfaceKind).not.toContain("screenshot"); + } + const voice = allowedFor("realtime_voice"); + expect(voice.surfaceKind).toBe("realtime_voice"); + expect(voice.allowedToolNames).toContain("screenshot"); + }); + it("keeps capability state internal and revokes it at terminal attempt", () => { const { store, session, run, attempt } = fixture(); const broker = createBroker(store); @@ -827,6 +883,595 @@ describe("RunToolCapabilityBroker", () => { }); }); +describe("RunToolCapabilityBroker desktop tool approvals", () => { + const sendInput = { to: "+15551234567", text: "Running late" }; + + function parkedSend( + options: Omit[0], "store" | "profileForSession"> = {}, + ) { + const fx = fixture(); + const broker = createBroker(fx.store, options); + const capability = broker.register({ + ownerId: fx.session.ownerId, + sessionId: fx.session.sessionId, + runId: fx.run.runId, + attemptId: fx.attempt.attemptId, + }); + const outcome = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "send-1", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: fx.session.ownerId, + }); + if (outcome.kind !== "approval_required") throw new Error("expected the send to park"); + return { ...fx, broker, capability, outcome }; + } + + function dispatchRow(store: SqliteAgentStore, dispatchId: string) { + return store.getRow("SELECT * FROM desktop_dispatches WHERE dispatch_id = ?", [dispatchId]); + } + + function counts(store: SqliteAgentStore) { + return { + dispatches: Number(store.getRow("SELECT COUNT(*) AS count FROM desktop_dispatches").count), + ledger: Number(store.getRow("SELECT COUNT(*) AS count FROM tool_invocation_ledger").count), + }; + } + + it("never lets the model name the resource a grant covers", () => { + const { store, session, run, attempt } = fixture(); + const broker = createBroker(store); + const capability = broker.register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + const send = (invocationId: string, toolInput: Record) => + broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId, + toolName: "send_message", + toolInput, + activeOwnerId: session.ownerId, + }); + + // Reproduces the review finding: a smuggled resource_ref used to become + // the grant pattern, so one approval covered any later recipient that + // repeated the same ref. The manifest schema has no such field. + expectCode(() => send("forged-ref", { to: "bob", text: "hi", resource_ref: "x" }), "invalid_tool_input"); + expectCode(() => send("missing-text", { to: "bob" }), "invalid_tool_input"); + expectCode(() => send("wrong-type", { to: "bob", text: 42 }), "invalid_tool_input"); + expect(counts(store)).toEqual({ dispatches: 0, ledger: 0 }); + + // A session grant the user minted for bob covers bob and nobody else. + store.insertGrant({ + sessionId: session.sessionId, + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "bob", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 60_000, + }); + expect(send("to-bob", { to: "bob", text: "hi" }).kind).toBe("authorized"); + const mallory = send("to-mallory", { to: "mallory", text: "hi" }); + expect(mallory.kind).toBe("approval_required"); + if (mallory.kind === "approval_required") expect(mallory.dispatch.resourceRef).toBe("mallory"); + store.close(); + }); + + it("refuses to park an input the card cannot show in full", () => { + const { store, session, run, attempt } = fixture(); + const broker = createBroker(store); + const capability = broker.register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + + expectCode(() => broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "huge-script", + toolName: "run_applescript", + toolInput: { script: "display dialog \"hi\"\n".repeat(400) }, + activeOwnerId: session.ownerId, + }), "input_too_large_to_approve"); + // Nothing was prepared: the model shortens the input and calls again. + expect(counts(store)).toEqual({ dispatches: 0, ledger: 0 }); + store.close(); + }); + + it("keeps a hard policy deny rejected instead of parking it", () => { + const { store, session, run, attempt } = fixture(); + const broker = createBroker(store, { + desktopToolPolicy: (request) => ({ + ...evaluateDesktopToolPolicy(request), + decision: "deny", + reason: "denied by test policy", + }), + }); + const capability = broker.register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + + expectCode(() => broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "denied", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: session.ownerId, + }), "approval_required"); + expect(counts(store)).toEqual({ dispatches: 0, ledger: 0 }); + store.close(); + }); + + it("refuses an approval whose owner or attempt changed while the card was open", () => { + const cancelled: unknown[] = []; + const { store, session, run, attempt, broker, outcome } = parkedSend({ + onApprovalsCancelled: (closed) => cancelled.push(...closed), + }); + const binding = toolApprovalInvocationBinding(outcome.dispatch)!; + + // Another owner cannot answer this card, and asking revokes the capability. + expectCode( + () => broker.assertApprovalAuthority({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: "owner-2" }), + "owner_mismatch", + ); + expect(dispatchRow(store, outcome.dispatch.dispatchId)).toMatchObject({ status: "cancelled", resolved_by: "system" }); + expect(readToolInvocation(store, "send-1")).toMatchObject({ status: "failed", errorCode: "run_tool_owner_changed" }); + expect(cancelled).toEqual([{ + dispatchId: outcome.dispatch.dispatchId, + invocationId: "send-1", + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + reason: "owner_changed", + }]); + expectCode( + () => broker.approveInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: session.ownerId }), + "capability_revoked", + ); + store.close(); + }); + + it("refuses an approval once its attempt is no longer the live one", () => { + const { store, session, run, attempt, broker, outcome } = parkedSend(); + const binding = toolApprovalInvocationBinding(outcome.dispatch)!; + store.execute("UPDATE run_attempts SET status = 'failed' WHERE attempt_id = ?", [attempt.attemptId]); + store.insertAttempt({ runId: run.runId, attemptNo: 2, status: "running", adapterId: "acp", adapterInstanceId: "worker-2" }); + + expectCode( + () => broker.approveInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: session.ownerId }), + "attempt_terminal", + ); + expect(dispatchRow(store, outcome.dispatch.dispatchId).status).toBe("cancelled"); + expect(readToolInvocation(store, "send-1").status).toBe("failed"); + store.close(); + }); + + it("reports the approvals it closes on owner revocation", () => { + const cancelled: unknown[] = []; + const { store, session, run, broker, outcome } = parkedSend({ + onApprovalsCancelled: (closed) => cancelled.push(...closed), + }); + + expect(broker.revokeForOwner(session.ownerId)).toBe(1); + + expect(cancelled).toEqual([expect.objectContaining({ dispatchId: outcome.dispatch.dispatchId, reason: "owner_changed" })]); + expect(dispatchRow(store, outcome.dispatch.dispatchId).status).toBe("cancelled"); + expect(broker.hasPendingApprovals(run.runId)).toBe(false); + store.close(); + }); + + it("restores a released approval when the surrounding transaction rolls back", () => { + const { store, session, run, broker, outcome } = parkedSend(); + const binding = toolApprovalInvocationBinding(outcome.dispatch)!; + + const { released } = broker.approveInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: session.ownerId }); + expect(broker.hasPendingApprovals(run.runId)).toBe(false); + + broker.restorePendingApproval(released); + + expect(broker.hasPendingApprovals(run.runId)).toBe(true); + expectCode(() => broker.markInvocationDispatched(outcome.invocation), "approval_required"); + const again = broker.approveInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: session.ownerId }); + expect(again.record.invocationId).toBe("send-1"); + store.close(); + }); + + it("parks a sensitive send behind one approval dispatch bound to the prepared invocation", () => { + const { store, session, run, attempt, broker, outcome } = parkedSend({ nowMs: () => 5_000 }); + const { invocation, dispatch, request } = outcome; + + expect(invocation.canonicalToolName).toBe("send_message"); + expect(readToolInvocation(store, "send-1")).toMatchObject({ status: "prepared", toolName: "send_message" }); + expect(dispatch).toMatchObject({ + kind: "approval", + status: "pending", + ownerId: session.ownerId, + sourceSessionId: session.sessionId, + sourceRunId: run.runId, + sourceAttemptId: attempt.attemptId, + capability: "desktop.messaging.send", + operation: "send_message", + resourceRef: "+15551234567", + recommendedDefault: "deny", + expiresAtMs: 5_000 + request.expiresAtMs - request.requestedAtMs, + }); + expect(toolApprovalInvocationBinding(dispatch)).toMatchObject({ + invocationId: "send-1", + toolName: "send_message", + inputHash: invocation.inputHash, + daemonBootEpoch: broker.daemonBootEpoch, + }); + expect(JSON.parse(dispatch.payloadJson).preview).toEqual(sendInput); + expect(broker.hasPendingApprovals(run.runId)).toBe(true); + + // Possession of the invocation object is not authority to dispatch it. + expectCode(() => broker.markInvocationDispatched(invocation), "approval_required"); + expectCode(() => broker.acquireExecutionLease(invocation, () => session.ownerId), "approval_required"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + store.close(); + }); + + it("admits exactly the approved invocation once and nothing else", () => { + const { store, session, run, broker, outcome } = parkedSend(); + const binding = toolApprovalInvocationBinding(outcome.dispatch)!; + store.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: session.ownerId, + status: "resolved", + resolvedBy: "user", + resolutionJson: JSON.stringify({ decision: "allow" }), + }); + + const { record } = broker.approveInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: session.ownerId }); + expect(record).toMatchObject({ invocationId: "send-1", status: "prepared" }); + expect(broker.hasPendingApprovals(run.runId)).toBe(false); + broker.markInvocationDispatched(outcome.invocation); + expect(readToolInvocation(store, "send-1").status).toBe("dispatched"); + + // The same dispatch cannot admit anything a second time, and a fresh + // identical call is a new invocation that has to ask again. + expectCode( + () => broker.approveInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, activeOwnerId: session.ownerId }), + "capability_revoked", + ); + const again = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: outcome.invocation.capabilityRef, + invocationId: "send-2", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: session.ownerId, + }); + expect(again.kind).toBe("approval_required"); + expect(store.getRow("SELECT COUNT(*) AS count FROM grants").count).toBe(0); + store.close(); + }); + + it("rejects an approval whose binding no longer matches the prepared invocation", () => { + const { store, session, broker, outcome } = parkedSend(); + const binding = toolApprovalInvocationBinding(outcome.dispatch)!; + + expectCode( + () => broker.approveInvocation({ + dispatchId: outcome.dispatch.dispatchId, + binding: { ...binding, inputHash: "sha256:tampered" }, + activeOwnerId: session.ownerId, + }), + "invocation_replayed", + ); + expectCode( + () => broker.approveInvocation({ dispatchId: "disp_unknown", binding, activeOwnerId: session.ownerId }), + "capability_revoked", + ); + // A failed approval leaves the invocation parked, not admitted. + expectCode(() => broker.markInvocationDispatched(outcome.invocation), "approval_required"); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + store.close(); + }); + + it("fails a denied or expired invocation closed without ever dispatching it", () => { + const { store, session, run, broker, outcome } = parkedSend(); + const binding = toolApprovalInvocationBinding(outcome.dispatch)!; + + const denied = broker.denyInvocation({ dispatchId: outcome.dispatch.dispatchId, binding, code: "approval_denied" }).record; + expect(denied).toMatchObject({ status: "failed", errorCode: "approval_denied", dispatchedAtMs: null }); + expect(broker.hasPendingApprovals(run.runId)).toBe(false); + // The ledger row is terminal, so the dispatch transition has no prepared tuple to claim. + expect(() => broker.markInvocationDispatched(outcome.invocation)).toThrow("stale or already dispatched"); + expect(readToolInvocation(store, "send-1").status).toBe("failed"); + + // Denial does not poison the capability: the next sensitive call asks again. + const next = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: outcome.invocation.capabilityRef, + invocationId: "send-3", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: session.ownerId, + }); + expect(next.kind).toBe("approval_required"); + store.close(); + }); + + it("skips the dispatch when a covering scoped grant already exists", () => { + const { store, session, run, attempt } = fixture(); + const broker = createBroker(store, { nowMs: () => 10_000 }); + const capability = broker.register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + store.insertGrant({ + sessionId: session.sessionId, + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "+15551234567", + effect: "allow", + source: "user", + expiresAtMs: 20_000, + }); + + const covered = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "send-covered", + toolName: "send_message", + toolInput: sendInput, + activeOwnerId: session.ownerId, + }); + const otherRecipient = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "send-other", + toolName: "send_message", + toolInput: { ...sendInput, to: "+15550000000" }, + activeOwnerId: session.ownerId, + }); + + expect(covered.kind).toBe("authorized"); + expect(otherRecipient.kind).toBe("approval_required"); + expect(store.getRow("SELECT COUNT(*) AS count FROM desktop_dispatches").count).toBe(1); + store.close(); + }); + + it("gives list tools a stable resource so a session grant can name them", () => { + const { store, session, run, attempt } = fixture(); + const broker = createBroker(store); + const capability = broker.register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + const parked = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "mail-1", + toolName: "list_mail_messages", + toolInput: { limit: 10 }, + activeOwnerId: session.ownerId, + }); + if (parked.kind !== "approval_required") throw new Error("expected mail read to park"); + expect(parked.dispatch.resourceRef).toBe("mail:inbox"); + + store.insertGrant({ + sessionId: session.sessionId, + runId: null, + capability: "desktop.mail.read", + operation: "list_mail_messages", + resourcePattern: "mail:inbox", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 60_000, + }); + const covered = broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId: "mail-2", + toolName: "list_mail_messages", + toolInput: { limit: 10 }, + activeOwnerId: session.ownerId, + }); + expect(covered.kind).toBe("authorized"); + store.close(); + }); + + it("parks every live screenshot behind its own Allow Once card, and no grant covers the next one", () => { + const { store, session, run, attempt } = fixture(); + store.execute("UPDATE runs SET input_json = ? WHERE run_id = ?", [ + JSON.stringify({ + prompt: "What is on my screen?", + admittedContextSnapshot: { sourceOutcomes: [{ source: "screen", outcome: "available" }] }, + }), + run.runId, + ]); + const broker = createBroker(store); + const capability = broker.register({ + ownerId: session.ownerId, + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + }); + const capture = (invocationId: string) => broker.authorizeRelayInvocationOrRequestApproval({ + capabilityRef: capability.capabilityRef, + invocationId, + toolName: "capture_screen", + toolInput: {}, + activeOwnerId: session.ownerId, + }); + + const parked = capture("screen-1"); + if (parked.kind !== "approval_required") throw new Error("expected the screenshot to park"); + expect(readToolInvocation(store, "screen-1").status).toBe("prepared"); + expect(parked.dispatch).toMatchObject({ + kind: "approval", + status: "pending", + capability: "desktop.context.screenshot_image", + operation: "capture_screen", + resourceRef: "screen", + }); + expect(parked.request.title).toBe("Take a screenshot"); + expect(parked.request.options.map((option) => option.id)).toEqual(["allow_once", "deny"]); + expectCode(() => broker.markInvocationDispatched(parked.invocation), "approval_required"); + + store.insertGrant({ + sessionId: session.sessionId, + runId: null, + capability: "desktop.context.screenshot_image", + operation: "capture_screen", + resourcePattern: "screen", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 60_000, + }); + // Even a well-formed grant row for exactly this resource does not cover a capture. + expect(capture("screen-2").kind).toBe("approval_required"); + store.close(); + }); + + it("closes the pending dispatch when the attempt ends before the user answers", () => { + const cancelled: unknown[] = []; + const { store, session, run, attempt, broker, outcome } = parkedSend({ + onApprovalsCancelled: (closed) => cancelled.push(...closed), + }); + + broker.handleKernelEvent({ + eventId: "evt_terminal", + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + type: "attempt.cancelled", + retentionClass: "core", + visibility: "ui", + payloadJson: "{}", + createdAtMs: Date.now(), + }); + + expect(cancelled).toEqual([{ + dispatchId: outcome.dispatch.dispatchId, + invocationId: "send-1", + sessionId: session.sessionId, + runId: run.runId, + attemptId: attempt.attemptId, + reason: "attempt_terminal", + }]); + expect(dispatchRow(store, outcome.dispatch.dispatchId)).toMatchObject({ status: "cancelled", resolved_by: "system" }); + expect(readToolInvocation(store, "send-1")).toMatchObject({ status: "failed", errorCode: "run_tool_attempt_terminal" }); + expect(() => store.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: session.ownerId, + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + })).toThrow("not pending"); + store.close(); + }); + + it("never replays a parked invocation across a daemon restart", () => { + const { databasePath, store, session, run, outcome } = parkedSend({ daemonBootEpoch: "boot-before" }); + expect(readToolInvocation(store, "send-1").status).toBe("prepared"); + store.close(); + + const reopened = new SqliteAgentStore({ databasePath }); + expect(readToolInvocation(reopened, "send-1")).toMatchObject({ + status: "failed", + errorCode: "daemon_restart_before_dispatch", + retryPolicy: "never_auto_retry", + }); + expect(dispatchRow(reopened, outcome.dispatch.dispatchId)).toMatchObject({ + status: "expired", + resolved_by: "daemon_startup_reconciliation", + }); + const resolved = reopened.allRows( + "SELECT payload_json FROM events WHERE type = 'approval.resolved' AND run_id = ?", + [run.runId], + ); + expect(resolved).toHaveLength(1); + expect(JSON.parse(String(resolved[0]!.payload_json))).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + invocationId: "send-1", + decision: "expired", + automatic: true, + }); + // A user answer that arrives after the restart finds nothing to admit. + expect(() => reopened.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: session.ownerId, + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + })).toThrow("not pending"); + + // Reconciliation is idempotent: a second open records nothing new. + expect(reopened.reconcileStartup().expiredToolApprovalDispatchIds).toEqual([]); + expect(reopened.getRow("SELECT COUNT(*) AS count FROM events WHERE type = 'approval.resolved'").count).toBe(1); + reopened.close(); + }); +}); + +describe("desktop approval set and sensitive policy bundles", () => { + /** + * Relay-callable tools the policy classifies into a sensitive bundle that + * deliberately do not park behind the approval card. Each entry says why; + * a sensitive tool neither gated nor justified here fails the test. + */ + const UNGATED_SENSITIVE_TOOLS: Record = { + // The macOS permission prompt is itself the approval, and Swift already + // requires the person's current-turn consent before it opens. + request_permission: "native prompt plus current-turn consent", + // Scope: not one of the on-device tools issue #20938 gates. Safeguard: it + // only types into a Claude or ChatGPT "add custom connector" form that is + // already open in the person's signed-in browser. Risk it keeps: it takes + // an arbitrary server_url and can press the form's submit button. + fill_cloud_connector_form: "outside #20938's device tools; needs the connector form already open", + }; + + function relayCallable(toolName: string): boolean { + const tool = allOmiToolManifest.find((entry) => entry.name === toolName); + return Boolean(tool?.adapters["pi-mono"]?.advertised || tool?.adapters["omi-tools-stdio"]?.advertised); + } + + function sensitiveBundles(toolName: string): string[] { + const descriptor = desktopToolPolicyInternals.descriptorFromToolName(toolName); + return (descriptor?.bundles ?? []).filter(desktopToolPolicyInternals.isSensitiveBundle); + } + + const relayTools = [...new Set(allOmiToolManifest.map((tool) => tool.name))].filter(relayCallable); + + it("classifies every relay-callable tool on purpose, never by the read-only default", () => { + const unclassified = relayTools.filter((name) => !desktopToolPolicyInternals.isExplicitlyClassified(name)); + expect(unclassified).toEqual([]); + }); + + it("parks every relay-callable tool in a sensitive bundle, save the justified exceptions", () => { + const sensitive = relayTools.filter((name) => sensitiveBundles(name).length > 0); + expect(sensitive).toContain("capture_screen"); + const ungated = sensitive.filter((name) => !DESKTOP_APPROVAL_TOOLS.has(name)).sort(); + expect(ungated).toEqual(Object.keys(UNGATED_SENSITIVE_TOOLS).sort()); + }); + + it("keeps screen-image tools that are not gated off every chat relay", () => { + for (const name of ["get_screenshot", "screenshot"]) { + expect(sensitiveBundles(name), name).toEqual(["desktop.context.screenshot_image"]); + expect(relayCallable(name), name).toBe(false); + } + }); + + it("classifies show_rewind_evidence as stored-frame text, not pixels for the model", () => { + expect(desktopToolPolicyInternals.descriptorFromToolName("show_rewind_evidence")?.bundles) + .toEqual(["desktop.context.screen_summary"]); + }); + + it("keeps every gated tool sensitive and relay-callable, and the local-only exception off the relays", () => { + for (const name of DESKTOP_APPROVAL_TOOLS) { + expect(sensitiveBundles(name), name).not.toEqual([]); + expect(relayCallable(name), name).toBe(true); + } + expect(relayCallable("get_screenshot")).toBe(false); + }); +}); + describe("RunToolCapabilityBroker spawn-time tool policy", () => { it("intersects a spawn-time toolPolicy with the computed allowlist", () => { const { store, session, run, attempt } = fixture("leaf"); diff --git a/desktop/macos/agent/tests/sqlite-store.test.ts b/desktop/macos/agent/tests/sqlite-store.test.ts index 1634c90a43e..6c4af6f868c 100644 --- a/desktop/macos/agent/tests/sqlite-store.test.ts +++ b/desktop/macos/agent/tests/sqlite-store.test.ts @@ -1312,6 +1312,76 @@ describe("SqliteAgentStore", () => { store.close(); }); + it("expires invocation-bound approval dispatches on startup regardless of their TTL", () => { + const databasePath = newDatabasePath(); + const store = new SqliteAgentStore({ databasePath, reconcileOnOpen: false, nowMs: () => 1_000 }); + const session = store.insertSession({ ownerId: "owner", surfaceKind: "main_chat", defaultAdapterId: "acp" }); + const run = store.insertRun({ + sessionId: session.sessionId, + clientId: "client", + requestId: "request", + status: "waiting_approval", + mode: "act", + }); + const attempt = store.insertAttempt({ + runId: run.runId, + attemptNo: 1, + status: "waiting_approval", + adapterId: "acp", + adapterInstanceId: "worker", + }); + const bound = store.insertDesktopDispatch({ + ownerId: "owner", + kind: "approval", + priority: 100, + title: "Send a message", + decisionPrompt: "Send this message?", + sourceSessionId: session.sessionId, + sourceRunId: run.runId, + sourceAttemptId: attempt.attemptId, + capability: "desktop.messaging.send", + operation: "send_message", + resourceRef: "+15551234567", + payloadJson: JSON.stringify({ invocation: { invocationId: "inv-1", toolName: "send_message", inputHash: "sha256:x" } }), + expiresAtMs: 1_000 + 180_000, + }); + // An ordinary approval dispatch with no parked invocation keeps its own TTL. + const unbound = store.insertDesktopDispatch({ + ownerId: "owner", + kind: "approval", + priority: 1, + title: "Prepare artifact", + decisionPrompt: "Allow this prepared artifact?", + sourceSessionId: session.sessionId, + expiresAtMs: 1_000 + 180_000, + }); + store.close(); + + const reopened = new SqliteAgentStore({ databasePath, reconcileOnOpen: false, nowMs: () => 2_000 }); + const result = reopened.reconcileStartup(); + + expect(result.expiredToolApprovalDispatchIds).toEqual([bound.dispatchId]); + expect(result.orphanedAttemptIds).toEqual([attempt.attemptId]); + expect(reopened.getRow("SELECT status, resolved_by FROM desktop_dispatches WHERE dispatch_id = ?", [bound.dispatchId])) + .toEqual({ status: "expired", resolved_by: "daemon_startup_reconciliation" }); + expect(reopened.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [unbound.dispatchId]).status).toBe("pending"); + const event = reopened.getRow( + "SELECT run_id, attempt_id, payload_json FROM events WHERE type = 'approval.resolved'", + ); + expect(event).toMatchObject({ run_id: run.runId, attempt_id: attempt.attemptId }); + expect(JSON.parse(String(event.payload_json))).toMatchObject({ + approvalId: bound.dispatchId, + invocationId: "inv-1", + toolName: "send_message", + decision: "expired", + resolvedBy: "daemon_startup_reconciliation", + }); + + expect(reopened.reconcileStartup().expiredToolApprovalDispatchIds).toEqual([]); + expect(reopened.getRow("SELECT COUNT(*) AS count FROM events WHERE type = 'approval.resolved'").count).toBe(1); + reopened.close(); + }); + it("does not fail artifact lifecycle migration when columns already exist but the migration row is missing", () => { const store = newStore({ reconcileOnOpen: false }); store.execute("DELETE FROM schema_migrations WHERE version = ?", [2]); diff --git a/desktop/macos/changelog/unreleased/20261008-device-tool-approval-kernel.json b/desktop/macos/changelog/unreleased/20261008-device-tool-approval-kernel.json new file mode 100644 index 00000000000..bb56a275667 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20261008-device-tool-approval-kernel.json @@ -0,0 +1,3 @@ +{ + "kind": "none" +} From 269450c4cbdeb541ba52a82036f9ae026c73d350 Mon Sep 17 00:00:00 2001 From: Sauransh Bhardwaj Date: Thu, 8 Oct 2026 02:06:56 -0400 Subject: [PATCH 3/5] feat(desktop): relay approval_requested to Swift and resume parked tool calls The stdio relay no longer answers a parked device tool call immediately. A new relay-tool-approvals module, taking the kernel and the frame and result writers, sends Swift an approval_requested frame carrying the dispatch id, the bound invocation, the policy preview, the options and the expiry, keeps the relay call pending, and settles it from the kernel's approval.resolved event: an allow decision hands the invocation to Swift through the ordinary authorized_tool_execution path exactly once, every other outcome returns approval_denied to the model. Every resolution is mirrored to Swift as approval_resolved so a card can close whatever ended it, including owner changes. Expiry fires from the runtime, a disconnected relay client cancels its own dispatch, and terminal run events fail parked calls like any other pending tool call. The Swift hand-off itself moves into the same module so the whole relay side is covered by hermetic tests with injected timers. Clearing a chat through journal_clear_turns now ends what that chat granted before the journal is cleared: the kernel closes the session's parked approvals and revokes the grants the person minted for it, because the main chat keeps one kernel session across clears and an "Allow for This Chat" grant would otherwise cover the new conversation. No tool reaches this; only the app's own clear message does. Parking is on only after a client declares desktop_tool_approval_cards through the new client_capabilities message; until the Swift card ships no client does, and the relay keeps today's immediate approval_required. The runtime advertises desktop_tool_approval_requests so that client can require it first. Realtime voice keeps today's behavior because its Swift tool wait is 180s and would race the approval TTL. --- desktop/macos/agent/src/index.ts | 128 +++--- desktop/macos/agent/src/protocol.ts | 83 ++++ .../agent/src/runtime/relay-tool-approvals.ts | 394 ++++++++++++++++++ .../macos/agent/tests/control-tools.test.ts | 25 ++ desktop/macos/agent/tests/protocol-v2.test.ts | 70 ++++ .../agent/tests/relay-tool-approvals.test.ts | 370 ++++++++++++++++ .../tests/runtime-stdio-contract.test.ts | 71 ++++ 7 files changed, 1078 insertions(+), 63 deletions(-) create mode 100644 desktop/macos/agent/src/runtime/relay-tool-approvals.ts create mode 100644 desktop/macos/agent/tests/relay-tool-approvals.test.ts diff --git a/desktop/macos/agent/src/index.ts b/desktop/macos/agent/src/index.ts index 70c6da434de..38f023666fe 100644 --- a/desktop/macos/agent/src/index.ts +++ b/desktop/macos/agent/src/index.ts @@ -72,12 +72,14 @@ import type { JournalBackendReconcileResultMessage, ChatFirstDeferralDeliveryResultMessage, ChatFirstHarnessExecutorBeginMessage, + ClientCapabilitiesMessage, RefreshOwnerMessage, RevokeOwnerRuntimeMessage, ModelHeadersResultMessage, AuthMethod, } from "./protocol.js"; import { + CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS, PROTOCOL_VERSION, RUNTIME_CAPABILITIES, assertJournalRemoteTurnInput, @@ -125,6 +127,8 @@ import { import { providerBoundaryForAdapter } from "./runtime/execution-policy.js"; import { executionRoleForSurface } from "./runtime/execution-policy.js"; import type { AuthorizedRunToolInvocation, RunToolExecutionLease } from "./runtime/run-tool-capability.js"; +import type { DesktopToolApprovalRequest } from "./runtime/desktop-tool-policy.js"; +import { RelayToolApprovals } from "./runtime/relay-tool-approvals.js"; import { compactRealtimeSpawnToolResult, parseAgentSpawnProducerJournalDescriptor, @@ -324,6 +328,13 @@ const pendingExternalToolCalls = new Map< } >(); +/** + * Relay invocations parked behind a user approval, and the hand-off of every + * relay invocation to Swift. Created with the kernel; see + * `runtime/relay-tool-approvals.ts`. + */ +let relayToolApprovals: RelayToolApprovals | undefined; + /** * This exists solely for the local/offline desktop E2E fixture. Unlike the * external-surface bridge, it cannot accept a user/model-selected tool or @@ -805,6 +816,7 @@ function rejectPendingToolCallsForOwner( "failed", ); } + relayToolApprovals?.rejectForOwner(ownerId, errorCode, message); for (const [key, pending] of pendingExternalToolCalls) { if (pending.invocation.ownerId !== ownerId) continue; pendingExternalToolCalls.delete(key); @@ -891,6 +903,7 @@ function resolveClientToolCalls(client: Socket, result: string): void { } writeRelayToolResult(client, pending.callId, result, pending.invocation, "failed"); } + relayToolApprovals?.rejectForClient(client); } function relayError(code: string, message: string): string { @@ -992,8 +1005,9 @@ function startOmiToolsRelay(): Promise { ); continue; } - let authorized; + let authorized: AuthorizedRunToolInvocation; let routedProposal; + let approval: { dispatch: { dispatchId: string; expiresAtMs: number | null }; request: DesktopToolApprovalRequest } | undefined; try { routedProposal = runtimeKernel.routeRelayedRunToolProposal({ capabilityRef, @@ -1001,13 +1015,20 @@ function startOmiToolsRelay(): Promise { toolInput: msg.input ?? {}, activeOwnerId: currentOwnerId, }); - authorized = runtimeKernel.authorizeRelayedRunToolInvocation({ + // Sensitive device tools park here instead of failing: the + // kernel commits the dispatch, ledger row, and waiting_approval + // transition together, and the relay call waits for the user. + const outcome = runtimeKernel.authorizeRelayedRunToolInvocationOrRequestApproval({ capabilityRef, invocationId, toolName: routedProposal.toolName, toolInput: routedProposal.toolInput, activeOwnerId: currentOwnerId, }); + authorized = outcome.invocation; + if (outcome.kind === "approval_required") { + approval = { dispatch: outcome.dispatch, request: outcome.request }; + } } catch (error) { const code = error && typeof error === "object" && "code" in error ? String((error as { code: unknown }).code) @@ -1176,74 +1197,28 @@ function startOmiToolsRelay(): Promise { continue; } - const callId = msg.callId; - const pendingKey = toolCallPendingKey({ - invocationId, - }); - if (pendingToolCalls.has(pendingKey)) { + if (!relayToolApprovals) { writeRelayToolResult( client, - callId, - relayError("invocation_replayed", "Duplicate tool invocation"), + msg.callId, + relayError("runtime_not_ready", "Agent runtime kernel is not ready"), authorized, "failed", ); continue; } - - const timeout = setTimeout(() => { - const pending = pendingToolCalls.get(pendingKey); - if (!pending) return; - pendingToolCalls.delete(pendingKey); - try { - runtimeKernel?.markRunToolInvocationOutcomeUnknown(pending.invocation, "swift_tool_timeout"); - } catch (error) { - logErr(`Failed to mark timed-out tool invocation outcome unknown: ${error}`); - } - writeRelayToolResult( - pending.client, - pending.callId, - relayError("swift_tool_timeout", "Timed out waiting for the Swift tool executor"), - pending.invocation, - "failed", - ); - }, 120_000); - pendingToolCalls.set(pendingKey, { - client, - callId, - invocation: authorized, - timeout, - }); - runtimeKernel.markRunToolInvocationDispatched(authorized); - send({ - type: "authorized_tool_execution", - invocationId, - ownerId: authorized.ownerId, - sessionId: authorized.sessionId, - runId: authorized.runId, - attemptId: authorized.attemptId, - profileGeneration: authorized.profileGeneration, - manifestVersion: authorized.manifestVersion, - manifestDigest: authorized.manifestDigest, - daemonBootEpoch: authorized.daemonBootEpoch, - executionGeneration: authorized.executionGeneration, - capabilityRef: authorized.capabilityRef, - toolName: authorized.canonicalToolName, - input: routedProposal.toolInput, - inputHash: authorized.inputHash, - effectClass: authorized.effectClass, - retryPolicy: authorized.retryPolicy, - surfaceKind: authorized.surfaceKind, - externalRefKind: authorized.externalRefKind, - externalRefId: authorized.externalRefId, - originatingUserText: authorized.originatingUserText, - precedingAssistantText: authorized.precedingAssistantText, - runMode: authorized.runMode, - chatMode: authorized.chatMode, - ...(authorized.chatFirstControlGeneration !== null - ? { chatFirstControlGeneration: authorized.chatFirstControlGeneration } - : {}), - }); + if (approval) { + relayToolApprovals.park({ + client, + callId: msg.callId, + invocation: authorized, + toolInput: routedProposal.toolInput, + dispatch: approval.dispatch, + request: approval.request, + }); + continue; + } + relayToolApprovals.dispatchToSwift(client, msg.callId, authorized, routedProposal.toolInput); } } catch { logErr(`Failed to parse omi-tools message: ${line.slice(0, 200)}`); @@ -1698,6 +1673,15 @@ async function main(): Promise { }, }); kernel.subscribe(rejectPendingToolCallsForKernelEvent); + relayToolApprovals = new RelayToolApprovals({ + kernel, + send, + writeRelayToolResult, + pendingSwiftCalls: pendingToolCalls, + activeOwnerId: () => currentOwnerId, + log: logErr, + }); + kernel.subscribe((event) => relayToolApprovals?.handleKernelEvent(event)); runtimeKernel = kernel; let piMonoClasses: typeof import("./adapters/pi-mono.js") | undefined; const piMonoAdapters = new Set(); @@ -3540,6 +3524,11 @@ async function main(): Promise { externalRefKind: request.externalRefKind, externalRefId: request.externalRefId, }); + // A cleared chat is a new conversation on the same kernel session, so + // what the person allowed "for this chat" ends here: parked approvals + // close and their session grants are revoked before the clear, so a + // failed clear leaves fewer grants rather than more. + kernel.endChatApprovalScope({ ownerId, sessionId: resolved.agentSessionId, reason: "chat_cleared" }); const result = clearJournalConversation(store, { ownerId, conversationId: resolved.conversationId, @@ -3949,6 +3938,19 @@ async function main(): Promise { break; } + case "client_capabilities": { + // Interim gate for the device tool approval flow: only a client that + // can render the approval card may turn parking on. Without it the + // relay keeps today's immediate approval_required. The Swift card PR + // flips the default and removes this message. + const declared = msg as ClientCapabilitiesMessage; + const enabled = Array.isArray(declared.capabilities) + && declared.capabilities.includes(CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS); + kernel.setDesktopToolApprovalsEnabled(enabled); + logErr(`client_capabilities client=${declared.clientId ?? ""} desktopToolApprovals=${enabled ? "on" : "off"}`); + break; + } + case "refresh_owner": { const owner = msg as RefreshOwnerMessage; const transition = establishRuntimeOwner( diff --git a/desktop/macos/agent/src/protocol.ts b/desktop/macos/agent/src/protocol.ts index 81735241edc..0035a55b42a 100644 --- a/desktop/macos/agent/src/protocol.ts +++ b/desktop/macos/agent/src/protocol.ts @@ -10,6 +10,7 @@ export const RUNTIME_CAPABILITIES = [ "runtime_adapter_availability", "chat_first_capability_projection", "request_scoped_model_credentials", + "desktop_tool_approval_requests", ] as const; export type ProtocolVersion = typeof PROTOCOL_VERSION; @@ -632,6 +633,19 @@ export interface RefreshOwnerMessage { ownerId: string; } +/** + * A client declares that it renders device tool approval cards. Interim gate: + * until a client says so, the relay keeps today's immediate + * `approval_required` instead of parking a call nobody can answer. The Swift + * card PR turns parking on by default and deletes this capability. + */ +export const CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS = "desktop_tool_approval_cards"; + +export interface ClientCapabilitiesMessage extends ProtocolEnvelope { + type: "client_capabilities"; + capabilities: string[]; +} + /** * Local/offline-only E2E probe for the real Chat-first Swift tool executor. * The kernel derives capability from the already-resolved session; this message @@ -686,6 +700,7 @@ export type InboundMessage = | ChatFirstDeferralDeliveryResultMessage | ChatFirstHarnessExecutorBeginMessage | ModelHeadersResultMessage + | ClientCapabilitiesMessage | RefreshOwnerMessage; const INBOUND_RESPONSE_MESSAGE_TYPES = new Set([ @@ -773,6 +788,70 @@ export interface AuthorizedToolExecutionMessage extends OutboundEnvelope { policyRecovery?: "permission_delegation_to_native"; } +export interface ApprovalOption { + id: "allow_once" | "allow_session" | "deny"; + effect: "allow" | "deny"; + /** `run`: this invocation only; `session`: mints a scoped, expiring grant via `resolve_desktop_dispatch`. */ + scope: "run" | "session" | "request"; +} + +/** + * A sensitive device tool invocation is parked behind one `approval` dispatch. + * Swift renders it and answers through signed direct control + * (`resolve_desktop_dispatch` with `resolution.decision` and, for + * `allow_session`, a grant scoped to `capability`/`operation`/`resourceRef`). + * This frame carries no authority: only the resolved dispatch admits the + * invocation, and only for the exact `invocationId` + `inputHash` shown here. + */ +export interface ApprovalRequestedMessage extends OutboundEnvelope { + type: "approval_requested"; + approvalId: string; + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + invocationId: string; + adapterId: string; + surfaceKind: string; + policy: "default_user_approval"; + toolName: string; + capability: string; + operation: string; + resourceRef: string | null; + inputHash: string; + effectClass: "read_only" | "idempotent_write" | "non_idempotent_write"; + title: string; + decisionPrompt: string; + /** Bounded, display-safe projection of the tool input, never the raw input. */ + preview: Record; + previewTruncated: boolean; + reason: string; + options: ApprovalOption[]; + /** Always `deny` today; typed as an option id so a client never hardcodes the policy. */ + defaultOptionId: ApprovalOption["id"]; + requestedAtMs: number; + /** After this the runtime fails the invocation closed as `approval_denied`. */ + expiresAtMs: number; +} + +/** Every parked approval ends with exactly one of these, whatever ended it. */ +export interface ApprovalResolvedMessage extends OutboundEnvelope { + type: "approval_resolved"; + approvalId: string; + ownerId: string; + sessionId: string; + runId: string; + attemptId: string; + invocationId: string; + toolName: string; + decision: "allow" | "deny" | "expired" | "cancelled"; + selectedOptionId: string | null; + grantId: string | null; + resolvedBy: string; + resolvedAtMs: number; + automatic: boolean; +} + export interface ExternalAuthorityError { code: string; message: string; @@ -1349,6 +1428,8 @@ export type OutboundMessage = | AuthSuccessMessage | CancelAckMessage | AuthorizedToolExecutionMessage + | ApprovalRequestedMessage + | ApprovalResolvedMessage | ExternalSurfaceRunBeginResultMessage | ExternalSurfaceToolResultMessage | ExternalSurfaceRunCompleteResultMessage @@ -1390,6 +1471,8 @@ export type OutboundMessageDraft = | DraftEnvelope | DraftEnvelope | DraftEnvelope + | DraftEnvelope + | DraftEnvelope | DraftEnvelope | DraftEnvelope | DraftEnvelope diff --git a/desktop/macos/agent/src/runtime/relay-tool-approvals.ts b/desktop/macos/agent/src/runtime/relay-tool-approvals.ts new file mode 100644 index 00000000000..e77f6423902 --- /dev/null +++ b/desktop/macos/agent/src/runtime/relay-tool-approvals.ts @@ -0,0 +1,394 @@ +import type { OutboundMessageDraft } from "../protocol.js"; +import type { DesktopToolApprovalRequest } from "./desktop-tool-policy.js"; +import type { AuthorizedRunToolInvocation } from "./run-tool-capability.js"; +import type { AgentEvent, DesktopCoordinatorDispatch } from "./types.js"; + +/** + * The relay side of a parked device tool call. + * + * The kernel owns every decision: dispatch row, ledger row, run status, and + * the `approval.resolved` event that ends a card. This module only remembers + * which relay client is waiting for which invocation and turns the kernel's + * resolution into exactly one of two things: the ordinary Swift dispatch + * (`authorized_tool_execution`, once) or a failed `tool_result` for the model. + * It also mirrors every request and resolution to Swift as a frame. + * + * Nothing here survives a daemon restart on purpose: startup reconciliation + * fails the prepared ledger row and expires the dispatch, so a parked call is + * never replayed. + */ + +export type RelayToolOutcome = "succeeded" | "failed"; + +export interface PendingSwiftToolCall { + client: TClient; + callId: string; + invocation: AuthorizedRunToolInvocation; + timeout: unknown; +} + +export interface RelayToolApprovalKernel { + markRunToolInvocationDispatched(invocation: AuthorizedRunToolInvocation): void; + markRunToolInvocationOutcomeUnknown(invocation: AuthorizedRunToolInvocation, errorCode: string): void; + terminateDesktopToolApproval(input: { dispatchId: string; status: "expired" | "cancelled"; reason: string }): unknown; +} + +export interface RelayToolApprovalsOptions { + kernel: RelayToolApprovalKernel; + /** Writes an outbound frame to Swift. */ + send(message: OutboundMessageDraft): void; + /** Writes a finalized `tool_result` back to the relay client that asked. */ + writeRelayToolResult( + client: TClient, + callId: string, + result: string, + invocation?: AuthorizedRunToolInvocation, + outcome?: RelayToolOutcome, + ): void; + /** Swift executions in flight, shared with the daemon's result handler. */ + pendingSwiftCalls: Map>; + activeOwnerId(): string; + log(message: string): void; + nowMs?(): number; + setTimer?(callback: () => void, delayMs: number): unknown; + clearTimer?(handle: unknown): void; + /** How long Swift may take to return one authorized execution. */ + swiftTimeoutMs?: number; +} + +interface ParkedRelayCall { + approvalId: string; + client: TClient; + callId: string; + invocation: AuthorizedRunToolInvocation; + toolInput: Record; + expiry: unknown; +} + +const TERMINAL_RUN_TOOL_EVENTS = new Set([ + "run.succeeded", + "run.failed", + "run.cancelled", + "run.timed_out", + "run.orphaned", + "attempt.succeeded", + "attempt.failed", + "attempt.cancelled", + "attempt.timed_out", + "attempt.orphaned", +]); + +const APPROVAL_DECISIONS = new Set(["allow", "deny", "expired", "cancelled"]); + +export function relayToolError(code: string, message: string, extra: Record = {}): string { + return JSON.stringify({ ok: false, error: { code, message, ...extra } }); +} + +export class RelayToolApprovals { + private readonly kernel: RelayToolApprovalKernel; + private readonly send: RelayToolApprovalsOptions["send"]; + private readonly writeRelayToolResult: RelayToolApprovalsOptions["writeRelayToolResult"]; + private readonly pendingSwiftCalls: Map>; + private readonly activeOwnerId: () => string; + private readonly log: (message: string) => void; + private readonly nowMs: () => number; + private readonly setTimer: (callback: () => void, delayMs: number) => unknown; + private readonly clearTimer: (handle: unknown) => void; + private readonly swiftTimeoutMs: number; + private readonly parked = new Map>(); + + constructor(options: RelayToolApprovalsOptions) { + this.kernel = options.kernel; + this.send = options.send; + this.writeRelayToolResult = options.writeRelayToolResult; + this.pendingSwiftCalls = options.pendingSwiftCalls; + this.activeOwnerId = options.activeOwnerId; + this.log = options.log; + this.nowMs = options.nowMs ?? Date.now; + this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType)); + this.swiftTimeoutMs = options.swiftTimeoutMs ?? 120_000; + } + + isPending(invocationId: string): boolean { + return this.parked.has(invocationId) || this.pendingSwiftCalls.has(invocationId); + } + + /** + * Hold a relay call until the user answers its approval dispatch. The kernel + * already committed the dispatch, the prepared ledger row and the + * `waiting_approval` transition; this arms the expiry and shows the card. + */ + park(input: { + client: TClient; + callId: string; + invocation: AuthorizedRunToolInvocation; + toolInput: Record; + dispatch: Pick; + request: DesktopToolApprovalRequest; + }): void { + const { invocation, dispatch, request } = input; + const key = invocation.invocationId; + if (this.isPending(key)) { + this.writeRelayToolResult( + input.client, + input.callId, + relayToolError("invocation_replayed", "Duplicate tool invocation"), + invocation, + "failed", + ); + return; + } + const expiresAtMs = dispatch.expiresAtMs ?? request.expiresAtMs; + const expiry = this.setTimer(() => this.expire(key, dispatch.dispatchId), Math.max(0, expiresAtMs - this.nowMs())); + this.parked.set(key, { + approvalId: dispatch.dispatchId, + client: input.client, + callId: input.callId, + invocation, + toolInput: input.toolInput, + expiry, + }); + this.send({ + type: "approval_requested", + approvalId: dispatch.dispatchId, + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + invocationId: invocation.invocationId, + adapterId: invocation.adapterId, + surfaceKind: invocation.surfaceKind, + policy: request.policy, + toolName: invocation.canonicalToolName, + capability: request.capability, + operation: request.operation, + resourceRef: request.resourceRef, + inputHash: invocation.inputHash, + effectClass: invocation.effectClass, + title: request.title, + decisionPrompt: request.decisionPrompt, + preview: request.preview, + previewTruncated: request.previewTruncated, + reason: request.reason, + options: request.options.map((option) => ({ ...option })), + defaultOptionId: request.defaultOptionId, + requestedAtMs: request.requestedAtMs, + expiresAtMs, + }); + } + + /** Hand an authorized relay invocation to Swift and wait for its single result. */ + dispatchToSwift( + client: TClient, + callId: string, + invocation: AuthorizedRunToolInvocation, + toolInput: Record, + ): void { + const key = invocation.invocationId; + if (this.isPending(key)) { + this.writeRelayToolResult(client, callId, relayToolError("invocation_replayed", "Duplicate tool invocation"), invocation, "failed"); + return; + } + try { + // The broker refuses this while the invocation is still awaiting approval. + this.kernel.markRunToolInvocationDispatched(invocation); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code: unknown }).code) + : "capability_rejected"; + this.writeRelayToolResult( + client, + callId, + relayToolError(code, error instanceof Error ? error.message : "Tool dispatch rejected"), + invocation, + "failed", + ); + return; + } + const timeout = this.setTimer(() => { + const pending = this.pendingSwiftCalls.get(key); + if (!pending) return; + this.pendingSwiftCalls.delete(key); + try { + this.kernel.markRunToolInvocationOutcomeUnknown(pending.invocation, "swift_tool_timeout"); + } catch (error) { + this.log(`Failed to mark timed-out tool invocation outcome unknown: ${error}`); + } + this.writeRelayToolResult( + pending.client, + pending.callId, + relayToolError("swift_tool_timeout", "Timed out waiting for the Swift tool executor"), + pending.invocation, + "failed", + ); + }, this.swiftTimeoutMs); + this.pendingSwiftCalls.set(key, { client, callId, invocation, timeout }); + this.send({ + type: "authorized_tool_execution", + invocationId: invocation.invocationId, + ownerId: invocation.ownerId, + sessionId: invocation.sessionId, + runId: invocation.runId, + attemptId: invocation.attemptId, + profileGeneration: invocation.profileGeneration, + manifestVersion: invocation.manifestVersion, + manifestDigest: invocation.manifestDigest, + daemonBootEpoch: invocation.daemonBootEpoch, + executionGeneration: invocation.executionGeneration, + capabilityRef: invocation.capabilityRef, + toolName: invocation.canonicalToolName, + input: toolInput, + inputHash: invocation.inputHash, + effectClass: invocation.effectClass, + retryPolicy: invocation.retryPolicy, + surfaceKind: invocation.surfaceKind, + externalRefKind: invocation.externalRefKind, + externalRefId: invocation.externalRefId, + originatingUserText: invocation.originatingUserText, + precedingAssistantText: invocation.precedingAssistantText, + runMode: invocation.runMode, + chatMode: invocation.chatMode, + ...(invocation.chatFirstControlGeneration !== null + ? { chatFirstControlGeneration: invocation.chatFirstControlGeneration } + : {}), + }); + } + + /** + * Every approval ends with exactly one `approval.resolved`, whoever ended it + * (user, expiry, cancellation, run termination, owner change). Swift gets + * the matching frame; a parked call dispatches once or fails closed. + */ + handleKernelEvent(event: AgentEvent): void { + if (TERMINAL_RUN_TOOL_EVENTS.has(event.type)) { + this.rejectForTerminalEvent(event); + return; + } + if (event.type !== "approval.resolved") return; + const payload = parseObject(event.payloadJson); + const invocationId = typeof payload?.invocationId === "string" ? payload.invocationId : ""; + const approvalId = typeof payload?.approvalId === "string" ? payload.approvalId : ""; + if (!payload || !invocationId || !approvalId || !event.runId || !event.attemptId) return; + const decision = typeof payload.decision === "string" && APPROVAL_DECISIONS.has(payload.decision) + ? (payload.decision as "allow" | "deny" | "expired" | "cancelled") + : "deny"; + const pending = this.parked.get(invocationId); + this.send({ + type: "approval_resolved", + approvalId, + ownerId: pending?.invocation.ownerId ?? this.activeOwnerId(), + sessionId: event.sessionId, + runId: event.runId, + attemptId: event.attemptId, + invocationId, + toolName: typeof payload.toolName === "string" ? payload.toolName : pending?.invocation.canonicalToolName ?? "", + decision, + selectedOptionId: typeof payload.selectedOptionId === "string" ? payload.selectedOptionId : null, + grantId: typeof payload.grantId === "string" ? payload.grantId : null, + resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : "system", + resolvedAtMs: typeof payload.resolvedAtMs === "number" ? payload.resolvedAtMs : event.createdAtMs, + automatic: payload.automatic === true, + }); + if (!pending || pending.approvalId !== approvalId) return; + this.parked.delete(invocationId); + this.clearTimer(pending.expiry); + if (decision === "allow") { + this.dispatchToSwift(pending.client, pending.callId, pending.invocation, pending.toolInput); + return; + } + const message = decision === "deny" + ? "The user declined this action." + : decision === "expired" + ? "The approval request expired before the user answered." + : "The approval request was cancelled before the user answered."; + this.writeRelayToolResult( + pending.client, + pending.callId, + relayToolError("approval_denied", message, { reason: decision }), + pending.invocation, + "failed", + ); + } + + /** The owner changed or the runtime is stopping: parked calls fail like any other pending call. */ + rejectForOwner(ownerId: string, code: string, message: string): void { + for (const [key, pending] of this.parked) { + if (pending.invocation.ownerId !== ownerId) continue; + this.parked.delete(key); + this.clearTimer(pending.expiry); + this.writeRelayToolResult(pending.client, pending.callId, relayToolError(code, message), pending.invocation, "failed"); + } + } + + /** + * The relay client that was waiting went away. Nobody can receive the + * answer, so the kernel closes the card and fails the invocation; there is + * no client left to write a result to. + */ + rejectForClient(client: TClient): void { + for (const [key, pending] of this.parked) { + if (pending.client !== client) continue; + this.parked.delete(key); + this.clearTimer(pending.expiry); + try { + this.kernel.terminateDesktopToolApproval({ + dispatchId: pending.approvalId, + status: "cancelled", + reason: "relay_client_disconnected", + }); + } catch (error) { + this.log(`Failed to cancel approval ${pending.approvalId} for disconnected relay client: ${error}`); + } + } + } + + private rejectForTerminalEvent(event: AgentEvent): void { + if (!event.runId) return; + const code = event.type.startsWith("attempt.") ? "attempt_terminal" : "run_terminal"; + for (const [key, pending] of this.parked) { + if (pending.invocation.runId !== event.runId) continue; + if (event.attemptId && pending.invocation.attemptId !== event.attemptId) continue; + // The broker already cancelled the dispatch row and failed the prepared + // ledger row; the kernel's derived approval.resolved follows this event. + this.parked.delete(key); + this.clearTimer(pending.expiry); + this.writeRelayToolResult( + pending.client, + pending.callId, + relayToolError(code, "Run tool authority ended before the user answered the approval"), + pending.invocation, + "failed", + ); + } + } + + private expire(key: string, dispatchId: string): void { + const pending = this.parked.get(key); + if (!pending) return; + try { + // The kernel's approval.resolved event settles the relay call. + this.kernel.terminateDesktopToolApproval({ dispatchId, status: "expired", reason: "approval_wait_expired" }); + } catch (error) { + this.log(`Failed to expire approval ${dispatchId}: ${error}`); + if (!this.parked.has(key)) return; + this.parked.delete(key); + this.writeRelayToolResult( + pending.client, + pending.callId, + relayToolError("approval_denied", "The approval request expired before the user answered.", { reason: "expired" }), + pending.invocation, + "failed", + ); + } + } +} + +function parseObject(json: string): Record | null { + try { + const parsed = JSON.parse(json) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : null; + } catch { + return null; + } +} diff --git a/desktop/macos/agent/tests/control-tools.test.ts b/desktop/macos/agent/tests/control-tools.test.ts index 3c8e62cc591..b7cd43ddc05 100644 --- a/desktop/macos/agent/tests/control-tools.test.ts +++ b/desktop/macos/agent/tests/control-tools.test.ts @@ -1219,6 +1219,31 @@ describe("agent control tools", () => { store.close(); }); + it("gives the model no tool that clears a chat or ends its grants", async () => { + const { store, kernel, sessionId, finish } = await liveSendMessageRun("no-tool"); + const minted = store.insertGrant({ + sessionId, + runId: null, + capability: "desktop.messaging.send", + operation: "send_message", + resourcePattern: "+15551234567", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 60 * 60_000, + }); + // Only the app's own journal_clear_turns message ends a chat's grants; + // neither name is a tool, even with trusted direct control. + for (const name of ["journal_clear_turns", "end_chat_approval_scope"]) { + for (const context of [ownerContext(kernel), trustedOwnerContext(kernel)]) { + const result = parseToolResult(await handleAgentControlToolCall(context, name, { sessionId })); + expect(result.error.code).toBe("unknown_control_tool"); + } + } + expect(store.getRow("SELECT revoked_at_ms FROM grants WHERE grant_id = ?", [minted.grantId]).revoked_at_ms).toBeNull(); + await finish(); + store.close(); + }); + it("caps a session grant at 24 hours", async () => { const { store, kernel, outcome, runId, finish } = await parkedSendMessage(); diff --git a/desktop/macos/agent/tests/protocol-v2.test.ts b/desktop/macos/agent/tests/protocol-v2.test.ts index e32788abf2f..4eb88c6acbb 100644 --- a/desktop/macos/agent/tests/protocol-v2.test.ts +++ b/desktop/macos/agent/tests/protocol-v2.test.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { + ApprovalRequestedMessage, + ApprovalResolvedMessage, AuthorizedToolExecutionMessage, + ClientCapabilitiesMessage, AuthorizedToolExecutionResultMessage, CancelAckMessage, ControlToolResultMessage, @@ -27,7 +30,9 @@ import type { JournalBackendSyncMessage, } from "../src/protocol.js"; import { + CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS, PROTOCOL_VERSION, + RUNTIME_CAPABILITIES, assertPublicJournalRecordAuthority, assertPublicJournalUpdateAuthority, isInboundResponseMessage, @@ -414,6 +419,71 @@ describe("protocol v2", () => { expect([began, invoked, completed] satisfies OutboundMessage[]).toHaveLength(3); }); + it("types the device-tool approval card and its single resolution as outbound frames", () => { + const requested: ApprovalRequestedMessage = { + type: "approval_requested", + protocolVersion: PROTOCOL_VERSION, + approvalId: "disp_1", + ownerId: "owner-1", + sessionId: "ses_1", + runId: "run_1", + attemptId: "att_1", + invocationId: "inv_1", + adapterId: "pi-mono", + surfaceKind: "main_chat", + policy: "default_user_approval", + toolName: "send_message", + capability: "desktop.messaging.send", + operation: "send_message", + resourceRef: "+15551234567", + inputHash: "sha256:abc", + effectClass: "non_idempotent_write", + title: "Send a message", + decisionPrompt: "Send this message to +15551234567?", + preview: { to: "+15551234567", text: "Running late" }, + previewTruncated: false, + reason: "Sensitive action requires dispatch or scoped grant.", + options: [ + { id: "allow_once", effect: "allow", scope: "run" }, + { id: "allow_session", effect: "allow", scope: "session" }, + { id: "deny", effect: "deny", scope: "request" }, + ], + defaultOptionId: "deny", + requestedAtMs: 1, + expiresAtMs: 180_001, + }; + const resolved: ApprovalResolvedMessage = { + type: "approval_resolved", + protocolVersion: PROTOCOL_VERSION, + approvalId: requested.approvalId, + ownerId: requested.ownerId, + sessionId: requested.sessionId, + runId: requested.runId, + attemptId: requested.attemptId, + invocationId: requested.invocationId, + toolName: requested.toolName, + decision: "allow", + selectedOptionId: "allow_once", + grantId: null, + resolvedBy: "user", + resolvedAtMs: 2, + automatic: false, + }; + expect([requested, resolved] satisfies OutboundMessage[]).toHaveLength(2); + // Swift can require this before it offers the approval card. + expect(RUNTIME_CAPABILITIES).toContain("desktop_tool_approval_requests"); + // And it opts the relay into parking only once it can render that card. + const declared: ClientCapabilitiesMessage = { + type: "client_capabilities", + protocolVersion: PROTOCOL_VERSION, + requestId: "caps-1", + clientId: "desktop-shell", + capabilities: [CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS], + }; + expect([declared] satisfies InboundMessage[]).toHaveLength(1); + expect(CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS).toBe("desktop_tool_approval_cards"); + }); + it("keeps removed capability and dual-writer wire names absent", () => { const here = dirname(fileURLToPath(import.meta.url)); const protocol = readFileSync(join(here, "../src/protocol.ts"), "utf8"); diff --git a/desktop/macos/agent/tests/relay-tool-approvals.test.ts b/desktop/macos/agent/tests/relay-tool-approvals.test.ts new file mode 100644 index 00000000000..02ea15b2dae --- /dev/null +++ b/desktop/macos/agent/tests/relay-tool-approvals.test.ts @@ -0,0 +1,370 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { OutboundMessageDraft } from "../src/protocol.js"; +import { RelayToolApprovals, type PendingSwiftToolCall } from "../src/runtime/relay-tool-approvals.js"; +import type { AuthorizedRunToolInvocation } from "../src/runtime/run-tool-capability.js"; +import { readToolInvocation } from "../src/runtime/tool-invocation-ledger.js"; +import { createKernelHarness, waitUntil } from "./kernel-fakes.js"; + +const createdDirs: string[] = []; + +afterEach(() => { + for (const dir of createdDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +interface FakeClient { + name: string; +} + +interface WrittenResult { + client: FakeClient; + callId: string; + result: Record; + invocationId: string | undefined; + outcome: string | undefined; +} + +/** Deterministic timers: nothing fires until the test says so. */ +class ManualTimers { + private next = 1; + readonly scheduled = new Map void; delayMs: number }>(); + + set = (callback: () => void, delayMs: number): unknown => { + const handle = this.next++; + this.scheduled.set(handle, { callback, delayMs }); + return handle; + }; + + clear = (handle: unknown): void => { + this.scheduled.delete(handle as number); + }; + + fireAll(): void { + for (const [handle, timer] of [...this.scheduled]) { + this.scheduled.delete(handle); + timer.callback(); + } + } +} + +const sendInput = { to: "+15551234567", text: "Running late" }; + +async function relayFixture() { + const dir = mkdtempSync(join(tmpdir(), "omi-relay-approvals-")); + createdDirs.push(dir); + const harness = createKernelHarness(join(dir, "omi-agentd.sqlite3"), "acp"); + const { store, adapter, kernel } = harness; + kernel.setDesktopToolApprovalsEnabled(true); + const resolved = kernel.resolveSurfaceSession({ + ownerId: "owner", + surfaceRef: { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "relay-approvals" }, + defaultAdapterId: "acp", + }); + adapter.deferResult(); + const runPromise = kernel.executeRun({ + ownerId: "owner", + sessionId: resolved.agentSessionId, + surfaceKind: "main_chat", + externalRefKind: "chat", + externalRefId: "relay-approvals", + defaultAdapterId: "acp", + adapterId: "acp", + clientId: "relay-client", + requestId: "relay-request", + prompt: "Tell Alice I'm running late", + cwd: "/tmp/relay-approvals", + admittedContextSnapshot: kernel.contextSnapshot(resolved.agentSessionId, "owner", "main_chat"), + }); + await waitUntil(() => adapter.executed.length === 1); + const capabilityRef = adapter.executed[0]!.toolCapabilityRef; + + const sent: OutboundMessageDraft[] = []; + const written: WrittenResult[] = []; + const timers = new ManualTimers(); + const pendingSwiftCalls = new Map>(); + const relay = new RelayToolApprovals({ + kernel, + send: (message) => sent.push(message), + writeRelayToolResult: (client, callId, result, invocation, outcome) => + written.push({ client, callId, result: JSON.parse(result), invocationId: invocation?.invocationId, outcome }), + pendingSwiftCalls, + activeOwnerId: () => "owner", + log: () => undefined, + setTimer: timers.set, + clearTimer: timers.clear, + }); + kernel.subscribe((event) => relay.handleKernelEvent(event)); + + const park = (client: FakeClient, invocationId: string, toolInput: Record = sendInput) => { + const outcome = kernel.authorizeRelayedRunToolInvocationOrRequestApproval({ + capabilityRef, + invocationId, + toolName: "send_message", + toolInput, + activeOwnerId: "owner", + }); + if (outcome.kind !== "approval_required") throw new Error("expected send_message to park"); + relay.park({ + client, + callId: `call-${invocationId}`, + invocation: outcome.invocation, + toolInput, + dispatch: outcome.dispatch, + request: outcome.request, + }); + return outcome; + }; + const finish = async () => { + adapter.resolveDeferred({ terminalStatus: "completed", text: "done" }); + await runPromise; + }; + const frames = (type: string) => sent.filter((message) => message.type === type); + return { store, kernel, relay, sent, written, timers, pendingSwiftCalls, park, frames, finish, capabilityRef }; +} + +describe("relay tool approvals", () => { + it("shows the card, then an allow dispatches to Swift exactly once", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + + expect(fx.frames("approval_requested")).toHaveLength(1); + expect(fx.frames("approval_requested")[0]).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + invocationId: "send-1", + toolName: "send_message", + preview: sendInput, + defaultOptionId: "deny", + }); + expect(fx.frames("authorized_tool_execution")).toHaveLength(0); + expect(fx.written).toHaveLength(0); + expect(fx.timers.scheduled.size).toBe(1); + + fx.kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolvedBy: "user", + resolutionJson: JSON.stringify({ decision: "allow" }), + }); + + expect(fx.frames("approval_resolved")).toHaveLength(1); + expect(fx.frames("approval_resolved")[0]).toMatchObject({ approvalId: outcome.dispatch.dispatchId, decision: "allow", selectedOptionId: "allow_once" }); + const executions = fx.frames("authorized_tool_execution"); + expect(executions).toHaveLength(1); + expect(executions[0]).toMatchObject({ invocationId: "send-1", toolName: "send_message", input: sendInput, inputHash: outcome.invocation.inputHash }); + expect(fx.pendingSwiftCalls.get("send-1")).toMatchObject({ client, callId: "call-send-1" }); + expect(readToolInvocation(fx.store, "send-1").status).toBe("dispatched"); + // The expiry timer is gone; the Swift execution timer took its place. + expect(fx.timers.scheduled.size).toBe(1); + expect(fx.written).toHaveLength(0); + + // A second resolution of the same card changes nothing. + expect(() => fx.kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + })).toThrow(); + expect(fx.frames("authorized_tool_execution")).toHaveLength(1); + await fx.finish(); + fx.store.close(); + }); + + it("a deny answers the model with approval_denied and never reaches Swift", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + + fx.kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolvedBy: "user", + resolutionJson: JSON.stringify({ decision: "deny" }), + }); + + expect(fx.frames("authorized_tool_execution")).toHaveLength(0); + expect(fx.frames("approval_resolved")[0]).toMatchObject({ decision: "deny", selectedOptionId: "deny" }); + expect(fx.written).toEqual([{ + client, + callId: "call-send-1", + result: { ok: false, error: { code: "approval_denied", message: "The user declined this action.", reason: "deny" } }, + invocationId: "send-1", + outcome: "failed", + }]); + expect(fx.timers.scheduled.size).toBe(0); + expect(readToolInvocation(fx.store, "send-1")).toMatchObject({ status: "failed", errorCode: "approval_denied" }); + await fx.finish(); + fx.store.close(); + }); + + it("expiry fails the call closed as a system decision", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + + fx.timers.fireAll(); + + expect(fx.frames("authorized_tool_execution")).toHaveLength(0); + expect(fx.frames("approval_resolved")[0]).toMatchObject({ + approvalId: outcome.dispatch.dispatchId, + decision: "expired", + automatic: true, + resolvedBy: "system", + }); + expect(fx.written[0]).toMatchObject({ + callId: "call-send-1", + result: { ok: false, error: { code: "approval_denied", reason: "expired" } }, + outcome: "failed", + }); + expect(fx.store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [outcome.dispatch.dispatchId]).status).toBe("expired"); + expect(readToolInvocation(fx.store, "send-1")).toMatchObject({ status: "failed", errorCode: "approval_expired" }); + // A late answer after expiry finds nothing to admit. + expect(() => fx.kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + })).toThrow("not pending"); + await fx.finish(); + fx.store.close(); + }); + + it("a disconnected relay client closes its own card without writing anywhere", async () => { + const fx = await relayFixture(); + const gone: FakeClient = { name: "gone" }; + const staying: FakeClient = { name: "staying" }; + const first = fx.park(gone, "send-1"); + const second = fx.park(staying, "send-2", { ...sendInput, text: "Still here" }); + + fx.relay.rejectForClient(gone); + + expect(fx.store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [first.dispatch.dispatchId]).status).toBe("cancelled"); + expect(fx.store.getRow("SELECT status FROM desktop_dispatches WHERE dispatch_id = ?", [second.dispatch.dispatchId]).status).toBe("pending"); + expect(readToolInvocation(fx.store, "send-1")).toMatchObject({ status: "failed", errorCode: "approval_cancelled" }); + expect(readToolInvocation(fx.store, "send-2").status).toBe("prepared"); + // Nobody is left to receive a result for the disconnected client. + expect(fx.written).toHaveLength(0); + expect(fx.frames("approval_resolved")).toEqual([ + expect.objectContaining({ approvalId: first.dispatch.dispatchId, decision: "cancelled" }), + ]); + // The other client's card is still live and still answerable. + fx.kernel.resolveDesktopDispatch(second.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + }); + expect(fx.frames("authorized_tool_execution")).toEqual([expect.objectContaining({ invocationId: "send-2" })]); + await fx.finish(); + fx.store.close(); + }); + + it("a duplicate invocation id is refused while the first is parked or in flight", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + + // The broker already refuses a replayed invocation id; the relay also does + // when handed the same invocation twice. + fx.relay.park({ + client, + callId: "call-dup", + invocation: outcome.invocation, + toolInput: sendInput, + dispatch: outcome.dispatch, + request: outcome.request, + }); + expect(fx.written).toEqual([expect.objectContaining({ + callId: "call-dup", + result: { ok: false, error: { code: "invocation_replayed", message: "Duplicate tool invocation" } }, + })]); + expect(fx.frames("approval_requested")).toHaveLength(1); + + fx.kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + }); + expect(fx.frames("authorized_tool_execution")).toHaveLength(1); + fx.relay.dispatchToSwift(client, "call-dup-2", outcome.invocation, sendInput); + expect(fx.written).toHaveLength(2); + expect(fx.written[1]!.result).toMatchObject({ error: { code: "invocation_replayed" } }); + expect(fx.frames("authorized_tool_execution")).toHaveLength(1); + await fx.finish(); + fx.store.close(); + }); + + it("run cancellation fails the parked call and the card ends with one resolution", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + + await fx.kernel.cancelRun(outcome.invocation.runId, { ownerId: "owner" }); + await fx.finish(); + + expect(fx.written).toEqual([expect.objectContaining({ + callId: "call-send-1", + result: { ok: false, error: expect.objectContaining({ code: expect.stringMatching(/run_terminal|attempt_terminal/) }) }, + outcome: "failed", + })]); + expect(fx.frames("approval_resolved")).toEqual([ + expect.objectContaining({ approvalId: outcome.dispatch.dispatchId, decision: "cancelled", automatic: true }), + ]); + expect(fx.frames("authorized_tool_execution")).toHaveLength(0); + expect(fx.timers.scheduled.size).toBe(0); + fx.store.close(); + }); + + it("an owner change ends the card with one frame and one failed result, before anything else", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + + fx.kernel.revokeRunToolCapabilitiesForOwner("owner", "owner_changed"); + + // The resolution reached Swift as soon as the revocation happened, not on + // some later event. + expect(fx.frames("approval_resolved")).toEqual([ + expect.objectContaining({ approvalId: outcome.dispatch.dispatchId, decision: "cancelled", automatic: true }), + ]); + expect(fx.written).toEqual([expect.objectContaining({ + callId: "call-send-1", + result: { ok: false, error: { code: "approval_denied", message: expect.any(String), reason: "cancelled" } }, + outcome: "failed", + })]); + expect(fx.frames("authorized_tool_execution")).toHaveLength(0); + expect(fx.timers.scheduled.size).toBe(0); + // The daemon's owner sweep afterwards finds nothing left to fail twice. + fx.relay.rejectForOwner("owner", "owner_changed", "Active owner changed during tool execution"); + expect(fx.written).toHaveLength(1); + await fx.finish(); + fx.store.close(); + }); + + it("a Swift execution that never answers becomes outcome_unknown and fails the model's call", async () => { + const fx = await relayFixture(); + const client: FakeClient = { name: "pi-mono" }; + const outcome = fx.park(client, "send-1"); + fx.kernel.resolveDesktopDispatch(outcome.dispatch.dispatchId, { + ownerId: "owner", + status: "resolved", + resolutionJson: JSON.stringify({ decision: "allow" }), + }); + expect(readToolInvocation(fx.store, "send-1").status).toBe("dispatched"); + + fx.timers.fireAll(); + + expect(fx.pendingSwiftCalls.size).toBe(0); + expect(readToolInvocation(fx.store, "send-1")).toMatchObject({ status: "outcome_unknown", errorCode: "swift_tool_timeout" }); + expect(fx.written).toEqual([expect.objectContaining({ + result: { ok: false, error: { code: "swift_tool_timeout", message: "Timed out waiting for the Swift tool executor" } }, + outcome: "failed", + })]); + await fx.finish(); + fx.store.close(); + }); +}); + +/** Compile-time check that the relay accepts the kernel's invocation type unchanged. */ +export type RelayInvocation = AuthorizedRunToolInvocation; diff --git a/desktop/macos/agent/tests/runtime-stdio-contract.test.ts b/desktop/macos/agent/tests/runtime-stdio-contract.test.ts index c26262e910e..f67ecbfe9c7 100644 --- a/desktop/macos/agent/tests/runtime-stdio-contract.test.ts +++ b/desktop/macos/agent/tests/runtime-stdio-contract.test.ts @@ -294,6 +294,77 @@ describe("runtime stdio contract", () => { store.close(); }); + it("revokes the chat's user grants when the app clears that chat", async () => { + fixture = new RuntimeProcessFixture(); + await fixture.waitForMessage((message) => message.type === "init"); + fixture.send({ type: "refresh_owner", ownerId: "owner-clear" }); + const surface = { surfaceKind: "main_chat", externalRefKind: "chat", externalRefId: "default" }; + fixture.send({ + type: "journal_list_turns", + protocolVersion: PROTOCOL_VERSION, + requestId: "list-before-clear", + clientId: "contract-smoke", + ownerId: "owner-clear", + ...surface, + afterTurnSeq: 0, + limit: 1, + }); + const listed = await fixture.waitForMessage( + (message) => message.type === "journal_operation_result" && message.requestId === "list-before-clear", + ); + if (listed.type !== "journal_operation_result") throw new Error("missing journal list result"); + fixture.send({ + type: "resolve_surface_session", + protocolVersion: PROTOCOL_VERSION, + requestId: "resolve-clear", + clientId: "contract-smoke", + ownerId: "owner-clear", + ...surface, + }); + const resolved = await fixture.waitForMessage( + (message) => message.type === "surface_session_resolved" && message.requestId === "resolve-clear", + ); + if (resolved.type !== "surface_session_resolved") throw new Error("missing main chat session"); + + // A grant "Allow for This Chat" minted, written beside the running daemon. + const writer = new SqliteAgentStore({ stateDir: join(fixture.root, "state"), reconcileOnOpen: false }); + const minted = writer.insertGrant({ + sessionId: resolved.sessionId, + runId: null, + capability: "desktop.mail.read", + operation: "list_mail_messages", + resourcePattern: "mail:inbox", + effect: "allow", + source: "user", + expiresAtMs: Date.now() + 60 * 60_000, + }); + writer.close(); + + fixture.send({ + type: "journal_clear_turns", + protocolVersion: PROTOCOL_VERSION, + requestId: "clear-chat", + clientId: "contract-smoke", + ownerId: "owner-clear", + ...surface, + expectedGeneration: listed.conversationGeneration, + deleteBackend: false, + }); + const cleared = await fixture.waitForMessage( + (message) => message.type === "journal_operation_result" && message.requestId === "clear-chat", + ); + expect(cleared).toMatchObject({ operation: "clear" }); + + await fixture.stop(); + const store = new SqliteAgentStore({ stateDir: join(fixture.root, "state"), reconcileOnOpen: false }); + expect(store.getRow("SELECT revoked_at_ms FROM grants WHERE grant_id = ?", [minted.grantId]).revoked_at_ms).not.toBeNull(); + expect(store.getRow( + "SELECT COUNT(*) AS count FROM events WHERE type = 'grants.revoked' AND session_id = ?", + [resolved.sessionId], + ).count).toBe(1); + store.close(); + }); + it("accepts typed appendEvidence through the journal update RPC", async () => { fixture = new RuntimeProcessFixture(); await fixture.waitForMessage((message) => message.type === "init"); From 42611ec352bfd488eaacc91e29a0c6c10c273b23 Mon Sep 17 00:00:00 2001 From: Sauransh Bhardwaj Date: Thu, 8 Oct 2026 02:06:57 -0400 Subject: [PATCH 4/5] feat(desktop): give approval-gated device tools the long model-side wait The pi-mono extension waits 30s for a normal Swift tool result and gives up with a timeout error. A human answering an approval card needs longer than that, and a late approval would then run a send the model had already reported as failed. The six device tools that park behind an approval, capture_screen among them, now declare the long timeout class, which the extension maps to its 10 minute wait, and a test holds every gated tool to it; the approval TTL of 180s plus the 120s Swift executor timeout stays well inside it. Swift does not consume timeoutClass outside the generated surfaces, so the regenerated files only carry the new manifest digests. --- .../Sources/Generated/GeneratedToolExecutors.swift | 4 ++-- desktop/macos/agent/src/runtime/omi-tool-manifest.ts | 12 ++++++------ .../macos/agent/tests/fixtures/tool-manifest.json | 12 ++++++------ .../macos/agent/tests/run-tool-capability.test.ts | 6 ++++++ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift index 2995fd53ca7..24f09cc29a6 100644 --- a/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift +++ b/desktop/macos/Desktop/Sources/Generated/GeneratedToolExecutors.swift @@ -62,8 +62,8 @@ enum GeneratedSwiftToolExecutor: String { enum GeneratedToolExecutors { static let manifestVersion = 1 - static let manifestDigest = "sha256:971efde6c3c6543f006d3d22fa279c5d19c40af6c5b6ae88ba6ff431295ed754" - static let chatFirstManifestDigest = "sha256:5806dd22b1ca32df6ac0fdb1e209f9ff23742a1ffe1e27585e715cf47860e957" + static let manifestDigest = "sha256:41b37e838455d8d7c55fe7b10792bedbd128ca2313dd22d119a7832f157eaa41" + static let chatFirstManifestDigest = "sha256:5ae1371771c4738b99d5b6854947e4a5b90e8e2892365aaad4fc51b428b514df" static let aliasToCanonical: [String: GeneratedSwiftTool] = [ "search_screen_history": .semanticSearch, diff --git a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts index 9a32d4a3325..8687978ef50 100644 --- a/desktop/macos/agent/src/runtime/omi-tool-manifest.ts +++ b/desktop/macos/agent/src/runtime/omi-tool-manifest.ts @@ -1960,7 +1960,7 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ latency: "fast local", inputSchema: schema({}), annotations: readOnlyLocal, - timeoutClass: "normal", + timeoutClass: "long", executor: { kind: "swiftTool" }, intendedForAgents: true, runtimePreconditions: ["Requires macOS Screen Recording permission."], @@ -2357,7 +2357,7 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ limit: { type: "number", description: "Maximum chats to return (default 20, max 100)." }, }), annotations: readOnlyLocal, - timeoutClass: "normal", + timeoutClass: "long", executor: { kind: "swiftTool" }, intendedForAgents: true, runtimePreconditions: ["Requires macOS Full Disk Access to read the Messages database."], @@ -2381,7 +2381,7 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ limit: { type: "number", description: "Maximum messages to return (default 30, max 200)." }, }), annotations: readOnlyLocal, - timeoutClass: "normal", + timeoutClass: "long", executor: { kind: "swiftTool" }, intendedForAgents: true, runtimePreconditions: ["Requires macOS Full Disk Access to read the Messages database."], @@ -2403,7 +2403,7 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ limit: { type: "number", description: "Maximum messages to return (default 30, max 200)." }, }), annotations: readOnlyLocal, - timeoutClass: "normal", + timeoutClass: "long", executor: { kind: "swiftTool" }, intendedForAgents: true, runtimePreconditions: ["Requires macOS Full Disk Access to read the Apple Mail index."], @@ -2437,7 +2437,7 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ ["to", "text"], ), annotations: openWorldWrite, - timeoutClass: "normal", + timeoutClass: "long", executor: { kind: "swiftTool" }, intendedForAgents: true, runtimePreconditions: [ @@ -2467,7 +2467,7 @@ const swiftToolManifestDrafts: OmiToolManifestEntryDraft[] = [ ["script"], ), annotations: openWorldWrite, - timeoutClass: "normal", + timeoutClass: "long", executor: { kind: "swiftTool" }, intendedForAgents: true, runtimePreconditions: [ diff --git a/desktop/macos/agent/tests/fixtures/tool-manifest.json b/desktop/macos/agent/tests/fixtures/tool-manifest.json index 6e32561b29c..8edd6c08f6f 100644 --- a/desktop/macos/agent/tests/fixtures/tool-manifest.json +++ b/desktop/macos/agent/tests/fixtures/tool-manifest.json @@ -5332,7 +5332,7 @@ "destructiveHint": false, "openWorldHint": false }, - "timeoutClass": "normal", + "timeoutClass": "long", "executor": { "kind": "swiftTool", "executorName": "chatToolExecutor" @@ -6515,7 +6515,7 @@ "destructiveHint": false, "openWorldHint": false }, - "timeoutClass": "normal", + "timeoutClass": "long", "executor": { "kind": "swiftTool", "executorName": "chatToolExecutor" @@ -6579,7 +6579,7 @@ "destructiveHint": false, "openWorldHint": false }, - "timeoutClass": "normal", + "timeoutClass": "long", "executor": { "kind": "swiftTool", "executorName": "chatToolExecutor" @@ -6635,7 +6635,7 @@ "destructiveHint": false, "openWorldHint": false }, - "timeoutClass": "normal", + "timeoutClass": "long", "executor": { "kind": "swiftTool", "executorName": "chatToolExecutor" @@ -6714,7 +6714,7 @@ "destructiveHint": false, "openWorldHint": true }, - "timeoutClass": "normal", + "timeoutClass": "long", "executor": { "kind": "swiftTool", "executorName": "chatToolExecutor" @@ -6779,7 +6779,7 @@ "destructiveHint": false, "openWorldHint": true }, - "timeoutClass": "normal", + "timeoutClass": "long", "executor": { "kind": "swiftTool", "executorName": "chatToolExecutor" diff --git a/desktop/macos/agent/tests/run-tool-capability.test.ts b/desktop/macos/agent/tests/run-tool-capability.test.ts index 575ce8b49b0..a9b6f627f27 100644 --- a/desktop/macos/agent/tests/run-tool-capability.test.ts +++ b/desktop/macos/agent/tests/run-tool-capability.test.ts @@ -1470,6 +1470,12 @@ describe("desktop approval set and sensitive policy bundles", () => { } expect(relayCallable("get_screenshot")).toBe(false); }); + + it("gives every gated tool the long model-side wait so a person has time to answer the card", () => { + for (const name of DESKTOP_APPROVAL_TOOLS) { + expect(allOmiToolManifest.find((tool) => tool.name === name)?.timeoutClass, name).toBe("long"); + } + }); }); describe("RunToolCapabilityBroker spawn-time tool policy", () => { From 7cc3022b9a298b24fbf0b1282004b918242a87b5 Mon Sep 17 00:00:00 2001 From: Sauransh Bhardwaj Date: Thu, 8 Oct 2026 02:06:57 -0400 Subject: [PATCH 5/5] docs(desktop): record the implemented device tool approval flow The approval UX design doc keeps its design status and points at the device tool surface doc for the runtime half that now exists; the Swift card, the Claude Code lane and the voice surface stay deferred and section 24 still applies. The device tool surface doc gains the ask-and-answer table, the schema and resource rules that stop the model naming what a grant covers, the session-grant options and their 24 hour cap, the restart and expiry behavior, and the interim client-capability gate. The changelog fragment is kind none because nothing is user-visible until the Swift card lands. --- .../agent-docs/agent-grants-approval-ux.mdx | 14 +-- .../20261008-device-tool-approval-relay.json | 3 + desktop/macos/docs/device-tool-surface.md | 88 ++++++++++++++++++- 3 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20261008-device-tool-approval-relay.json diff --git a/.github/agent-docs/agent-grants-approval-ux.mdx b/.github/agent-docs/agent-grants-approval-ux.mdx index a8f6149469b..8672d0242cc 100644 --- a/.github/agent-docs/agent-grants-approval-ux.mdx +++ b/.github/agent-docs/agent-grants-approval-ux.mdx @@ -1,8 +1,8 @@ # Agent Grants and Approval UX Design -**Status:** Design-only for TICKET-005. Do not treat this document as implementation scope. +**Status:** Design for TICKET-005. The desktop runtime now parks the on-device tools (`send_message`, `run_applescript`, `read_message_history`, `list_message_chats`, `list_mail_messages`) and the live screenshot tool (`capture_screen`) behind an invocation-bound `approval` dispatch and resolves it through `resolve_desktop_dispatch`; that implementation is described in `desktop/macos/docs/device-tool-surface.md`. Everything else here, including the Swift card and the Claude Code lane, remains design-only. -This document narrows the approval and grant UX design that follows the Agent Control Plane spec, especially sections 6.8, 11.2, 17, and 24. It does not change code, cloud relay behavior, artifact lifecycle, or adapter contracts. +This document narrows the approval and grant UX design that follows the Agent Control Plane spec, especially sections 6.8, 11.2, 17, and 24. It does not change cloud relay behavior, artifact lifecycle, or adapter contracts. ## Current Inventory @@ -23,9 +23,9 @@ The Phase 2 runtime has a durable `grants` schema and TypeScript grant types: - optional `expires_at_ms` - optional `revoked_at_ms` -That schema is present, but grants are currently dormant state. There is no live insertion path for `policy`, `user`, or `system` grants in the merged Phase 2 runtime. +The only live insertion path is `resolve_desktop_dispatch`, which mints a `source: "user"` grant while resolving an `approval` dispatch and requires the grant to name the dispatch's exact capability, operation, and resource with a finite expiry. There is still no `policy` or `system` writer. -The intended meaning remains section 6.8: a grant is a recorded capability decision scoped to a session or run. Until implementation work explicitly activates grant writes, UI must not imply that Omi already has user-managed grant records. +The intended meaning remains section 6.8: a grant is a recorded capability decision scoped to a session or run. UI must not imply that Omi has user-managed grant records beyond the ones the user minted this way. ### Live Approval Behavior @@ -156,9 +156,9 @@ Hermes, OpenClaw, A2A, AgentVM, and future adapters must not inherit ACP's legac This design does not implement: - a new approvals table; -- grant persistence; -- Swift approval UI; -- runtime event persistence changes; +- Swift approval UI (the card that renders `approval_requested` and answers through direct control); +- routing the Claude Code `session/request_permission` lane through the same card; +- approval parking for the realtime voice surface, whose Swift tool wait is 180s and would race the approval TTL; - cloud relay or cross-device approval sync; - artifact lifecycle changes; - broad capability generation for future standards. diff --git a/desktop/macos/changelog/unreleased/20261008-device-tool-approval-relay.json b/desktop/macos/changelog/unreleased/20261008-device-tool-approval-relay.json new file mode 100644 index 00000000000..bb56a275667 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20261008-device-tool-approval-relay.json @@ -0,0 +1,3 @@ +{ + "kind": "none" +} diff --git a/desktop/macos/docs/device-tool-surface.md b/desktop/macos/docs/device-tool-surface.md index 70d071d159d..9c63a7e0781 100644 --- a/desktop/macos/docs/device-tool-surface.md +++ b/desktop/macos/docs/device-tool-surface.md @@ -67,7 +67,93 @@ scoped grant. Nothing about the old bundle's guarantees changed. A `send_message` grant carries a `resourceRef` of the recipient handle. Approving a message to one person does not authorize a message to anyone else; -the policy re-requires dispatch when the handle differs. +the policy re-requires dispatch when the handle differs. Tools without a +natural target get a stable ref so a session grant can still name them: +`list_message_chats` is `messages:chats` and `list_mail_messages` is +`mail:inbox`. `capture_screen` gets `screen` so its card names a target, but +no grant ever covers it (below). + +The resource is derived only from the fields the tool acts on (`to`, `script`, +`chat_id` or `handle`). The model cannot name it: the gated tools are held +to their manifest schema at the kernel boundary, so an unknown key such as +`resource_ref`, a missing required field, or a wrong type is refused as +`invalid_tool_input` before anything is prepared. + +### How a dispatch is asked and answered + +A `dispatch` decision does not fail the tool call. The kernel writes the +invocation to the ledger as `prepared`, inserts one `approval` dispatch bound +to that exact invocation (its id and input hash live in the dispatch payload), +moves the run to `waiting_approval`, and appends `approval.requested`, all in +one transaction. The runtime sends Swift an `approval_requested` frame with the +recipient, the exact text or script, the options, and the expiry. The card +shows the whole input or nothing: text or a script longer than the 4 KiB card +bound is refused as `input_too_large_to_approve` so the model can shorten it. + +The user answers through signed direct control with `resolve_desktop_dispatch`: + +| Answer | Call | Effect | +|---|---|---| +| Allow once | `status: resolved`, `resolution: { decision: "allow" }` | this invocation runs once; no grant | +| Allow for this chat | the same plus a `grant` with `runId: null`, the dispatch's capability/operation/resource, and an `expiresAtMs` at most 24 h out | mints a session-scoped grant, then runs | +| Deny | `resolution: { decision: "deny" }` or `status: cancelled` | the model gets `approval_denied`; the run continues | + +"Allow for this chat" is offered only when there is an exact resource for the +grant to cover, and each option says what that is (`covers`): messages to this +recipient, this conversation, your recent Messages conversations, your Mail +inbox headers. For `run_applescript` the grant covers only the identical +script, byte for byte; a different script asks again. A thread read with +neither `chat_id` nor `handle`, and every live screenshot, offers allow once +and deny only, and the kernel refuses to mint a grant for a card that did not +offer one. + +"This chat" ends when the person clears it. The main chat keeps one kernel +session across clears, so the app's `journal_clear_turns` message first calls +the kernel's `endChatApprovalScope` for that session: every approval still +parked there closes as `cancelled` (the call fails closed and never runs), and +every grant the person minted for the session is revoked (`revoked_at_ms`), +with one `grants.revoked` event. Only the app's clear reaches it; no tool does. + +Expiry (180 s, matching the mobile device-tool transport), run cancellation, +owner change and a disconnected relay client also end as `approval_denied`, +and every card ends with exactly one `approval.resolved` event and one +`approval_resolved` frame, whatever ended it. A daemon restart fails the +prepared invocation and expires its dispatch; nothing is replayed. A later call +with the same recipient and text is a new invocation and asks again unless a +grant covers it. The model-side wait for every gated tool is the `long` class +(10 min) so the model does not give up before the user answers. + +The model cannot answer for the user: `resolve_desktop_dispatch` from an +adapter relay returns `policy_denied` and leaves the dispatch pending. A +dispatch the model creates itself through `create_desktop_dispatch` has its +`invocation` payload key stripped, so it can never pose as a parked tool call. + +Interim gate: parking is on only after a connected client declares the +`desktop_tool_approval_cards` capability through the `client_capabilities` +message. Until the Swift card ships, no client does, and the relay keeps +today's immediate `approval_required` rather than holding a call nobody can +answer. The card PR turns parking on by default and deletes the gate. + +### Live screenshots ask too + +`capture_screen` is not in the table above, but it takes the same gate: its +bundle, `desktop.context.screenshot_image`, is sensitive, so a live capture +parks behind the same card ("Take a screenshot") under the stable resource +`screen`, and nothing is captured or saved until the user allows it. It is +allow once only: the capture is the whole display, including windows the +person keeps out of capture elsewhere, so the card offers no "Allow for this +chat", no grant row covers a capture (even one naming `screen`), and +`desktop.context.screenshot_image` grants must name an exact resource. + +`get_screenshot` and its `look_at_frame` alias share the bundle but are served +only by the local agent API, and the realtime voice `screenshot` tool is +offered only to realtime voice runs through their surface projection; none of +them is advertised to the chat, pill or workstream relays. `show_rewind_evidence` +is classed with the screen-history reads: the model gets back only the stored +frame's title, app and OCR excerpt, and the pixels go to the person's own Chat +turn. Tests in `run-tool-capability.test.ts` require every relay-callable tool +to be classified on purpose and hold the approval set to every relay-callable +tool in a sensitive bundle, naming each deliberate exception. ### AppleScript injection