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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/agent-docs/agent-grants-approval-ux.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Agent Grants and Approval UX Design

**Status:** Design-only for TICKET-005. Do not treat this document as implementation scope.
**Status:** Design for TICKET-005. The desktop runtime now parks the on-device tools (`send_message`, `run_applescript`, `read_message_history`, `list_message_chats`, `list_mail_messages`) and the live screenshot tool (`capture_screen`) behind an invocation-bound `approval` dispatch and resolves it through `resolve_desktop_dispatch`; that implementation is described in `desktop/macos/docs/device-tool-surface.md`. Everything else here, including the Swift card and the Claude Code lane, remains design-only.

This document narrows the approval and grant UX design that follows the Agent Control Plane spec, especially sections 6.8, 11.2, 17, and 24. It does not change code, cloud relay behavior, artifact lifecycle, or adapter contracts.
This document narrows the approval and grant UX design that follows the Agent Control Plane spec, especially sections 6.8, 11.2, 17, and 24. It does not change cloud relay behavior, artifact lifecycle, or adapter contracts.

## Current Inventory

Expand All @@ -23,9 +23,9 @@ The Phase 2 runtime has a durable `grants` schema and TypeScript grant types:
- optional `expires_at_ms`
- optional `revoked_at_ms`

That schema is present, but grants are currently dormant state. There is no live insertion path for `policy`, `user`, or `system` grants in the merged Phase 2 runtime.
The only live insertion path is `resolve_desktop_dispatch`, which mints a `source: "user"` grant while resolving an `approval` dispatch and requires the grant to name the dispatch's exact capability, operation, and resource with a finite expiry. There is still no `policy` or `system` writer.

The intended meaning remains section 6.8: a grant is a recorded capability decision scoped to a session or run. Until implementation work explicitly activates grant writes, UI must not imply that Omi already has user-managed grant records.
The intended meaning remains section 6.8: a grant is a recorded capability decision scoped to a session or run. UI must not imply that Omi has user-managed grant records beyond the ones the user minted this way.

### Live Approval Behavior

Expand Down Expand Up @@ -156,9 +156,9 @@ Hermes, OpenClaw, A2A, AgentVM, and future adapters must not inherit ACP's legac
This design does not implement:

- a new approvals table;
- grant persistence;
- Swift approval UI;
- runtime event persistence changes;
- Swift approval UI (the card that renders `approval_requested` and answers through direct control);
- routing the Claude Code `session/request_permission` lane through the same card;
- approval parking for the realtime voice surface, whose Swift tool wait is 180s and would race the approval TTL;
- cloud relay or cross-device approval sync;
- artifact lifecycle changes;
- broad capability generation for future standards.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:41b37e838455d8d7c55fe7b10792bedbd128ca2313dd22d119a7832f157eaa41"
static let chatFirstManifestDigest = "sha256:5ae1371771c4738b99d5b6854947e4a5b90e8e2892365aaad4fc51b428b514df"

static let aliasToCanonical: [String: GeneratedSwiftTool] = [
"search_screen_history": .semanticSearch,
Expand Down
128 changes: 65 additions & 63 deletions desktop/macos/agent/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ModelCredentialRelay } from "./runtime/model-credentials.js";

Check warning on line 1 in desktop/macos/agent/src/index.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/agent/src/index.ts is 4075 lines; consider splitting files over 800 lines.
/**
* ACP Bridge — translates between OMI's JSON-lines protocol and the
* Agent Client Protocol (ACP) used by claude-code-acp.
Expand Down Expand Up @@ -72,12 +72,14 @@
JournalBackendReconcileResultMessage,
ChatFirstDeferralDeliveryResultMessage,
ChatFirstHarnessExecutorBeginMessage,
ClientCapabilitiesMessage,
RefreshOwnerMessage,
RevokeOwnerRuntimeMessage,
ModelHeadersResultMessage,
AuthMethod,
} from "./protocol.js";
import {
CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS,
PROTOCOL_VERSION,
RUNTIME_CAPABILITIES,
assertJournalRemoteTurnInput,
Expand Down Expand Up @@ -125,6 +127,8 @@
import { providerBoundaryForAdapter } from "./runtime/execution-policy.js";
import { executionRoleForSurface } from "./runtime/execution-policy.js";
import type { AuthorizedRunToolInvocation, RunToolExecutionLease } from "./runtime/run-tool-capability.js";
import type { DesktopToolApprovalRequest } from "./runtime/desktop-tool-policy.js";
import { RelayToolApprovals } from "./runtime/relay-tool-approvals.js";
import {
compactRealtimeSpawnToolResult,
parseAgentSpawnProducerJournalDescriptor,
Expand Down Expand Up @@ -324,6 +328,13 @@
}
>();

/**
* Relay invocations parked behind a user approval, and the hand-off of every
* relay invocation to Swift. Created with the kernel; see
* `runtime/relay-tool-approvals.ts`.
*/
let relayToolApprovals: RelayToolApprovals<Socket> | undefined;

