From 915f64473160ef4a74bab10535fa93f8a5d1d572 Mon Sep 17 00:00:00 2001 From: Sauransh Bhardwaj Date: Thu, 8 Oct 2026 02:24:51 -0400 Subject: [PATCH 1/2] 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/2] 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" +}