mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-28 00:51:12 +00:00
471 lines
39 KiB
Markdown
471 lines
39 KiB
Markdown
---
|
|
summary: "Design for durable, deep-linkable approvals across Control UI, native apps, channels, and parent sessions"
|
|
read_when:
|
|
- Changing exec or plugin approval lifecycle, storage, protocol, or authorization
|
|
- Adding approval links or native approval controls to a channel
|
|
- Projecting child-session approvals into parent or orchestrator views
|
|
title: "Multi-surface operator approvals"
|
|
---
|
|
|
|
# Multi-surface operator approvals
|
|
|
|
This design tracks [#103505](https://github.com/openclaw/openclaw/issues/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-always` persistence 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.
|
|
|
|
## Pre-rollout baseline and evidence map
|
|
|
|
This table records the implementation state when #103505 was opened. The rollout sections below track the durable registry, typed actions, deep-link page, and native-client increments built on top of that baseline.
|
|
|
|
| Surface | Baseline entry point and owner | Baseline 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` | Supports origin routing, approver DMs, pending replay, native handlers, and in-process terminal cleanup. A separate follow-up adds durable terminal reconciliation. |
|
|
| 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.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/runtime/policies/approval.py) parks ASK, applies per-policy timeouts, and treats only an exact accept as approval.
|
|
- [`sessions.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/server/routes/sessions.py) contains the server-side native harness gate and ancestor request/resolution projection.
|
|
- [`ApprovePage.tsx`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/web/src/pages/ApprovePage.tsx) provides the standalone mobile approval page.
|
|
|
|
Do not copy its storage claim uncritically. Current active pending state is process-local in [`_elicitation_registry.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/server/_elicitation_registry.py), and the unused pending table is removed by [`e3b1f2a4c9d7_drop_pending_tool_calls_table.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/db/migrations/versions/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:
|
|
|
|
1. An agent, plugin hook, or node policy supplies a kind-specific request and process-local execution binding.
|
|
2. The Gateway validates it and builds a sanitized reviewer projection.
|
|
3. The approval service computes a source/owner audience, inserts the canonical row, then registers the in-process waiter.
|
|
4. After durable insert, the Gateway publishes existing approval events, session projections, channel notifications, and native push.
|
|
5. Every surface resolves through the same service.
|
|
6. The service commits one terminal transition, wakes the runtime waiter, and publishes terminal projections.
|
|
7. A failed event delivery never rolls back the committed decision; clients recover through `approval.get` or 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/` and `extensions/*`: 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 canonical ID. Keep existing exec IDs and `plugin:` IDs for protocol compatibility, but never infer kind from the prefix. |
|
|
| `resolution_ref` | Unique full SHA-256 base64url locator for transport callbacks that cannot carry the canonical ID. It is not authorization or a public URL ID. |
|
|
| `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:
|
|
|
|
| Index | Purpose |
|
|
| ------------------------------------------ | --------------------------------------------------------------------------- |
|
|
| unique `(resolution_ref)` | Reject cross-column `approval_id`/`resolution_ref` ambiguity during insert. |
|
|
| `(status, expires_at_ms)` | Find pending approvals and reconcile authoritative deadlines. |
|
|
| `(source_session_key, created_at_ms DESC)` | Replay recent approvals for one source session. |
|
|
| `(resolved_at_ms)` | Prune retained terminal approvals according to the fixed retention policy. |
|
|
|
|
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`: explicit `allow-once` or `allow-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:
|
|
|
|
```sql
|
|
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: false` with the recorded winner; legacy adapters retain `APPROVAL_ALREADY_RESOLVED` where 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, kind, decision }` | Accepts the canonical ID or fixed-size transport reference, then runs authorization, kind and allowed-decision validation, deadline reconciliation, and terminal CAS. The response always carries the canonical ID. |
|
|
|
|
After a successful CAS, return the committed projection immediately. Legacy events, channel forwarders, and push terminalizers are best-effort follow-ups; a slow or failed surface must not delay or roll back the winning response.
|
|
|
|
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:
|
|
|
|
```ts
|
|
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.requested`
|
|
- `exec.approval.resolved`
|
|
- `plugin.approval.requested`
|
|
- `plugin.approval.resolved`
|
|
|
|
Those legacy events can contain the full runtime request, so they must not be fanned out to every approval-scoped client. PR 5 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`: `pending \| terminal`, discriminated against the approval status.
|
|
- one safe `OperatorApproval` projection.
|
|
|
|
Clients opt in with `sessions.messages.subscribe { key, agentId?, includeApprovals: true }`. The successful response adds an `approvalReplay` containing up to 1,000 current pending approvals for that exact stream key that the subscribing client is also record-authorized to review. `truncated: false` makes the filtered replay authoritative and reconnecting clients replace their local pending set with it; `truncated: true` is an overload signal and clients must keep unseen local entries until canonical lookup or later lifecycle events settle them. A later durable timeout discovered during replay emits terminal tombstones only to subscribed, record-authorized audiences before the new snapshot is returned. `operator.admin` may opt in directly; narrower clients require both a paired device identity and `operator.approvals`. Session subscription alone never grants approval visibility.
|
|
|
|
Register the event under `operator.approvals` in `src/gateway/server-broadcast.ts`. The projection is observational: it never appends transcript rows, emits `sessions.changed`, or wakes an agent.
|
|
|
|
Extend `MessagePresentationAction` in `src/interactive/payload.ts`:
|
|
|
|
```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 when an approved absolute Control UI origin is available. Channels encode an approval action into their own callback format and send resolution to the canonical service. A callback uses the exact canonical ID when it fits; otherwise it uses the row's unique full-digest `resolution_ref`. The reference is only a compact lookup key: normal Gateway authentication, record authorization, explicit kind, allowed-decision validation, deadline reconciliation, and first-answer CAS still apply. Channels must not truncate IDs, resolve hash prefixes, 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.
|
|
|
|
The document is owned by the Gateway that served its URL. Its initial connection ignores the full app's persisted remote-Gateway selection without changing or copying that selection's settings; only auth stays session-scoped to the serving Gateway. Trusted native auth or a separately confirmed `gatewayUrl` override may retarget it. The core reserves the one-segment `/approve` namespace ahead of plugin HTTP routes and static-extension detection, including IDs that end in `.json` or `.js`; when Control UI serving is disabled, the reserved route fails closed with `404`. Keep the page in the main Control UI bundle so a failed lazy chunk cannot strand a security decision on a spinner.
|
|
|
|
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:
|
|
|
|
1. authenticated Gateway connection;
|
|
2. `operator.approvals` or `operator.admin`;
|
|
3. record-level reviewer authorization.
|
|
|
|
Record-level rules:
|
|
|
|
- `operator.admin` may review.
|
|
- `reviewer_device_ids` is authoritative when present. Only a listed paired
|
|
`operator.approvals` device may review; the requesting device has no implicit
|
|
access unless it is also listed.
|
|
- Without an explicit reviewer list, the requesting paired
|
|
`operator.approvals` device 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.resolve` fields 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 5 `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:
|
|
|
|
1. Seed the queue with the source session key.
|
|
2. For each dequeued key, read the latest subagent registry row and enqueue both distinct ownership edges in fixed order: `controllerSessionKey`, then `requesterSessionKey`.
|
|
3. 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`.
|
|
4. Normalize and de-duplicate on enqueue so the first, shortest path wins.
|
|
5. 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 terminal cleanup for every audience session stream that received the request projection. Resolution always targets the source approval ID; audience sessions never receive cloned approval state. Forwarded channel-message cleanup remains the separate delivery-locator follow-up below.
|
|
|
|
Do not write transcript messages, inject system prompts, start owner turns, or emit `sessions.changed` solely for an approval.
|
|
|
|
## Delivered-surface convergence
|
|
|
|
Native approval handlers already retain their delivered message entries long enough to replace or retire active controls. Generic forwarded approval messages currently discard the `MessageReceipt`, so a decision on another surface can leave their old controls looking pending. A separate follow-up closes that gap with an `operator_approval_deliveries` child table in the shared state database.
|
|
|
|
Each row stores the approval ID, a unique delivery ID, channel/account/exact route, a bounded JSON-validated channel-private message locator, delivery timestamps, and terminalization state. It never stores callback data, decision tokens, or raw approval requests. The channel owns locator encoding and message mutation; core owns canonical status, target selection, retry policy, and fallback terminal text.
|
|
|
|
Delivery registration and terminal resolution race safely:
|
|
|
|
1. After a pending send returns its receipt, insert the delivery locator and read the parent approval status in one transaction.
|
|
2. If the parent is already terminal, schedule immediate terminalization instead of leaving the late delivery pending.
|
|
3. Every committed terminal transition separately schedules all unfinalized delivery rows; droppable broadcasts are not the trigger.
|
|
4. A channel terminalizer reports `replaced`, `retired`, or `unsupported`. Replaced suppresses a duplicate terminal message; retired sends the existing terminal follow-up; unsupported or failure falls back without rolling back the approval CAS.
|
|
5. Startup retries terminal approvals with unfinished deliveries, making cleanup resilient to Gateway restart.
|
|
|
|
This transport lifecycle is an optional delivery adapter hook, not a renderer or model-facing message action. QQ C2C/group messages currently have no edit, delete, or keyboard-clear API; that adapter remains unsupported and can only show canonical truth after a later click until the transport gains a mutation API.
|
|
|
|
## 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 `cancelled` with reason `gateway-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 exec behavior still conflicts with this contract:
|
|
|
|
- `src/agents/bash-tools.exec-host-shared.ts` may apply `askFallback`.
|
|
- `docs/tools/exec-approvals.md` and `docs/cli/approvals.md` document that surface.
|
|
|
|
Plugin approvals now fail closed on timeout and malformed verdicts; the legacy
|
|
`timeoutBehavior` field remains accepted but ignored. The exec strict-semantics
|
|
follow-up 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 `/approve` text 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: typed actions and channel callbacks
|
|
|
|
- Typed approval, URL, and Web App actions.
|
|
- Core presentation builders and plugin SDK exports.
|
|
- Transport-private callback encoding with explicit owner kind.
|
|
- Durable fixed-size callback references for canonical IDs beyond transport limits.
|
|
- Bundled channel migration away from command-text and approval-ID inference.
|
|
- Canonical first-answer truth on the clicked surface and best-effort active-native terminal updates; durable channel-message terminalization remains a follow-up.
|
|
- SDK and bundled-channel tests.
|
|
|
|
### PR 3: Control UI deep link
|
|
|
|
- Standalone authenticated approval page and base-path-aware startup routing.
|
|
- Serving-Gateway binding without mutating the operator's saved remote selection.
|
|
- Core-owned approval HTTP namespace, including asset-like IDs.
|
|
- Gateway-authored URL payload and pending-state polling until lifecycle events ship.
|
|
- Mobile-width, reconnect, competing-answer, reload, and mounted-path proof.
|
|
|
|
### PR 4: native clients
|
|
|
|
- iOS and Android review surfaces use kind-aware `approval.get/resolve`; watchOS relays reviewer-safe prompts and decisions through the paired iPhone.
|
|
- Watch offers the exec decisions supported by its compact relay contract: allow once and deny.
|
|
- Canonical first-answer terminal truth replaces local attempted-decision state.
|
|
- Lost or ambiguous resolve acknowledgements freeze controls until canonical readback.
|
|
- Previous shipped Gateway v4 instances retain exec review through a narrow legacy-method fallback; retained cross-surface terminal state requires the unified methods.
|
|
- Reviewer warnings and owner context remain visible across iPhone, Watch, and Android.
|
|
- Native unit, build, and platform proof.
|
|
|
|
### PR 5: ancestor lifecycle propagation
|
|
|
|
- `session.approval` pending/terminal delivery from the audience snapshot persisted in PR 1.
|
|
- Exact-session subscription, reconnect replay, and terminal tombstones without transcript mutation or agent wake.
|
|
- Lifecycle callbacks run after durable insert/CAS and never become approval authority.
|
|
- Nested-subagent and reconnect proof.
|
|
|
|
### PR 6: fail-closed behavior
|
|
|
|
- Migrate `node-invoke-plugin-policy.ts` and the embedded plugin broker away from duplicate authority.
|
|
- Strict timeout, malformed, no-route, binding, and allow-once consumption semantics.
|
|
- Deprecate shipped permissive timeout settings without honoring them after an ask is pending.
|
|
- Multi-surface contention and failure-injection proof.
|
|
|
|
### Follow-up: durable remote-message cleanup
|
|
|
|
- Persist forwarded-delivery locators and terminalize every delivered channel message after restart.
|
|
- Keep this transport lifecycle separate from canonical approval authority and typed presentation actions.
|
|
|
|
## 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-once` is 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.approvals` behavior.
|
|
- 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.
|
|
- Native approval identifiers and Gateway owner identifiers preserve exact UTF-8 bytes across routing and reconciliation.
|
|
- Native RPC-family negotiation pins one canonical or legacy family per admitted Gateway route and never silently downgrades after use.
|
|
- Lost native resolve acknowledgements freeze actions until canonical readback; failed readback cannot fabricate a winner or acknowledge a Watch refresh.
|
|
- Watch snapshot request correlation is accepted only for the exact paired Gateway owner and a completed canonical iPhone readback.
|
|
- User-path proof through Testbox/Crabbox, including a mobile-width approval page, Telegram action cleanup, and one pending/resolve/late-loser round trip across Android, iPhone, and Watch.
|
|
|
|
## 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. Lifecycle subscribers recover through PR 5 replay and canonical lookup. Durable channel-message terminalization remains the separate follow-up above.
|
|
|
|
## Open decisions
|
|
|
|
1. **Externally reachable Control UI origin.** Every snapshot carries the stable relative `urlPath`. An absolute URL may be advertised only from a cached Tailscale Serve/Funnel location after Gateway exposure succeeds; `allowedOrigins`, request Host headers, `gateway.remote.url`, and display-only loopback/LAN candidates are not canonical origins. Telegram can use its authenticated Mini App wrapper to retain the approval path through bootstrap. Arbitrary reverse proxies remain relative-only until a separately reviewed explicit public-URL contract exists. Never let a channel guess the origin.
|
|
2. **Exec strict timeout compatibility cutover.** Plugin approval timeouts now fail closed and `timeoutBehavior` is deprecated. The remaining shipped `askFallback` contract needs explicit owner/security review, changelog, docs, and a migration/deprecation decision before it stops authorizing execution after a pending ask times out.
|
|
3. **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.
|