/**
* This exists solely for the local/offline desktop E2E fixture. Unlike the
* external-surface bridge, it cannot accept a user/model-selected tool or
Expand Down Expand Up @@ -429,7 +440,7 @@
}

/** Resolve a pending tool call with a result from Swift */
function resolveToolCall(msg: AuthorizedToolExecutionResultMessage): void {

Check warning on line 443 in desktop/macos/agent/src/index.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

function resolveToolCall(msg: AuthorizedToolExecutionResultMessage): void is 212 lines; consider extracting focused helpers over 150 lines.
const key = toolCallPendingKey(msg);
const chatFirstHarness = pendingChatFirstHarnessExecutors.get(key);
if (chatFirstHarness) {
Expand Down Expand Up @@ -805,6 +816,7 @@
"failed",
);
}
relayToolApprovals?.rejectForOwner(ownerId, errorCode, message);
for (const [key, pending] of pendingExternalToolCalls) {
if (pending.invocation.ownerId !== ownerId) continue;
pendingExternalToolCalls.delete(key);
Expand Down Expand Up @@ -891,6 +903,7 @@
}
writeRelayToolResult(client, pending.callId, result, pending.invocation, "failed");
}
relayToolApprovals?.rejectForClient(client);
}

function relayError(code: string, message: string): string {
Expand Down Expand Up @@ -943,7 +956,7 @@
);

