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 eb2a5466c22..2ad3feba026 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" @@ -71,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", @@ -85,8 +89,18 @@ 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. +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"]); @@ -119,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 @@ -171,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[] = [ @@ -236,13 +279,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; @@ -317,8 +367,187 @@ export function evaluateDesktopToolPolicy(request: DesktopToolPolicyRequest): De export const desktopToolPolicyInternals = { isSqlWrite, descriptorFromToolName, + isSensitiveBundle, + isExplicitlyClassified, }; +// --- 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/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/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"); 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" +}