* feat(gateway): persist operator approvals * fix(gateway): preserve approval ids exactly * fix(gateway): enforce approval reviewer bindings * fix(gateway): reconcile durable approvals with exec revocation hardening Map #103515 semantics onto the durable lifecycle: resolutionSource and one-shot consumeAskFallback stay process-local record facts, trusted auto-review resolves through the durable CAS as a runtime resolver, and lost races settle with the winner's operator source. Also: derive the audience walker cap from the store cap, report APPROVAL_ALREADY_RESOLVED for a resolve that loses the CAS race, document approval.get/resolve in the protocol docs, and regenerate Swift models on the new base. * fix(approvals): validate resolver kind * docs(approvals): explain malformed verdict denial * fix(protocol): regenerate approval models after rebase * chore: leave changelog entry to release generation * fix(gateway): preserve approval registration boundaries * test(gateway): prove multi-device approval races * test(gateway): keep approval order assertion stable * docs: index operator approval architecture * fix(protocol): bound approval declaration exports
30 KiB
summary, read_when, title
| summary | read_when | title | |||
|---|---|---|---|---|---|
| Design for durable, deep-linkable approvals across Control UI, native apps, channels, and parent sessions |
|
Multi-surface operator approvals |
Multi-surface operator approvals
This design tracks #103505. It replaces process-local approval authority with one Gateway-owned, SQLite-backed lifecycle. Every Gateway-owned exec or plugin/tool approval gets one stable ID, one authenticated Control UI route, atomic first-answer-wins resolution, and operator-only projections to its source and ancestor session streams.
Inline actions and deep links coexist. There is no approval-mode toggle.
Goals
- One durable approval object for exec and plugin/tool gates.
- Stable
${controlUiBasePath}/approve/{approvalId}route. - Resolution from any authorized Control UI, native app, or channel surface.
- Atomic first-answer-wins behavior across concurrent surfaces.
- Idempotent identical retries; conflicting late answers cannot overwrite the winner.
- Timeout, malformed trusted verdicts, missing routes, cancellation, and restart fail closed.
- Requested and terminal events reach the source session and all relevant parent/orchestrator owners.
- Channels receive typed approval and navigation actions; transport callback data remains channel-private.
- Existing exec/plugin Gateway methods remain compatible while their implementation converges on one service.
Non-goals
- Persisting or resuming the blocked tool execution itself across Gateway restart.
- Making an approval ID or URL a bearer credential.
- Appending approval prompts to model-visible transcripts or waking parent agents.
- Moving approval policy, product commands, or reviewer authorization into channel plugins.
- Cloning approval state per channel, device, or ancestor.
- Redesigning exec allowlists, plugin policy composition, or
allow-alwayspersistence except where required to make terminal outcomes unambiguous. - Making a gatewayless embedded TUI remotely reachable in the first increment. It remains local-only and must fail closed when no reviewer exists.
Existing system and evidence map
| Surface | Current entry point and owner | Current behavior and gap |
|---|---|---|
| Agent exec | src/agents/bash-tools.exec-approval-request.ts, src/agents/bash-tools.exec-host-shared.ts |
Two-phase exec.approval.* registration prevents an early /approve race, but timeout can still become allow through askFallback. |
| Plugin tool gate | src/agents/agent-tools.before-tool-call.ts |
Requests plugin.approval.*; timeoutBehavior: "allow" can approve a timed-out gate. Embedded mode has separate process-local authority in src/infra/embedded-plugin-approval-broker.ts. |
| Plugin node gate | src/gateway/node-invoke-plugin-policy.ts |
Creates and broadcasts directly through the plugin manager, duplicating part of the server-method lifecycle. |
| Gateway authority | src/gateway/server-aux-handlers.ts, src/gateway/exec-approval-manager.ts, src/gateway/server-methods/approval-shared.ts |
Separate exec and plugin managers use process-local maps. Terminal entries survive for 15 seconds. First-answer-wins holds only inside one process. |
| Gateway protocol | packages/gateway-protocol/src/schema/exec-approvals.ts, packages/gateway-protocol/src/schema/plugin-approvals.ts, src/gateway/methods/core-descriptors.ts |
Exec has pending-only get; plugin has no get; no kind-agnostic terminal lookup exists for a deep link. |
| Delivery | src/infra/exec-approval-channel-runtime.ts, src/infra/approval-native-runtime.ts, src/infra/approval-handler-runtime.ts |
Already supports origin routing, approver DMs, replay, native handlers, and terminal cleanup. It needs durable source state, not another router. |
| Portable actions | src/interactive/payload.ts, src/plugin-sdk/interactive-runtime.ts, src/plugin-sdk/approval-reply-runtime.ts |
Approval buttons are command actions containing /approve ...; URL and Web App targets are untyped button fields. |
| Telegram | extensions/telegram/src/approval-handler.runtime.ts, extensions/telegram/src/button-types.ts |
The renderer parses command text to recognize approval semantics before producing private callback data. |
| Control UI | ui/src/app/exec-approval.ts, ui/src/app/overlays.ts, ui/src/components/exec-approval.ts |
Approval UI is a global modal. ui/src/app-route-paths.ts and ui/src/app-routes.ts use exact routes and rewrite unknown paths to Chat. |
| Session ownership | src/agents/subagent-registry.types.ts, src/agents/subagent-registry-read.ts, src/config/sessions/types.ts |
Controller, requester, explicit parent, and legacy spawn ownership exist, but approval events are not projected to those session streams. |
| Shared state | src/state/openclaw-state-schema.sql, src/state/openclaw-state-db.ts |
Existing immediate transactions and Kysely conditional updates support durable compare-and-set in state/openclaw.sqlite. |
Representative current tests include src/gateway/exec-approval-manager.test.ts, src/gateway/server-methods/approval-shared.test.ts, src/agents/bash-tools.exec-gateway-approval.e2e.test.ts, extensions/telegram/src/approval-handler.runtime.test.ts, and ui/src/e2e/approval-flow.e2e.test.ts.
The plugin SDK remains the only channel/plugin boundary. Approval runtime and presentation changes must be exported through the existing src/plugin-sdk/approval-*.ts and src/plugin-sdk/interactive-runtime.ts subpaths; plugin production code must not import Gateway internals.
Prior art
Omnigent provides useful UX and failure semantics:
approval.pyparks ASK, applies per-policy timeouts, and treats only an exact accept as approval.sessions.pycontains the server-side native harness gate and ancestor request/resolution projection.ApprovePage.tsxprovides the standalone mobile approval page.
Do not copy its storage claim uncritically. Current active pending state is process-local in _elicitation_registry.py, and the unused pending table is removed by e3b1f2a4c9d7_drop_pending_tool_calls_table.py. OpenClaw deliberately goes further: SQLite is authoritative and every terminal transition is a database compare-and-set.
Architecture and ownership
The Gateway owns the lifecycle:
- An agent, plugin hook, or node policy supplies a kind-specific request and process-local execution binding.
- The Gateway validates it and builds a sanitized reviewer projection.
- The approval service computes a source/owner audience, inserts the canonical row, then registers the in-process waiter.
- After durable insert, the Gateway publishes existing approval events, session projections, channel notifications, and native push.
- Every surface resolves through the same service.
- The service commits one terminal transition, wakes the runtime waiter, and publishes terminal projections.
- A failed event delivery never rolls back the committed decision; clients recover through
approval.getor list replay.
Ownership boundaries:
src/gateway/: approval service, authorization, RPC adapters, URL construction, waiter lifecycle, and event publication.src/state/: shared schema and generated Kysely types.src/infra/: sanitized approval view models and portable presentation construction.src/agents/: request, wait, and apply the returned verdict; no persistence.src/channels/andextensions/*: render typed actions, authorize channel users, encode private callbacks, and update delivered controls.src/plugin-sdk/: public approval and presentation contracts only.ui/: standalone page and existing queue/modal clients.
The in-process waiter is a notification mechanism, not authority. Registration inserts the row and installs the waiter synchronously before publishing the request, so a resolver cannot interleave between those steps. Every later resolver commits through SQLite before settling that waiter.
Persistent record
Add one operator_approvals table to the shared state database.
| Column | Purpose |
|---|---|
approval_id |
Globally unique stable locator. Keep existing exec IDs and plugin: IDs for protocol compatibility, but never infer kind from the prefix. |
kind |
Closed exec | plugin discriminator. |
status |
Closed pending | allowed | denied | expired | cancelled state. |
presentation_json |
Validated, kind-tagged reviewer projection. Raw runtime requests, command bindings, and callback payloads remain process-local. |
source_agent_id, source_session_key |
Source identity and session projection anchor. Session key is durable; rotating session UUID is not. |
audience_session_keys_json |
Ordered, de-duplicated JSON array produced by the bounded breadth-first ownership walk. Requested and terminal events use this same snapshot. |
requested_by_device_id, requested_by_client_id |
Durable requester/audit metadata. Connection ID stays in memory and is not a cross-surface principal. |
reviewer_device_ids_json |
Optional explicitly targeted reviewer devices supplied only by the trusted approval runtime. |
runtime_epoch |
Process epoch that owns the parked execution; used to cancel orphaned rows after restart. |
created_at_ms, expires_at_ms, updated_at_ms |
Authoritative timing. |
decision |
Explicit user decision when one exists. |
terminal_reason |
Closed reason such as user, timeout, malformed-verdict, no-route, run-aborted, or gateway-restart. |
resolved_at_ms, resolver_kind, resolver_id |
Winner and audit identity retained server-side. Reviewer projections omit raw resolver identifiers. |
consumed_at_ms, consumed_by |
Separate replay guard for allow-once; consuming must not erase the recorded decision. |
Required indexes:
(status, expires_at_ms)(source_session_key, created_at_ms DESC)(resolved_at_ms)for retention pruning
Audience arrays are small and bounded. Session-filtered replay first selects visible pending rows through Kysely, then decodes and filters the bounded audience arrays in application code; it does not use string matching or raw SQL JSON queries.
Retain terminal rows for 30 days, aligned with metadata audit retention in src/audit/audit-event-store.ts. Pruning is fixed maintenance policy, not a new config surface. The database is private local control-plane state, but reviewer APIs must never expose the full stored request or runtime binding.
State machine and compare-and-set
Only these transitions are valid:
pending -> allowed: explicitallow-onceorallow-always.pending -> denied: explicit deny, trusted malformed terminal verdict, or no delivery route.pending -> expired: authoritative deadline reached.pending -> cancelled: run abort, graceful shutdown, or restart orphan recovery.
Every non-allowed terminal state has effective verdict deny.
Resolution uses one immediate SQLite transaction and a Kysely conditional update equivalent to:
UPDATE operator_approvals
SET status = ?, decision = ?, terminal_reason = ?, resolved_at_ms = ?
WHERE approval_id = ?
AND status = 'pending'
AND expires_at_ms > ?;
If the update affects no row, the same transaction reads the record:
- Missing or unauthorized: return not found; do not reveal existence.
- Still pending but deadline reached: compare-and-set it to
expired, then return that terminal row. - Same recorded decision: return idempotent success with the recorded winner.
- Different decision: the unified API returns
applied: falsewith the recorded winner; legacy adapters retainAPPROVAL_ALREADY_RESOLVEDwhere required by their shipped contract. - Any terminal state: never mutate it.
now == expires_at_ms is expired. Gateway time is authoritative.
allow-once execution uses a second CAS over consumed_at_ms IS NULL, bound to the existing exact command/system-run context. The approval row remains an audit record after consumption.
Malformed HTTP/RPC input that cannot be authenticated or identify an approval is rejected without mutation and can never approve. A malformed terminal verdict received from a trusted harness/waiter for a known approval transitions to denied.
Gateway API
Add kind-agnostic reviewer methods:
| Method | Contract |
|---|---|
approval.get { id } |
Returns a visible pending or retained terminal projection. |
approval.resolve { id, decision } |
Runs authorization, allowed-decision validation, deadline reconciliation, and terminal CAS. |
Kind-specific request validation remains in exec.approval.request and plugin.approval.request. Existing exec.approval.get/list/waitDecision/resolve and plugin.approval.list/waitDecision/resolve become protocol-boundary adapters to the canonical service because they are shipped Gateway API. Internal callers migrate to the service in the same change.
A reviewer projection is a tagged union:
type OperatorApproval = {
id: string;
status: OperatorApprovalStatus;
presentation:
| { kind: "exec"; commandText: string /* safe exec preview */ }
| { kind: "plugin"; title: string; description: string /* safe plugin preview */ };
// common lifecycle fields
};
The stable path is derived, not persisted. approval.get returns urlPath; surfaces that know an approved public origin may also receive an absolute url. Reviewer snapshots omit source and audience session keys. The Gateway keeps those routing keys server-side for the separate session.approval projection.
Events and portable actions
PR 1 preserves the shipped event names, payloads, and existing record-level recipient filters:
exec.approval.requestedexec.approval.resolvedplugin.approval.requestedplugin.approval.resolved
Those legacy events can contain the full runtime request, so they must not be fanned out to every approval-scoped client. PR 3 adds tagged lifecycle fields (status, sourceSessionKey, urlPath, terminal metadata, and a presentation-level kind) through the sanitized lifecycle projection instead of widening legacy event delivery.
Add an approval-scoped session.approval projection event. Publish the canonical event once with the persisted audience keys; exact-session subscribers receive the same event for each matching key:
sessionKey: stream receiving the projection.sourceSessionKey: child/source that raised the gate.phase:requested \| terminal.- one safe
OperatorApprovalprojection.
Register the event under operator.approvals in src/gateway/server-broadcast.ts. Session subscription alone never grants approval visibility.
Extend MessagePresentationAction in src/interactive/payload.ts:
type MessagePresentationAction =
| { type: "command"; command: string }
| { type: "callback"; value: string }
| {
type: "approval";
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalDecision;
}
| { type: "url"; url: string }
| { type: "web-app"; url: string };
Core builds typed decision actions and a separate Review link. Channels encode an approval action into their own callback format and send resolution to the canonical service. They must not parse /approve text or infer kind from an ID prefix.
Keep button.url, button.webApp, and command-backed approval controls as deprecated plugin SDK compatibility inputs. Normalize them at the SDK boundary; migrate every bundled internal caller in the same PR. /approve {id} {decision} remains a text fallback and CLI/chat command, not the button semantic contract.
Control UI
The route is ${basePath}/approve/{approvalId}. The ID is the only path parameter; source session identity comes from the record.
Because the current router has exact static routes and rewrites unknown paths to Chat, detect this deep link in ui/src/app/bootstrap.ts before normal route normalization. Reuse normal Gateway/auth setup, but render a standalone approval page outside the sidebar shell and global modal.
Page states:
- loading
- authentication required
- pending
- resolving
- approved or denied here
- resolved elsewhere
- expired
- cancelled
- forbidden/not found
- connection error with retry
The page calls Gateway RPC, not a second unauthenticated REST API. A browser refresh re-reads durable state. It never places Gateway credentials in the URL, query, or fragment.
Authorization and privacy
The URL is a locator, not authority. Resolution requires:
- authenticated Gateway connection;
operator.approvalsoroperator.admin;- record-level reviewer authorization.
Record-level rules:
operator.adminmay review.reviewer_device_idsis authoritative when present. Only a listed pairedoperator.approvalsdevice may review; the requesting device has no implicit access unless it is also listed.- Without an explicit reviewer list, the requesting paired
operator.approvalsdevice may review its own record. - Genuinely legacy records with no requester or reviewer binding retain broad paired-device visibility so upgrades do not strand already-pending work.
- Device-less internal runtimes may resolve, but not read, through the scoped
approval-runtime connection. That authority comes only from the
server-authenticated runtime token; public
approval.resolvefields cannot mint it. - Live requester connection ownership remains valid for legacy adapters; it is never inferred from a matching client name.
- Audience membership changes presentation only. It never widens authorization.
approval.get exposes only the sanitized reviewer projection and omits internal source/audience routing keys. The PR 3 session.approval event carries its one destination sessionKey plus sourceSessionKey after the Gateway applies the persisted audience snapshot server-side. Existing exec/plugin events keep their historical payload and restricted recipients until consumers migrate. The executable request, command binding, and continuation remain only in the process-local waiter. The durable row contains the safe presentation plus lifecycle, routing, and audit metadata; it never stores raw environment values, credentials, auth headers, or channel callback data.
Audience projection
Compute the audience once before insert and persist the ordered snapshot. Ownership is a graph, not always a single parent chain: a child may have both a current controller and an original requester, and those owners can lead to different roots.
Use a deterministic breadth-first walk:
- Seed the queue with the source session key.
- For each dequeued key, read the latest subagent registry row and enqueue both distinct ownership edges in fixed order:
controllerSessionKey, thenrequesterSessionKey. - When a usable registry row exists, do not also follow session-entry lineage that may be stale after steering. Otherwise enqueue the single current fallback edge
parentSessionKey ?? spawnedBy. - Normalize and de-duplicate on enqueue so the first, shortest path wins.
- Stop at 64 unique keys; this audience-size cap also bounds traversal depth.
The registry source is src/agents/subagent-registry-read.ts; ownership fields are defined in src/agents/subagent-registry.types.ts. Session fallback fields are defined in src/config/sessions/types.ts.
Requested and terminal projections use the same persisted audience even if focus/controller ownership changes while the approval is pending. This guarantees that every surface that displayed the request receives terminal cleanup. Resolution always targets the source approval ID; audience sessions never receive cloned approval state.
Do not write transcript messages, inject system prompts, start owner turns, or emit sessions.changed solely for an approval.
Restart, timeout, and route semantics
SQLite persistence does not imply execution resumption. Command/tool bindings remain in memory because they can contain security-sensitive runtime facts and are not a resumable job contract.
On Gateway startup:
- generate a new runtime epoch;
- atomically transition pending rows from older epochs to
cancelledwith reasongateway-restart; - retain rows so their URLs explain what happened;
- never execute a later approval against a missing runtime binding.
Timers are wake-up optimizations. Deadline authority is stored expires_at_ms; reads, waits, and resolves all run expiry reconciliation.
Final strict behavior:
- timeout ->
expired, deny; - no route ->
denied, deny; - run abort ->
cancelled, deny; - malformed trusted verdict ->
denied, deny; - only an allowed explicit allow decision ->
allowed.
Current shipped behavior conflicts with this contract:
src/agents/bash-tools.exec-host-shared.tsmay applyaskFallback.src/agents/agent-tools.before-tool-call.tsmay honortimeoutBehavior: "allow".docs/tools/exec-approvals.md,docs/cli/approvals.md, anddocs/plugins/plugin-permission-requests.mddocument those surfaces.
Do not silently change them in the storage PR. The strict-semantics PR must update code, types, docs, tests, and changelog together, with explicit owner/security review. askFallback may continue to describe pre-gate policy selection during migration, but it must not turn a created pending record's timeout into approval.
Compatibility plan
- Additive Gateway protocol; no protocol version bump.
- Preserve existing exec/plugin methods and events at the external boundary.
- Keep existing IDs, including
plugin:prefixes, but stop using prefixes as type information. - Keep
/approvetext command behavior. - Keep legacy button URL/Web App fields and command actions as plugin SDK compatibility input; new core output is typed.
- Migrate all bundled channels and internal callers in the same typed-action change.
- Add a changelog entry for the new URL/page and for the later timeout behavior change.
- Do not add an elicitation-mode setting.
Rollout
PR 1: durable lifecycle
- This design note.
- Shared SQLite schema, Kysely generation, store, and 30-day pruning.
- Gateway approval service, runtime waiter bridge, and restart orphan handling.
- Unified
approval.get/resolve. - Exec/plugin method adapters.
- First-answer-wins, idempotency, expiry, authorization, and consumption tests.
- No UI or channel behavior change yet.
PR 2: deep link and typed actions
- Standalone Control UI approval page and base-path-aware startup routing.
- Typed approval, URL, and Web App actions.
- Core presentation builders and plugin SDK exports.
- Telegram migration first; migrate other bundled parsers that infer approval semantics from command text.
- Gateway-authored URL builder and native/mobile payload support.
- UI, SDK, Telegram, and reconnect tests.
PR 3: propagation and fail-closed behavior
session.approvalrequest/terminal delivery from the audience snapshot persisted in PR 1.- Migrate
node-invoke-plugin-policy.tsand the embedded plugin broker away from duplicate authority. - Strict timeout/malformed/no-route semantics and compatibility docs.
- Multi-surface and nested-subagent end-to-end proof.
Tests
Required focused coverage:
- SQLite reopen preserves pending and terminal projections.
- Two concurrent resolvers produce exactly one CAS winner.
- Same-decision retry succeeds idempotently; conflicting retry returns the recorded winner.
- Resolve at or after deadline cannot approve.
allow-onceis consumable exactly once without erasing terminal audit state.- Startup cancels older runtime epochs.
- Unauthorized lookup and resolution do not reveal record existence.
- Explicit reviewer allowlist and general paired
operator.approvalsbehavior. - Exec and plugin legacy methods share the same store.
- Gateway request/list/get/resolve schemas and additive event payloads.
- Typed-action normalization, fallback rendering, SDK exports, and bundled channel switches.
- Telegram callback encoding contains transport-private data and no command-string inference.
- Direct child, branched controller/requester owners, nested owners, reassignment, session-field fallback, cycle, and audience-size cap.
- Requested and terminal audience arrays are identical.
- Owner projections cause no transcript mutation or agent wake.
- Control UI route works at
/and a configured base path; refresh shows pending or terminal truth. - Simultaneous Control UI and Telegram answers show one winner and "resolved elsewhere" on the loser.
- User-path proof through Testbox/Crabbox, including a mobile-width approval page and Telegram action cleanup.
Observability
Emit structured, content-free transition logs with approval ID, kind, source session key, status, reason, and latency. Never log the preview or raw binding.
Track:
- requested count by kind;
- terminal count by kind/status/reason;
- pending gauge;
- request-to-terminal latency;
- resolution race outcomes: winner, idempotent retry, conflict, expired;
- delivery route count and no-route denials;
- startup-orphan cancellations;
- audience size.
A committed transition is success even if later event delivery fails. Delivery failure is logged separately and repaired through list/get replay.
Open decisions
- Externally reachable Control UI origin.
src/gateway/control-ui-links.tscan derive loopback, LAN, tailnet, and custom-bind URLs, butallowedOriginsis an authorization allowlist, not a canonical navigation origin. Recommended: add narrowly scopedgateway.controlUi.publicUrlonly after confirming existing Tailscale/device-pair public URL seams cannot supply the target. Never let a channel guess the origin. - Strict timeout compatibility cutover. The target is fail-closed, but
askFallbackand plugintimeoutBehavior: "allow"are shipped contracts. Recommended: make the behavior change in PR 3 with explicit owner/security approval, changelog, docs, and a migration/deprecation decision rather than hiding it in PR 1. - Gatewayless embedded mode. Recommended: keep it local-only initially, then make it a client of the canonical service when a Gateway exists. Do not advertise a deep link that no server can resolve.