/** Start Unix socket server for omi-tools stdio processes to connect to */
function startOmiToolsRelay(): Promise<string> {

Check warning on line 959 in desktop/macos/agent/src/index.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

function startOmiToolsRelay(): Promise<string> is 300 lines; consider extracting focused helpers over 150 lines.
const pipePath = join(tmpdir(), `omi-tools-${process.pid}.sock`);

// Clean up any stale socket
Expand Down Expand Up @@ -992,22 +1005,30 @@
);
continue;
}
let authorized;
let authorized: AuthorizedRunToolInvocation;
let routedProposal;
let approval: { dispatch: { dispatchId: string; expiresAtMs: number | null }; request: DesktopToolApprovalRequest } | undefined;
try {
routedProposal = runtimeKernel.routeRelayedRunToolProposal({
capabilityRef,
toolName: msg.name,
toolInput: msg.input ?? {},
activeOwnerId: currentOwnerId,
});
authorized = runtimeKernel.authorizeRelayedRunToolInvocation({
// Sensitive device tools park here instead of failing: the
// kernel commits the dispatch, ledger row, and waiting_approval
// transition together, and the relay call waits for the user.
const outcome = runtimeKernel.authorizeRelayedRunToolInvocationOrRequestApproval({
capabilityRef,
invocationId,
toolName: routedProposal.toolName,
toolInput: routedProposal.toolInput,
activeOwnerId: currentOwnerId,
});
authorized = outcome.invocation;
if (outcome.kind === "approval_required") {
approval = { dispatch: outcome.dispatch, request: outcome.request };
}
} catch (error) {
const code = error && typeof error === "object" && "code" in error
? String((error as { code: unknown }).code)
Expand Down Expand Up @@ -1176,74 +1197,28 @@
continue;
}

const callId = msg.callId;
const pendingKey = toolCallPendingKey({
invocationId,
});
if (pendingToolCalls.has(pendingKey)) {
if (!relayToolApprovals) {
writeRelayToolResult(
client,
callId,
relayError("invocation_replayed", "Duplicate tool invocation"),
msg.callId,
relayError("runtime_not_ready", "Agent runtime kernel is not ready"),
authorized,
"failed",
);
continue;
}

const timeout = setTimeout(() => {
const pending = pendingToolCalls.get(pendingKey);
if (!pending) return;
pendingToolCalls.delete(pendingKey);
try {
runtimeKernel?.markRunToolInvocationOutcomeUnknown(pending.invocation, "swift_tool_timeout");
} catch (error) {
logErr(`Failed to mark timed-out tool invocation outcome unknown: ${error}`);
}
writeRelayToolResult(
pending.client,
pending.callId,
relayError("swift_tool_timeout", "Timed out waiting for the Swift tool executor"),
pending.invocation,
"failed",
);
}, 120_000);
pendingToolCalls.set(pendingKey, {
client,
callId,
invocation: authorized,
timeout,
});
runtimeKernel.markRunToolInvocationDispatched(authorized);
send({
type: "authorized_tool_execution",
invocationId,
ownerId: authorized.ownerId,
sessionId: authorized.sessionId,
runId: authorized.runId,
attemptId: authorized.attemptId,
profileGeneration: authorized.profileGeneration,
manifestVersion: authorized.manifestVersion,
manifestDigest: authorized.manifestDigest,
daemonBootEpoch: authorized.daemonBootEpoch,
executionGeneration: authorized.executionGeneration,
capabilityRef: authorized.capabilityRef,
toolName: authorized.canonicalToolName,
input: routedProposal.toolInput,
inputHash: authorized.inputHash,
effectClass: authorized.effectClass,
retryPolicy: authorized.retryPolicy,
surfaceKind: authorized.surfaceKind,
externalRefKind: authorized.externalRefKind,
externalRefId: authorized.externalRefId,
originatingUserText: authorized.originatingUserText,
precedingAssistantText: authorized.precedingAssistantText,
runMode: authorized.runMode,
chatMode: authorized.chatMode,
...(authorized.chatFirstControlGeneration !== null
? { chatFirstControlGeneration: authorized.chatFirstControlGeneration }
: {}),
});
if (approval) {
relayToolApprovals.park({
client,
callId: msg.callId,
invocation: authorized,
toolInput: routedProposal.toolInput,
dispatch: approval.dispatch,
request: approval.request,
});
continue;
}
relayToolApprovals.dispatchToSwift(client, msg.callId, authorized, routedProposal.toolInput);
}
} catch {
logErr(`Failed to parse omi-tools message: ${line.slice(0, 200)}`);
Expand Down Expand Up @@ -1645,7 +1620,7 @@

// --- Main ---

async function main(): Promise<void> {

Check warning on line 1623 in desktop/macos/agent/src/index.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

async function main(): Promise<void> is 2445 lines; consider extracting focused helpers over 150 lines.
logErr(`Bridge main() starting (pid=${process.pid}, node=${process.version}, execPath=${process.execPath})`);

const defaultHarnessMode = process.env.HARNESS_MODE || "acp";
Expand Down Expand Up @@ -1698,6 +1673,15 @@
},
});
kernel.subscribe(rejectPendingToolCallsForKernelEvent);
relayToolApprovals = new RelayToolApprovals<Socket>({
kernel,
send,
writeRelayToolResult,
pendingSwiftCalls: pendingToolCalls,
activeOwnerId: () => currentOwnerId,
log: logErr,
});
kernel.subscribe((event) => relayToolApprovals?.handleKernelEvent(event));
runtimeKernel = kernel;
let piMonoClasses: typeof import("./adapters/pi-mono.js") | undefined;
const piMonoAdapters = new Set<import("./adapters/pi-mono.js").PiMonoAdapter>();
Expand Down Expand Up @@ -3540,6 +3524,11 @@
externalRefKind: request.externalRefKind,
externalRefId: request.externalRefId,
});
// A cleared chat is a new conversation on the same kernel session, so
// what the person allowed "for this chat" ends here: parked approvals
// close and their session grants are revoked before the clear, so a
// failed clear leaves fewer grants rather than more.
kernel.endChatApprovalScope({ ownerId, sessionId: resolved.agentSessionId, reason: "chat_cleared" });
const result = clearJournalConversation(store, {
ownerId,
conversationId: resolved.conversationId,
Expand Down Expand Up @@ -3949,6 +3938,19 @@
break;
}

case "client_capabilities": {
// Interim gate for the device tool approval flow: only a client that
// can render the approval card may turn parking on. Without it the
// relay keeps today's immediate approval_required. The Swift card PR
// flips the default and removes this message.
const declared = msg as ClientCapabilitiesMessage;
const enabled = Array.isArray(declared.capabilities)
&& declared.capabilities.includes(CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS);
kernel.setDesktopToolApprovalsEnabled(enabled);
logErr(`client_capabilities client=${declared.clientId ?? ""} desktopToolApprovals=${enabled ? "on" : "off"}`);
break;
}

case "refresh_owner": {
const owner = msg as RefreshOwnerMessage;
const transition = establishRuntimeOwner(
Expand Down
83 changes: 83 additions & 0 deletions desktop/macos/agent/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ModelHeadersReply } from "./runtime/model-fetch.js";

Check warning on line 1 in desktop/macos/agent/src/protocol.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/agent/src/protocol.ts is 1504 lines; consider splitting files over 800 lines.
// JSON lines protocol between Swift app and Node.js agent runtime
// Extended from agent protocol with authentication message types

Expand All @@ -10,6 +10,7 @@
"runtime_adapter_availability",
"chat_first_capability_projection",
"request_scoped_model_credentials",
"desktop_tool_approval_requests",
] as const;
export type ProtocolVersion = typeof PROTOCOL_VERSION;

Expand Down Expand Up @@ -632,6 +633,19 @@
ownerId: string;
}

