Multi-surface operator approvals
기준일: 2026-07-26
공식 기준: Multi-surface operator approvals
Multi-surface operator approvals 문서는 OpenClaw 공식 문서(refactor/operator-approvals)를 한국어로 정리한 가이드입니다. Design for durable, deep-linkable approvals across Control UI, native apps, channels, and parent sessions 명령·설정 키·코드 예시는 공식 문서를 그대로 보존하며, 해석과 절차 안내는 한국어로 제공합니다. 최종 동작은 설치된 CLI 버전과 공식 원문을 확인하세요.
핵심 요약
Design for durable, deep-linkable approvals across Control UI, native apps, channels, and parent sessions
한국어 가이드 범위: refactor/operator-approvals 경로의 설정·명령·제약·예시를 학습용으로 재구성합니다.
문서 구성
공식 문서의 주요 섹션은 다음과 같습니다.
- Multi-surface operator approvals
- Goals
- Non-goals
- Pre-rollout baseline and evidence map
- Prior art
- Architecture and ownership
- Persistent record
- State machine and compare-and-set
- Gateway API
- Events and portable actions
- Control UI
- Authorization and privacy
- Audience projection
- Delivered-surface convergence
- Restart, timeout, and route semantics
- Compatibility plan
- Rollout
- PR 1: durable lifecycle
- PR 2: typed actions and channel callbacks
- PR 3: Control UI deep link
- PR 4: native clients
- PR 5: ancestor lifecycle propagation
- PR 6: fail-closed behavior
- Follow-up: durable remote-message cleanup
- Tests
- Observability
- Open decisions
상세 내용
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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
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.
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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
| 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. |
Prior art
Omnigent provides useful UX and failure semantics:
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
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.
Architecture and ownership
- 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.getor list replay.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
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.
Persistent record
Add one operator_approvals table to the shared state database.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
| 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. |
| 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. |
State machine and compare-and-set
Every non-allowed terminal state has effective verdict deny.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
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.- 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.
UPDATE operator_approvals
SET status = ?, decision = ?, terminal_reason = ?, resolved_at_ms = ?
WHERE approval_id = ?
AND status = 'pending'
AND expires_at_ms > ?;
Gateway API
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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
| 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. |
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
};
Events and portable actions
PR 1 preserves the shipped event names, payloads, and existing record-level recipient filters:
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
exec.approval.requestedexec.approval.resolvedplugin.approval.requestedplugin.approval.resolvedsessionKey: stream receiving the projection.sourceSessionKey: child/source that raised the gate.phase:pending \| terminal, discriminated against the approval status.- one safe
OperatorApprovalprojection.
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 };
Control UI
The route is ${basePath}/approve/{approvalId}. The ID is the only path parameter; source session identity comes from the record.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
- loading
- authentication required
- pending
- resolving
- approved or denied here
- resolved elsewhere
- expired
- cancelled
- forbidden/not found
- connection error with retry
Authorization and privacy
The URL is a locator, not authority. Resolution requires:
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
operator.adminmay review.reviewer_device_idsis authoritative when present. Only a listed paired- Without an explicit reviewer list, the requesting paired
- Genuinely legacy records with no requester or reviewer binding retain broad
- Device-less internal runtimes may resolve, but not read, through the scoped
- Live requester connection ownership remains valid for legacy adapters; it is
- Audience membership changes presentation only. It never widens authorization.
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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
- 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.
- timeout ->
expired, deny; - no route ->
denied, deny; - run abort ->
cancelled, deny; - malformed trusted verdict ->
denied, deny; - only an allowed explicit allow decision ->
allowed. src/agents/bash-tools.exec-host-shared.tsmay applyaskFallback.docs/tools/exec-approvals.mdanddocs/cli/approvals.mddocument that surface.
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
이 섹션의 세부 항목은 공식 문서 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.approvalpending/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.tsand 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
주요 항목:
- 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.
- 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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
주요 항목:
- 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.
Open decisions
- 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 andtimeoutBehavioris deprecated. The remaining shippedaskFallbackcontract 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.
위 내용은 공식 문서의 해당 섹션 요지입니다. 세부 플래그·기본값은 원문과
--help를 확인하세요.
실습 체크리스트
- 공식 문서와 로컬 버전을 대조합니다:
https://docs.openclaw.ai/refactor/operator-approvals - 관련 CLI는
openclaw --help및 하위 명령--help로 옵션을 확인합니다. - 설정 변경 시
openclaw config/openclaw doctor로 유효성을 검사합니다. - Gateway·채널·플러그인 변경 후에는 필요 시 Gateway를 재시작합니다.
자주 쓰는 명령·설정 예시
UPDATE operator_approvals
SET status = ?, decision = ?, terminal_reason = ?, resolved_at_ms = ?
WHERE approval_id = ?
AND status = 'pending'
AND expires_at_ms > ?;
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
};
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 };
관련 링크
이 가이드는 공식 문서를 한국어 학습용으로 재구성한 것입니다. 옵션 기본값·플래그 이름은 설치 버전에 따라 달라질 수 있습니다.