/**
* A client declares that it renders device tool approval cards. Interim gate:
* until a client says so, the relay keeps today's immediate
* `approval_required` instead of parking a call nobody can answer. The Swift
* card PR turns parking on by default and deletes this capability.
*/
export const CLIENT_CAPABILITY_DESKTOP_TOOL_APPROVAL_CARDS = "desktop_tool_approval_cards";

export interface ClientCapabilitiesMessage extends ProtocolEnvelope {
type: "client_capabilities";
capabilities: string[];
}

/**
* Local/offline-only E2E probe for the real Chat-first Swift tool executor.
* The kernel derives capability from the already-resolved session; this message
Expand Down Expand Up @@ -686,6 +700,7 @@
| ChatFirstDeferralDeliveryResultMessage
| ChatFirstHarnessExecutorBeginMessage
| ModelHeadersResultMessage
| ClientCapabilitiesMessage
| RefreshOwnerMessage;

const INBOUND_RESPONSE_MESSAGE_TYPES = new Set<InboundMessage["type"]>([
Expand Down Expand Up @@ -773,6 +788,70 @@
policyRecovery?: "permission_delegation_to_native";
}

export interface ApprovalOption {
id: "allow_once" | "allow_session" | "deny";
effect: "allow" | "deny";
/** `run`: this invocation only; `session`: mints a scoped, expiring grant via `resolve_desktop_dispatch`. */
scope: "run" | "session" | "request";
}

/**
* A sensitive device tool invocation is parked behind one `approval` dispatch.
* Swift renders it and answers through signed direct control
* (`resolve_desktop_dispatch` with `resolution.decision` and, for
* `allow_session`, a grant scoped to `capability`/`operation`/`resourceRef`).
* This frame carries no authority: only the resolved dispatch admits the
* invocation, and only for the exact `invocationId` + `inputHash` shown here.
*/
export interface ApprovalRequestedMessage extends OutboundEnvelope {
type: "approval_requested";
approvalId: string;
ownerId: string;
sessionId: string;
runId: string;
attemptId: string;
invocationId: string;
adapterId: string;
surfaceKind: string;
policy: "default_user_approval";
toolName: string;
capability: string;
operation: string;
resourceRef: string | null;
inputHash: string;
effectClass: "read_only" | "idempotent_write" | "non_idempotent_write";
title: string;
decisionPrompt: string;
/** Bounded, display-safe projection of the tool input, never the raw input. */
preview: Record<string, string | number | boolean>;
previewTruncated: boolean;
reason: string;
options: ApprovalOption[];
/** Always `deny` today; typed as an option id so a client never hardcodes the policy. */
defaultOptionId: ApprovalOption["id"];
requestedAtMs: number;
/** After this the runtime fails the invocation closed as `approval_denied`. */
expiresAtMs: number;
}

/** Every parked approval ends with exactly one of these, whatever ended it. */
export interface ApprovalResolvedMessage extends OutboundEnvelope {
type: "approval_resolved";
approvalId: string;
ownerId: string;
sessionId: string;
runId: string;
attemptId: string;
invocationId: string;
toolName: string;
decision: "allow" | "deny" | "expired" | "cancelled";
selectedOptionId: string | null;
grantId: string | null;
resolvedBy: string;
resolvedAtMs: number;
automatic: boolean;
}

export interface ExternalAuthorityError {
code: string;
message: string;
Expand Down Expand Up @@ -1349,6 +1428,8 @@
| AuthSuccessMessage
| CancelAckMessage
| AuthorizedToolExecutionMessage
| ApprovalRequestedMessage
| ApprovalResolvedMessage
| ExternalSurfaceRunBeginResultMessage
| ExternalSurfaceToolResultMessage
| ExternalSurfaceRunCompleteResultMessage
Expand Down Expand Up @@ -1390,6 +1471,8 @@
| DraftEnvelope<ErrorMessage>
| DraftEnvelope<CancelAckMessage>
| DraftEnvelope<AuthorizedToolExecutionMessage>
| DraftEnvelope<ApprovalRequestedMessage>
| DraftEnvelope<ApprovalResolvedMessage>
| DraftEnvelope<ExternalSurfaceRunBeginResultMessage>
| DraftEnvelope<ExternalSurfaceToolResultMessage>
| DraftEnvelope<ExternalSurfaceRunCompleteResultMessage>
Expand Down
Loading
Loading