diff --git a/AGENTS.md b/AGENTS.md index 63b42df..3b5e876 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ Key methods: **Codex chat** (`chat/codex/adapter.rs`, `rollout.rs`; only with `[codex] hooks = true`, else Codex has no chat and its activity is `unknown`): the worker calls `chat::codex::adapter::prepare_chat` (a `chat-/` dir like Claude's, no extra arguments; the adapter's `Drop` removes it) and sends `hello.chat` (required: an older worker is `recovery_required`; the descriptor offers `chat-v1` for Claude or a worker that says so). Atem's hooks write `events/ev--.json` (first line the hook's `$$ $PPID`, then its stdin; `EventReader` takes only that name, in (seq, name) order, at most 256 per poll, `O_NOFOLLOW`, ≤ 1 MiB, deleting each once read) and the rollout JSONL named by the hook's `transcript_path` (must be `/sessions/…/rollout-*-.jsonl`, symlinks resolved, read with `O_NOFOLLOW`, ≤ 8 MiB per poll) carries all content: `RolloutReader` turns `event_msg` records into log items (`task_started` → turn start; `item_completed` user message, agent message, command execution → a `Bash` tool part; `task_complete` → done, `turn_aborted` → interrupted; everything else, e.g. reasoning, is skipped; a known record missing a required field withdraws chat, an automatic compaction's nested `task_started` is suppressed). Hooks pin the session: a hook counts only when the session's agent started it (`approvals::HookAgent`/`started_by`, shared with approvals: direct or through one shell it may pin or re-pin on `/new`, through a non-shell child (npm's wrapper) only the pinned session's or the first, which makes that child the agent so the real binary's `/new` re-pins and a nested `codex` can't; a wrapped hook read after its parent exited counts as foreign, harmless); a SessionStart of another session re-pins after one poll (old rollout read to its end first), or after the current turn ends (deferred). Status: `unknown` until the first hook (Codex fires SessionStart lazily, at the first prompt) with the detail "type a first prompt in the terminal: …", then `working` (UserPromptSubmit or `task_started`), `waiting` while the worker's open-approval set for this session is non-empty (its only source for remote waits) or while an approval the worker released ("no decision": input to Codex, no broker, an adopting broker that doesn't know it) sits in Codex's own dialog — until its PostToolUse or Interrupt hook, a Stop, a turn end or the turn's next rollout item (`set_approvals`), `ready` at the turn's end. A resumed rollout is read to its end at the switch (several 8 MiB reads if needed), so all of it is history. The agent's own UserPromptSubmit of another session read before that session's SessionStart (async hooks publish in either order after `/new`) re-pins like a first hook (its late SessionStart is then no restart); a nested `codex` never does. With `[codex] hooks = true` the worker itself runs the idempotent hooks.json merge (backup, read-back, restore) in the launch's `CODEX_HOME` before spawning, so sessions launched from the bare `atem` menu get every hook too; it never asks and never writes when the setting is unset or false; a failure is a session notice and Codex starts anyway. Before the first prompt chat opens but a `submit` is refused with that text (`Refused` receipt detail and chat-v1 `error.text`), since text typed into Codex's startup dialogs could answer them. A remote prompt is accepted only when UserPromptSubmit and the rollout's user message agree on `turn_id` (whitespace-blind text match), and only evidence stamped after the paste counts (`at_ms`: the event file's mtime or the log's timestamp; a whole-second time counts to the end of its second), so a local prompt typed just before is a local turn; this applies to Claude too. A turn end or prompt answers a permission request only when it is newer than the request (`opened_ms <= at_ms`); tool-result evidence keeps 1 s slack. A turn that is not the prompted one (compaction) emits no `complete`. `interrupt` writes Esc only while `working` or `waiting` and completes at the turn's end; in `ready`/`unknown` it completes at once with nothing written (Esc on a startup dialog quits Codex); with no turn state (hooks off; for Claude too: before its SessionStart or after its chat was withdrawn) it is `rejected` "no turn state; use the terminal", and a prompt "chat is not available for this agent; use the terminal". Capabilities say what Codex's chat can't do: `unavailable` = `streaming` (replies arrive whole), `reasoning`, `error_details` (Claude: `streaming`, `reasoning`; "always allow" is offered as an Atem session rule, see Approval hooks), in the worker's `chat`/`chat_snapshot` messages and chat-v1 `opened.unavailable`. Known limits: whole-second filesystems and logs allow same-second races. See `designs/agent-chat-host.md` (Codex) and `designs/fixtures/codex-hooks/README.md`. -**Approval hooks** (`src/chat/approval_hook.sh`, `chat/approvals.rs`, `chat/codex/`, `runtime/worker_approvals.rs`, `runtime/host_bridge.rs`, `runtime/push_queue.rs`; contract §6a): agent permission prompts reach the worker through a `PermissionRequest` hook that writes `req-.json` into the session's `approvals/` dir and waits for `dec-.json` (claimed by renaming it to `taken-.json`, then set aside as `claimed-` once read, the claim time kept; the worker withdraws an unclaimed one the same way, so exactly one side gets it, and removes the files only once the hook process is gone). Claude gets it per launch in its `--settings` (600 s; its own dialog stays visible, first answer wins) unless `[claude] approvals_hook = false` in config.toml (default on, never asked; read when the worker starts: off leaves out the `PermissionRequest` hook and its `PostToolUse` note, the chat hooks stay); Codex gets one Atem-marked entry per hook event (`chat::codex::EVENTS`: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, Stop, Interrupt; commands start with `: atem-approval-hook;`, all run the one script `~/.config/atem/hooks/approval-hook.sh`, the same command for every session, `ATEM_APPROVAL_DIR` set by the worker; outside Atem they exit at once) in `$CODEX_HOME/hooks.json`, appended after the user's entries (Codex trusts each handler by position plus hash, so theirs keep their trust; the PermissionRequest entry is byte for byte the earlier one: sync, 300 s), after `atem agent launch codex` asks once (`[codex] hooks` in config.toml, one answer for approvals and chat; until it is `true` a Codex worker writes no hook script and watches nothing). The other events are `async: true` (SessionEnd is always sync), 3 s, and only record their stdin as `events/ev--.json` in the session directory (`approval_hook.sh event`: 10-digit monotonic seq under `events/seq.lock`, a symlink naming the holder's pid, taken over only when that pid is gone; published with `ln`, never overwriting a file, 0600, no output, exit 0 within ~2.5 s; readers take only `ev-*.json` and ignore `tmp.*`, `seq`, `seq.`, `seq.lock`). Install is idempotent: an equal entry stays, an older Atem entry is replaced in place, a missing one appended, extra Atem entries of an event dropped. The merge keeps every other byte, writes `hooks.json.atem-bak` first (empty when there was no file) and keeps the file's mode. Setting `[codex] hooks = false` removes every Atem-marked entry on the next `atem agent launch codex` (only groups made of marked hooks; anything unexpected is refused; a copy first as `hooks.json.atem-removed.`; read back and restored if it doesn't match; no file again when there was none before Atem and nothing else is left); `atem unpair` never touches hooks.json. A Codex hook counts only when the session's agent started it: directly (or through one shell) its Codex session becomes the pinned one, so `/new` keeps working; through a non-shell child it must be the pinned session; a request without a session id counts only when the hook's parent is the agent itself. The worker reports each request redacted (tool, the command's first line or the file path, Claude's own `permission_suggestions` as "always allow" rules); the broker opens it in `SessionHost` whoever holds the lease: `approval_request` goes to subscribed attachments with `control` (view-only ones get nothing), a chat-v1 card appears in their conversations, and `push_approval{approval_id, atem_device_id, session_id, tool, summary, expires_at}` goes to the relay as `{"type":"relay",…}` over the verified link (best effort: the latest state per approval is kept while the link is down, at most 64 entries; `push_resolved{approval_id, reason}` when it stops being pending). An answer is a `kind:"approval"` submission (allow once / always allow with an offered `rule_id` / deny with an optional message), under the current lease, or with `acquire: true` (no `lease_epoch`), which takes the lease (takeover allowed) only once the answer is durably accepted. The decision goes to the worker (`approval_decision`); `approval_applied` `ok`/`unknown`/`not_delivered` finishes the receipt `completed`/`outcome_unknown`/`rejected`, and one the agent never confirms is `outcome_unknown` after 630 s. A decision within 5 s of the hook's timeout is `not_delivered` (the hook might give up before taking it). Closes: `answered_remote`, `answered_terminal` (Claude: the tool ran, or its transcript shows the refusal), `interrupted`, `timed_out` (a hook that ended within 5 s of its timeout, our decision untaken or none), `released`, `process_ended`; lease changes never close one. Every write and paste to a Codex session carries `release_approvals` (Codex hides its prompt while a hook waits, showing "Running hook"): the waiting hooks get "no decision" and close `released` (status stays waiting), and the input that released them is consumed, never written, since the user never saw Codex's dialog it brings back (a `y` must not approve it): the local frontend gets the notice "Codex's approval was handed to the terminal; answer its dialog", a remote `input` is acked `written` with "the approval was handed to the terminal; not written", a remote prompt or interrupt is `rejected`; input after it is consumed too while the released hooks still run (at most 500 ms); input with no hook waiting is written as before. An adopted worker is told the approvals the new broker knows (none), so it releases the rest, without `push_resolved`: a phone notification may linger until its `expires_at`, and a tap gets `stale_target` (shown as already answered). Same-user processes that can see `ATEM_APPROVAL_DIR` can forge request files: the result is a fake card, never an agent action (same-user attackers are out of scope). Never an allow without a decision from Astation; any failure is "no decision". Codex can't take a persistent rule (`updatedPermissions` fails closed), so its cards offer an **Atem session rule** instead (contract §6a r7.2): `always_rules` holds one `{rule_id: "s-<16 hex>", label: "Always allow ``[ (+N more lines)] in for this session", scope: "session"}` only for a shell command (`Bash`/`shell`/`exec_command` whose `tool_input` is a string `command` plus at most `description`; any other key, e.g. a workdir or env, gets no rule) with the hook's session `cwd` (`approvals::RuleMatch`, built from the unredacted request and never leaving the worker); file changes (`apply_patch`) never get one (Allow once / Deny), though an `apply_patch` run as a shell heredoc is a command like any other (byte-exact, patch text included). An `allow_always` naming it is a plain allow for the hook; once the hook took it the worker keeps the rule for this agent process (`worker_approvals`: at most 32, a full session offers none, and one full by the time the hook takes the answer is reported `approval_rule_not_kept`, so the card ends `allowed` with a `note`; gone when the process ends, so a respawn starts with none; each kept with the `account_id`, `atem_device_id` and `host_trust_gen` it was granted under (`granted_under` on the `approval_decision`; a decision without one keeps nothing); kept across broker restarts but unconfirmed after every new broker connection, when matching requests ask as usual: the worker reports its rules as `approval_rules` (each `{rule, granted_under, confirmed}`) on connection and on `request_approval_rules`, which the broker sends when it registers a session, and the broker answers `confirm_approval_rules{rule_ids}` naming those granted under its current identity and trust — the worker confirms them and drops every other unconfirmed one; a new account, device or host trust generation while the broker runs sends every worker `revoke_all_approval_rules`). A later request of the session's pinned Codex (never a nested or foreign `codex`) whose tool, full command (byte-equal) and cwd equal a rule's is answered allow by the worker itself while a broker is connected: no approval, no push, no lease; the worker reports `approval_auto_allowed{rule_id, tool, summary}` (redacted), the broker writes one runtime.log line naming the session, tool and rule (never the command) and shows an `auto_allowed` chat card with `rule_id`. Anything else still asks (an extra `; rm`, another cwd, one byte more). The active rules go to attachments with `control` as session-v1 `approval_rules{session_id, process_gen, rules}` (on change, after each `sessions` snapshot, `[]` when the process ends) and to chat-v1 (`opened.approvalRules`, `rules` on change); the lease holder revokes one with a `kind:"approval_rule_revoke"` submission `{rule_id}` (chat-v1: `submit` with `approval_rule_revoke` instead of `text`; an inactive rule is `stale_target`), dispatched to the worker (`revoke_approval_rule` → `approval_rule_revoked`), completed when it dropped the rule; an allow written for that rule that the hook hasn't taken is withdrawn (Codex's own dialog asks). No local prefix-key revoke yet. Claude's rules are unchanged (its own suggestions, echoed as `updatedPermissions`). An interrupt whose Esc only handed a waiting Codex approval to Codex's dialog is `rejected` "the approval was handed to the terminal; interrupt again to stop the turn" (a prompt: "the approval was handed to the terminal; not written"). +**Approval hooks** (`src/chat/approval_hook.sh`, `chat/approvals.rs`, `chat/codex/`, `runtime/worker_approvals.rs`, `runtime/host_bridge.rs`, `runtime/push_queue.rs`; contract §6a): agent permission prompts reach the worker through a `PermissionRequest` hook that writes `req-.json` into the session's `approvals/` dir and waits for `dec-.json` (claimed by renaming it to `taken-.json`, then set aside as `claimed-` once read, the claim time kept; the worker withdraws an unclaimed one the same way, so exactly one side gets it, and removes the files only once the hook process is gone). Claude gets it per launch in its `--settings` (600 s; its own dialog stays visible, first answer wins) unless `[claude] approvals_hook = false` in config.toml (default on, never asked; read when the worker starts: off leaves out the `PermissionRequest` hook and its `PostToolUse` note, the chat hooks stay); Codex gets one Atem-marked entry per hook event (`chat::codex::EVENTS`: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, Stop, Interrupt; commands start with `: atem-approval-hook;`, all run the one script `~/.config/atem/hooks/approval-hook.sh`, the same command for every session, `ATEM_APPROVAL_DIR` set by the worker; outside Atem they exit at once) in `$CODEX_HOME/hooks.json`, appended after the user's entries (Codex trusts each handler by position plus hash, so theirs keep their trust; the PermissionRequest entry is byte for byte the earlier one: sync, 300 s), after `atem agent launch codex` asks once (`[codex] hooks` in config.toml, one answer for approvals and chat; until it is `true` a Codex worker writes no hook script and watches nothing). The other events are `async: true` (SessionEnd is always sync), 3 s, and only record their stdin as `events/ev--.json` in the session directory (`approval_hook.sh event`: 10-digit monotonic seq under `events/seq.lock`, a symlink naming the holder's pid, taken over only when that pid is gone; published with `ln`, never overwriting a file, 0600, no output, exit 0 within ~2.5 s; readers take only `ev-*.json` and ignore `tmp.*`, `seq`, `seq.`, `seq.lock`). Install is idempotent: an equal entry stays, an older Atem entry is replaced in place, a missing one appended, extra Atem entries of an event dropped. The merge keeps every other byte, writes `hooks.json.atem-bak` first (empty when there was no file) and keeps the file's mode. Setting `[codex] hooks = false` removes every Atem-marked entry on the next `atem agent launch codex` (only groups made of marked hooks; anything unexpected is refused; a copy first as `hooks.json.atem-removed.`; read back and restored if it doesn't match; no file again when there was none before Atem and nothing else is left); `atem unpair` never touches hooks.json. A Codex hook counts only when the session's agent started it: directly (or through one shell) its Codex session becomes the pinned one, so `/new` keeps working; through a non-shell child it must be the pinned session; a request without a session id counts only when the hook's parent is the agent itself. The worker reports each request redacted (tool, the command's first line or the file path, Claude's own `permission_suggestions` as "always allow" rules); the broker opens it in `SessionHost` whoever holds the lease: `approval_request` goes to subscribed attachments with `control` (view-only ones get nothing), a chat-v1 card appears in their conversations, and `push_approval{approval_id, atem_device_id, session_id, tool, summary, expires_at}` goes to the relay as `{"type":"relay",…}` over the verified link (best effort: the latest state per approval is kept while the link is down, at most 64 entries; `push_resolved{approval_id, reason}` when it stops being pending). An answer is a `kind:"approval"` submission (allow once / always allow with an offered `rule_id` / deny with an optional message), under the current lease, or with `acquire: true` (no `lease_epoch`), which takes the lease (takeover allowed) only once the answer is durably accepted. The decision goes to the worker (`approval_decision`); `approval_applied` `ok`/`unknown`/`not_delivered` finishes the receipt `completed`/`outcome_unknown`/`rejected`, and one the agent never confirms is `outcome_unknown` after 630 s. A decision within 5 s of the hook's timeout is `not_delivered` (the hook might give up before taking it). Closes: `answered_remote`, `answered_terminal` (Claude: the tool ran, or its transcript shows the refusal), `interrupted`, `timed_out` (a hook that ended within 5 s of its timeout, our decision untaken or none), `released`, `process_ended`; lease changes never close one. Every write and paste to a Codex session carries `release_approvals` (Codex hides its prompt while a hook waits, showing "Running hook"): the waiting hooks get "no decision" and close `released` (status stays waiting), and the input that released them is consumed, never written, since the user never saw Codex's dialog it brings back (a `y` must not approve it): the local frontend gets the notice "Codex's approval was handed to the terminal; answer its dialog", a remote `input` is acked `written` with "the approval was handed to the terminal; not written", a remote prompt or interrupt is `rejected`; input after it is consumed too while the released hooks still run (at most 500 ms); input with no hook waiting is written as before. An adopted worker is told the approvals the new broker knows (none), so it releases the rest, without `push_resolved`: a phone notification may linger until its `expires_at`, and a tap gets `stale_target` (shown as already answered). Same-user processes that can see `ATEM_APPROVAL_DIR` can forge request files: the result is a fake card, never an agent action (same-user attackers are out of scope). Never an allow without a decision from Astation; any failure is "no decision". Codex can't take a persistent rule (`updatedPermissions` fails closed), so its cards offer an **Atem session rule** instead (contract §6a r7.2): `always_rules` holds one `{rule_id: "s-<16 hex>", label: "Always allow ``[ (+N more lines)] in for this session", scope: "session"}` only for a shell command (`Bash`/`shell`/`exec_command` whose `tool_input` is a string `command` plus at most `description`; any other key, e.g. a workdir or env, gets no rule) with the hook's session `cwd` (`approvals::RuleMatch`, built from the unredacted request and never leaving the worker); file changes (`apply_patch`) never get one (Allow once / Deny), though an `apply_patch` run as a shell heredoc is a command like any other (byte-exact, patch text included). An `allow_always` naming it is a plain allow for the hook; once the hook took it the worker keeps the rule for this agent process (`worker_approvals`: at most 32, a full session offers none, and one full by the time the hook takes the answer is reported `approval_rule_not_kept`, so the card ends `allowed` with a `note`; gone when the process ends, so a respawn starts with none; each kept with the `account_id`, `atem_device_id` and `host_trust_gen` it was granted under (`granted_under` on the `approval_decision`; a decision without one keeps nothing); kept across broker restarts but unconfirmed after every new broker connection, when matching requests ask as usual: the worker reports its rules as `approval_rules` (each `{rule, granted_under, confirmed}`) on connection and on `request_approval_rules`, which the broker sends when it registers a session, and the broker answers `confirm_approval_rules{rule_ids}` naming those granted under its current identity and trust — the worker confirms them and drops every other unconfirmed one; a new account, device or host trust generation while the broker runs sends every worker `revoke_all_approval_rules`). A later request of the session's pinned Codex (never a nested or foreign `codex`) whose tool, full command (byte-equal) and cwd equal a rule's is answered allow by the worker itself while a broker is connected: no approval, no push, no lease; the worker reports `approval_auto_allowed{rule_id, tool, summary}` (redacted), the broker writes one runtime.log line naming the session, tool and rule (never the command) and shows an `auto_allowed` chat card with `rule_id`. Anything else still asks (an extra `; rm`, another cwd, one byte more). The active rules go to attachments with `control` as session-v1 `approval_rules{session_id, process_gen, rules}` (on change, after each `sessions` snapshot, `[]` when the process ends) and to chat-v1 (`opened.approvalRules`, `rules` on change); any attachment with `control` revokes one, with or without the lease (r7.3), with a `kind:"approval_rule_revoke"` submission `{rule_id}` (chat-v1: `submit` with `approval_rule_revoke` instead of `text`; view-only is `forbidden`, an inactive rule or another process `stale_target`; `lease_epoch` optional and ignored, `acquire` refused; it never takes, renews or moves the lease; journaled with a receipt like any submission), dispatched to the worker (`revoke_approval_rule` → `approval_rule_revoked`, one runtime.log line naming the rule and session, never the command), completed when it dropped the rule; an allow written for that rule that the hook hasn't taken is withdrawn (Codex's own dialog asks). No local prefix-key revoke yet. Claude's rules are unchanged (its own suggestions, echoed as `updatedPermissions`). An interrupt whose Esc only handed a waiting Codex approval to Codex's dialog is `rejected` "the approval was handed to the terminal; interrupt again to stop the turn" (a prompt: "the approval was handed to the terminal; not written"). **Vault** (`vault_client.rs`): Client for the relay-hosted shared cross-agent context store. `atem vault new/list/read/write/set-summary` — a versioned, append-only store that multiple atems read/write to hand off context between their agents. Pure request builders + human/plain renderers + a thin reqwest executor (auth: `Authorization: session ` + `?id=`). The `/api/vault` endpoints + Postgres live in the relay-server (Astation repo). See `designs/vault.md`. diff --git a/designs/agent-chat-api.md b/designs/agent-chat-api.md index 4ddd07f..74a0d1f 100644 --- a/designs/agent-chat-api.md +++ b/designs/agent-chat-api.md @@ -426,7 +426,9 @@ approvals pending across a broker restart are released without - Active rules: `opened.approvalRules`, chat `rules` on change, session-v1 `approval_rules{session_id, process_gen, rules}` (after each `sessions` snapshot and on change; `[]` when the process ends). All only with `control`. -- Revoke (lease required, no `acquire` form): chat `submit` with +- Revoke (r7.3: needs only `control`, not the lease; `lease_epoch` optional + and ignored, `acquire` refused, the lease never taken or moved; view-only + is `forbidden`): chat `submit` with `"approval_rule_revoke":{"rule_id":"s-…"}` in place of `text`, or session-v1 `submit` with `kind:"approval_rule_revoke"`, `payload:{"rule_id":"s-…"}`. It completes once the worker dropped the rule (chat `complete`); a rule not diff --git a/designs/agent-chat-host.md b/designs/agent-chat-host.md index 4cc9631..2f9715b 100644 --- a/designs/agent-chat-host.md +++ b/designs/agent-chat-host.md @@ -67,8 +67,12 @@ no `chat-v1` capability. pushed and no lease is taken. A nested `codex`, another cwd or any other command still asks. The active rules are listed in `opened.approvalRules` and re-sent with `rules` when they change; a `submit` with - `approval_rule_revoke: {rule_id}` instead of `text` revokes one (lease - required; an allow it wrote that Codex hasn't taken yet is withdrawn). A + `approval_rule_revoke: {rule_id}` instead of `text` revokes one (r7.3: + any attachment with `control`, lease or not — `lease_epoch` optional and + ignored, `acquire` refused, the lease never taken or moved; view-only is + `forbidden`, an inactive rule `stale_target`; an allow it wrote that Codex + hasn't taken yet is withdrawn; runtime.log gets one line naming the rule + and session). A rule the session can't keep when Codex takes the answer (32 already) is not kept: the card ends `allowed` with a `note`. A new account, device or trust generation while the host runs drops every rule. An `apply_patch` @@ -351,7 +355,7 @@ on the existing authenticated Astation connection. |---|---|---| | `open` | Astation → Atem | `conversationId`, `agentId`, optional `before` (messageId) and `generation` (the one `before` came from) | | `opened` | Atem → Astation | `generation`, `sequence: 0`, `status {state, since}`, `unavailable` (array of strings, below), `approvalRules` (the session's active Atem session rules `[{rule_id, label, scope}]`; `[]` without `control`), then history as `message` events (latest 50 or 256 KB, older pages with `before`) | -| `submit` | Astation → Atem | `generation`, `requestId` (UUID), and one of `text`, `approval` (§6a answer) or `approval_rule_revoke` (`{rule_id}`, r7.2) | +| `submit` | Astation → Atem | `generation`, `requestId` (UUID), and one of `text`, `approval` (§6a answer) or `approval_rule_revoke` (`{rule_id}`, r7.2; `control` only, no lease: r7.3) | | `rules` | Atem → Astation | `approvalRules` (the whole list, when it changed; conversations with `control` only) | | `accepted` | Atem → Astation | `requestId` | | `message` / `delta` | Atem → Astation | see the message model | diff --git a/designs/protocol/atem-host-handoff.md b/designs/protocol/atem-host-handoff.md index bd273af..eee1b69 100644 --- a/designs/protocol/atem-host-handoff.md +++ b/designs/protocol/atem-host-handoff.md @@ -211,7 +211,8 @@ Receipts (session-v1 `submit`), per kind: - `stop`: stops the agent; completes at its exit. - `approval`: handed to the agent's waiting permission hook; completes when the agent applied it (see [Approvals](#approvals-r7-6a-on-featapprovals)). -- `approval_rule_revoke` (r7.2): `{rule_id}` under the current lease; +- `approval_rule_revoke` (r7.2; r7.3): `{rule_id}` from any attachment + with `control`, lease or not (`lease_epoch` optional and ignored); dispatched to the session's worker, completed when it dropped the rule (an inactive rule is `stale_target` at admission). - An `interrupt` (or `prompt`) whose write only handed a waiting Codex @@ -448,10 +449,18 @@ Astation sees that changed since r6.2. (`approvalRules`) on change. Show the active rules on the session (e.g. under the chat header or in the session's details), each with a **Revoke** button. -- **Revoke.** session-v1 `submit` with `kind:"approval_rule_revoke"`, - `payload: {"rule_id": "s-…"}` and the current `lease_epoch` (no - `acquire` form: take the lease first), or chat-v1 `submit` with - `approval_rule_revoke: {"rule_id": "s-…"}` instead of `text`. The receipt +- **Revoke (r7.3: needs only `control`, no lease, no `acquire`).** + session-v1 `submit` with `kind:"approval_rule_revoke"`, + `payload: {"rule_id": "s-…"}`, or chat-v1 `submit` with + `approval_rule_revoke: {"rule_id": "s-…"}` instead of `text`. Send it from + any attachment with `control`, whether or not it holds the lease: don't + acquire or take over first, and omit `lease_epoch` (one sent is ignored, + even a stale one). Don't put `acquire` in the payload: it is exactly + `{rule_id}`, so `acquire` fails to decode (`unsupported_version`). A + revoke never moves the lease, so whoever holds control keeps it. A + view-only attachment is `forbidden`. (r7.2 required the lease; a Revoke + tapped without control was refused `stale_lease` and the rule kept + allowing.) The receipt completes once the worker dropped the rule (chat: `complete`), then the new list arrives. An unknown or already revoked rule is `stale_target` (refresh the list). A revoke also withdraws an allow written for that @@ -461,8 +470,9 @@ Astation sees that changed since r6.2. - **Rule not kept.** If the session filled up (32) between the card and the answer, the request is allowed once and the card ends `allowed` with a `note` ("allowed once; the rule wasn't kept: …"): show the note. -- New shared fixture: `approval-rule-revoke-needs-the-lease-and-an-active-rule` - (core harness; the real-runtime runner lists it as not run, since its +- Shared fixture: `approval-rule-revoke-needs-control-not-the-lease` (r7.3, + replaces `approval-rule-revoke-needs-the-lease-and-an-active-rule`; core + harness; the real-runtime runner lists it as not run, since its agent is Claude, and the real-agent Codex test covers it end to end). ### Expected relay behaviour (Astation's side) diff --git a/designs/protocol/atem-session-v1.md b/designs/protocol/atem-session-v1.md index ee5a6a3..ce39581 100644 --- a/designs/protocol/atem-session-v1.md +++ b/designs/protocol/atem-session-v1.md @@ -1,12 +1,25 @@ -# atem-session v1: sessions, control leases, receipts and approvals (r7.2: r7.1 plus Atem session rules — for review) - -Status: r7.2 (2026-10-17) adds Atem session rules (§6a): for an agent whose +# atem-session v1: sessions, control leases, receipts and approvals (r7.3: r7.2 with lease-free rule revocation — for review) + +Status: r7.3 (2026-10-17) lets any attachment with `control` revoke an Atem +session rule (§6a) with or without the lease: `approval_rule_revoke` needs +only `control`, a running session and an active rule of that exact process +(`stale_target` otherwise); its binding's `lease_epoch` is optional and never +checked (a stale one is not refused), it never takes, renews or moves the +lease, and an `acquire` field is refused (the payload is exactly +`{rule_id}`). Found live: a user tapped Revoke in Astation without holding +control, the revoke was refused `stale_lease` and the rule kept allowing. +Revocation only narrows what runs unattended, so it isn't serialized with +input. Journaling, receipts and duplicate/`request_id_reused` semantics are +unchanged. The fixture +`approval-rule-revoke-needs-the-lease-and-an-active-rule` is replaced by +`approval-rule-revoke-needs-control-not-the-lease`. +r7.2 (2026-10-17) adds Atem session rules (§6a): for an agent whose hook can't keep a persistent rule (Codex), its card offers "Always allow this command" with `scope: "session"`; Atem's worker keeps the rule for that agent process and answers a later byte-equal request itself (`auto_allowed`, no approval, no push, no lease). Attachments with `control` get the active rules (`approval_rules`) and revoke one with a `kind:"approval_rule_revoke"` -submission. New fixture: `approval-rule-revoke-needs-the-lease-and-an-active-rule`. +submission. New fixture: `approval-rule-revoke-needs-the-lease-and-an-active-rule` (replaced in r7.3). Clarified the same day (no new field): a `completed` `prompt` receipt's `detail` is its turn's outcome, `"done"` or `"interrupted"` (§6, §7). r7.1 (2026-10-17) lets a second `hello` on a welcomed attachment @@ -139,7 +152,9 @@ Every request that targets a session carries one **binding** object: terminal-v1 has no `lease_id` field (the binding's epoch is the lease); a chat `submit` is the §6 `submit` with `kind:"prompt"`, `request_id = requestId`, `payload = {"text": text}`. -- `lease_epoch` is required for mutations, absent for reads. +- `lease_epoch` is required for mutations, absent for reads; an + `approval_rule_revoke` submission (§6a, r7.3) may carry it or not, and it + is ignored. - Any mismatch → `error{code:"stale_target"}` with **no** current values; the client re-lists (§4). Errors never reveal another target's ids, paths or receipts; an unauthorized or unknown session is `unknown_session` either way. @@ -329,7 +344,7 @@ Kinds and payloads (v1): | `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) | | `stop` | `{}` | the agent process exits | | `approval` | `{"approval_id", "decision", "rule_id"?, "message"?}` (§6a) | the agent reports it applied the decision | -| `approval_rule_revoke` | `{"rule_id"}` (§6a, r7.2) | the runtime dropped the rule | +| `approval_rule_revoke` | `{"rule_id"}` (§6a, r7.2; needs `control`, not the lease: r7.3) | the runtime dropped the rule | - **Digest**: the host computes `payload_digest = sha256(JCS(payload))`, JCS = RFC 8785 over the supported domain (objects, arrays, strings, booleans, null, @@ -344,7 +359,8 @@ Kinds and payloads (v1): (terminal input/resize/stop) — is checked in this order: `control` permission (`forbidden`), then a session that accepts work (`session_closed`, even when the lease is also stale), then the current - lease (`stale_lease`). If the durable write of + lease (`stale_lease`; skipped for `approval_rule_revoke`, which needs no + lease, r7.3, and for an approval answer with `acquire`). If the durable write of `accepted` fails, the reply is `error{code:"busy"}`, nothing is recorded and the client may retry with the same `request_id`. After a double I/O failure (the write fails and so does undoing it) a request answered `busy` may still @@ -433,7 +449,7 @@ takes the lease with the answer (`acquire`). | `push_approval` | H→relay | `approval_id`, `atem_device_id`, `session_id`, `tool`, `summary`, `expires_at` | | `push_resolved` | H→relay | `approval_id`, `reason` (an `approval_closed` reason) | | `approval_rules` | H→C | `session_id`, `process_gen`, `rules: [{rule_id, label, scope}]` (r7.2; ≤ 32) | -| `submit` | C→H | §6 with `kind:"approval_rule_revoke"`, `payload: {rule_id}` (r7.2) | +| `submit` | C→H | §6 with `kind:"approval_rule_revoke"`, `payload: {rule_id}` (r7.2); `control` only, `lease_epoch` optional and ignored (r7.3) | ```json {"type":"session","data":{"v":1,"op":"approval_request","id":"…","approval_id":"88888888-…", @@ -587,10 +603,20 @@ takes the lease with the answer (`acquire`). after each `sessions` snapshot (only for sessions that have any), and with `rules: []` when the process ends. Revoking one is a §6 `submit` with `kind:"approval_rule_revoke"` and `payload: {rule_id}` (strict: - exactly that field, an opaque id) under the current lease (no `acquire` - form): admission is the §6 order (`forbidden`, `session_closed`, - `stale_lease`), then the rule must be active for exactly the binding's - process, else `stale_target` (nothing recorded or dispatched). It is + exactly that field, an opaque id) from any attachment with `control`, + with or without the lease (r7.3): admission is `forbidden` (no + `control`: view-only attachments can't revoke), `session_closed`, then the + rule must be active for exactly the binding's process, else + `stale_target` (nothing recorded or dispatched). The lease plays no part: + the binding's `lease_epoch` is optional and never checked (a stale or + someone else's epoch is not refused), and a revoke never takes, renews or + moves the lease, nor changes who holds control. `acquire` is refused, not + ignored: the payload is exactly `{rule_id}`, so `{rule_id, acquire}` fails + to decode (`unsupported_version`, nothing recorded) — a client must never + believe a revoke gave it control. Revoking only narrows what runs + unattended, so it needs no serialization with the controller's input. The + receipt is journaled like any other; a duplicate returns it and the same + `request_id` with other content is `request_id_reused`. It is dispatched to the runtime and completes once the runtime dropped the rule; the new list follows. An allow the runtime wrote for that rule and the agent hasn't taken yet is withdrawn ("no decision": the agent's own @@ -611,7 +637,7 @@ takes the lease with the answer (`acquire`). {"type":"session","data":{"v":1,"op":"approval_rules","id":"…","session_id":"ses-aaaaaaaaaaaa", "process_gen":"22222222-…","rules":[{"rule_id":"s-0123456789abcdef", "label":"Always allow `npm test` in /home/user/proj for this session","scope":"session"}]}} - {"type":"session","data":{"v":1,"op":"submit","id":"…","binding":{…,"lease_epoch":7}, + {"type":"session","data":{"v":1,"op":"submit","id":"…","binding":{"target":{…},"attachment_gen":"…"}, "request_id":"46464646-…","kind":"approval_rule_revoke","payload":{"rule_id":"s-0123456789abcdef"}}} ``` - **chat-v1 card.** A chat-v1 conversation shows each approval of its session @@ -632,8 +658,8 @@ takes the lease with the answer (`acquire`). receipt as for prompts. chat-v1 `opened` carries `approvalRules` (the active session rules; `[]` for an attachment without `control`) and a `rules{approvalRules}` event follows each change; a chat-v1 `submit` with - `approval_rule_revoke: {rule_id}` instead of `text` revokes one (its - binding with `lease_epoch`). + `approval_rule_revoke: {rule_id}` instead of `text` revokes one (r7.3: + `control` is enough; its binding's `lease_epoch` is optional and ignored). ## 7. Errors @@ -724,6 +750,12 @@ queryable, another replacement within a second `busy`). r7.2 adds revoking an Atem session rule (`approval_rules` on change and after `list`; an inactive rule and a stale lease refused with no dispatch; the revoke completes and the empty list follows; revoking again is `stale_target`). +r7.3 replaces it with `approval-rule-revoke-needs-control-not-the-lease`: a +control attachment without the lease revokes while another holds it (once +without `lease_epoch`, once naming an epoch it doesn't hold), a view-only one +is `forbidden`, an inactive rule or another process `stale_target`, `acquire` +refused, a duplicate returns the receipt, and the holder still renews at the +same epoch. ## 9. Gate: trust, freshness, channel and browser grants (joint) diff --git a/designs/protocol/fixtures/approval-rule-revoke-needs-control-not-the-lease.json b/designs/protocol/fixtures/approval-rule-revoke-needs-control-not-the-lease.json new file mode 100644 index 0000000..cf48d22 --- /dev/null +++ b/designs/protocol/fixtures/approval-rule-revoke-needs-control-not-the-lease.json @@ -0,0 +1,584 @@ +{ + "name": "approval-rule-revoke-needs-control-not-the-lease", + "setup": { + "sessions": [ + { + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + } + ], + "clients": [ + { + "name": "c1", + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "permissions": [ + "view", + "control" + ], + "holds_lease_epoch": 1, + "client_kind": "native" + }, + { + "name": "c2", + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "permissions": [ + "view", + "control" + ], + "client_kind": "native" + }, + { + "name": "c3", + "attachment_gen": "77777777-7777-4777-8777-777777777777", + "permissions": [ + "view" + ], + "client_kind": "native" + } + ], + "host": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "runtime_gen": "55555555-5555-4555-8555-555555555555", + "runtime_epoch": 7 + } + }, + "steps": [ + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000001" + } + } + }, + { + "expect": "c1", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000001", + "sessions.#": 1 + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000002" + } + } + }, + { + "expect": "c2", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000002", + "sessions.#": 1 + } + }, + { + "send": "c3", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "list", + "id": "11111111-1111-4111-8111-000000000003" + } + } + }, + { + "expect": "c3", + "match": { + "op": "sessions", + "re": "11111111-1111-4111-8111-000000000003", + "sessions.#": 1 + } + }, + { + "approval_rules": "ses-aaaaaaaaaaaa", + "rules": [ + { + "rule_id": "s-0123456789abcdef", + "label": "Always allow `npm test` in /home/user/proj for this session", + "scope": "session" + }, + { + "rule_id": "s-fedcba9876543210", + "label": "Always allow `cargo check` in /home/user/proj for this session", + "scope": "session" + } + ] + }, + { + "expect": "c1", + "match": { + "op": "approval_rules", + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "rules": [ + { + "rule_id": "s-0123456789abcdef", + "label": "Always allow `npm test` in /home/user/proj for this session", + "scope": "session" + }, + { + "rule_id": "s-fedcba9876543210", + "label": "Always allow `cargo check` in /home/user/proj for this session", + "scope": "session" + } + ] + } + }, + { + "expect": "c2", + "match": { + "op": "approval_rules", + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "rules": [ + { + "rule_id": "s-0123456789abcdef", + "label": "Always allow `npm test` in /home/user/proj for this session", + "scope": "session" + }, + { + "rule_id": "s-fedcba9876543210", + "label": "Always allow `cargo check` in /home/user/proj for this session", + "scope": "session" + } + ] + } + }, + { + "expect_none": "c3", + "for_ms": 0 + }, + { + "send": "c3", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000004", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "77777777-7777-4777-8777-777777777777" + }, + "request_id": "44444444-4444-4444-8444-000000000001", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-0123456789abcdef" + } + } + } + }, + { + "expect": "c3", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000004", + "code": "forbidden" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000005", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "request_id": "44444444-4444-4444-8444-000000000002", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-not-active" + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000005", + "code": "stale_target" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000006", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "88888888-8888-4888-8888-888888888888" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "request_id": "44444444-4444-4444-8444-000000000003", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-0123456789abcdef" + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000006", + "code": "stale_target" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000007", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "request_id": "44444444-4444-4444-8444-000000000004", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-0123456789abcdef", + "acquire": true + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000007", + "code": "unsupported_version" + } + }, + { + "assert": { + "rule_revokes": [], + "dispatches": 0, + "lease_holder": "c1" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000008", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "request_id": "44444444-4444-4444-8444-000000000005", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-0123456789abcdef" + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "re": "11111111-1111-4111-8111-000000000008", + "request_id": "44444444-4444-4444-8444-000000000005", + "kind": "approval_rule_revoke", + "state": "accepted" + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000005", + "state": "executing" + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000005", + "state": "completed" + } + }, + { + "expect": "c1", + "match": { + "op": "approval_rules", + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "rules.#": 1, + "rules.0.rule_id": "s-fedcba9876543210" + } + }, + { + "expect": "c2", + "match": { + "op": "approval_rules", + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "rules.#": 1, + "rules.0.rule_id": "s-fedcba9876543210" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000009", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666", + "lease_epoch": 1 + }, + "request_id": "44444444-4444-4444-8444-000000000006", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-fedcba9876543210" + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "re": "11111111-1111-4111-8111-000000000009", + "request_id": "44444444-4444-4444-8444-000000000006", + "kind": "approval_rule_revoke", + "state": "accepted" + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000006", + "state": "executing" + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "request_id": "44444444-4444-4444-8444-000000000006", + "state": "completed" + } + }, + { + "expect": "c1", + "match": { + "op": "approval_rules", + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "rules.#": 0 + } + }, + { + "expect": "c2", + "match": { + "op": "approval_rules", + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222", + "rules.#": 0 + } + }, + { + "assert": { + "rule_revokes": [ + "s-0123456789abcdef", + "s-fedcba9876543210" + ], + "dispatches": 2, + "lease_holder": "c1" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000010", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "request_id": "44444444-4444-4444-8444-000000000005", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-0123456789abcdef" + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "receipt", + "re": "11111111-1111-4111-8111-000000000010", + "request_id": "44444444-4444-4444-8444-000000000005", + "state": "completed" + } + }, + { + "send": "c2", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "submit", + "id": "11111111-1111-4111-8111-000000000011", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "66666666-6666-4666-8666-666666666666" + }, + "request_id": "44444444-4444-4444-8444-000000000007", + "kind": "approval_rule_revoke", + "payload": { + "rule_id": "s-0123456789abcdef" + } + } + } + }, + { + "expect": "c2", + "match": { + "op": "error", + "re": "11111111-1111-4111-8111-000000000011", + "code": "stale_target" + } + }, + { + "send": "c1", + "json": { + "type": "session", + "data": { + "v": 1, + "op": "renew", + "id": "11111111-1111-4111-8111-000000000012", + "binding": { + "target": { + "account_id": "account-a", + "atem_device_id": "host-a", + "host_trust_gen": 1, + "session_id": "ses-aaaaaaaaaaaa", + "process_gen": "22222222-2222-4222-8222-222222222222" + }, + "attachment_gen": "33333333-3333-4333-8333-333333333333", + "lease_epoch": 1 + } + } + } + }, + { + "expect": "c1", + "match": { + "op": "lease", + "re": "11111111-1111-4111-8111-000000000012", + "binding.lease_epoch": 1 + } + }, + { + "expect_none": "c1", + "for_ms": 0 + }, + { + "expect_none": "c2", + "for_ms": 0 + }, + { + "expect_none": "c3", + "for_ms": 0 + }, + { + "assert": { + "rule_revokes": [ + "s-0123456789abcdef", + "s-fedcba9876543210" + ], + "dispatches": 2, + "lease_holder": "c1" + } + } + ], + "note": "r7.3 Atem session rules: the runtime reports a session's active rules; attachments with control get approval_rules on every change and after each sessions snapshot (view-only ones get none). Revoking one is a kind:\"approval_rule_revoke\" submission that needs only the control permission: with or without the lease, lease_epoch optional and ignored (here c2 revokes while c1 holds the lease, once without lease_epoch and once naming c1's epoch). A view-only attachment is forbidden; an inactive rule_id or another process_gen is stale_target; acquire is refused (the payload is exactly {rule_id}); nothing is recorded or dispatched for any of these. An active rule is dispatched to the runtime, completes when the worker dropped it, and the shorter list follows. A duplicate returns the recorded receipt; revoking again under a new request_id is stale_target. The holder's lease is untouched: it renews at the same epoch." +} diff --git a/designs/protocol/fixtures/approval-rule-revoke-needs-the-lease-and-an-active-rule.json b/designs/protocol/fixtures/approval-rule-revoke-needs-the-lease-and-an-active-rule.json deleted file mode 100644 index 158a7ba..0000000 --- a/designs/protocol/fixtures/approval-rule-revoke-needs-the-lease-and-an-active-rule.json +++ /dev/null @@ -1,318 +0,0 @@ -{ - "name": "approval-rule-revoke-needs-the-lease-and-an-active-rule", - "setup": { - "sessions": [ - { - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222" - } - ], - "clients": [ - { - "name": "c1", - "attachment_gen": "33333333-3333-4333-8333-333333333333", - "permissions": [ - "view", - "control" - ], - "holds_lease_epoch": 1, - "client_kind": "native" - } - ], - "host": { - "account_id": "account-a", - "atem_device_id": "host-a", - "host_trust_gen": 1, - "runtime_gen": "55555555-5555-4555-8555-555555555555", - "runtime_epoch": 7 - } - }, - "steps": [ - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "list", - "id": "11111111-1111-4111-8111-000000000001" - } - } - }, - { - "expect": "c1", - "match": { - "op": "sessions", - "re": "11111111-1111-4111-8111-000000000001", - "sessions.#": 1 - } - }, - { - "approval_rules": "ses-aaaaaaaaaaaa", - "rules": [ - { - "rule_id": "s-0123456789abcdef", - "label": "Always allow `npm test` in /home/user/proj for this session", - "scope": "session" - } - ] - }, - { - "expect": "c1", - "match": { - "op": "approval_rules", - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222", - "rules": [ - { - "rule_id": "s-0123456789abcdef", - "label": "Always allow `npm test` in /home/user/proj for this session", - "scope": "session" - } - ] - } - }, - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "list", - "id": "11111111-1111-4111-8111-000000000002" - } - } - }, - { - "expect": "c1", - "match": { - "op": "sessions", - "re": "11111111-1111-4111-8111-000000000002", - "sessions.#": 1 - } - }, - { - "expect": "c1", - "match": { - "op": "approval_rules", - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222", - "rules.#": 1, - "rules.0.rule_id": "s-0123456789abcdef" - } - }, - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "submit", - "id": "11111111-1111-4111-8111-000000000003", - "binding": { - "target": { - "account_id": "account-a", - "atem_device_id": "host-a", - "host_trust_gen": 1, - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222" - }, - "attachment_gen": "33333333-3333-4333-8333-333333333333", - "lease_epoch": 1 - }, - "request_id": "44444444-4444-4444-8444-000000000001", - "kind": "approval_rule_revoke", - "payload": { - "rule_id": "s-not-active" - } - } - } - }, - { - "expect": "c1", - "match": { - "op": "error", - "re": "11111111-1111-4111-8111-000000000003", - "code": "stale_target" - } - }, - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "submit", - "id": "11111111-1111-4111-8111-000000000004", - "binding": { - "target": { - "account_id": "account-a", - "atem_device_id": "host-a", - "host_trust_gen": 1, - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222" - }, - "attachment_gen": "33333333-3333-4333-8333-333333333333", - "lease_epoch": 2 - }, - "request_id": "44444444-4444-4444-8444-000000000002", - "kind": "approval_rule_revoke", - "payload": { - "rule_id": "s-0123456789abcdef" - } - } - } - }, - { - "expect": "c1", - "match": { - "op": "error", - "re": "11111111-1111-4111-8111-000000000004", - "code": "stale_lease" - } - }, - { - "assert": { - "rule_revokes": [], - "dispatches": 0 - } - }, - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "submit", - "id": "11111111-1111-4111-8111-000000000005", - "binding": { - "target": { - "account_id": "account-a", - "atem_device_id": "host-a", - "host_trust_gen": 1, - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222" - }, - "attachment_gen": "33333333-3333-4333-8333-333333333333", - "lease_epoch": 1 - }, - "request_id": "44444444-4444-4444-8444-000000000003", - "kind": "approval_rule_revoke", - "payload": { - "rule_id": "s-0123456789abcdef" - } - } - } - }, - { - "expect": "c1", - "match": { - "op": "receipt", - "re": "11111111-1111-4111-8111-000000000005", - "request_id": "44444444-4444-4444-8444-000000000003", - "kind": "approval_rule_revoke", - "state": "accepted" - } - }, - { - "expect": "c1", - "match": { - "op": "receipt", - "request_id": "44444444-4444-4444-8444-000000000003", - "state": "executing" - } - }, - { - "expect": "c1", - "match": { - "op": "receipt", - "request_id": "44444444-4444-4444-8444-000000000003", - "state": "completed" - } - }, - { - "expect": "c1", - "match": { - "op": "approval_rules", - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222", - "rules.#": 0 - } - }, - { - "assert": { - "rule_revokes": [ - "s-0123456789abcdef" - ], - "dispatches": 1 - } - }, - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "submit", - "id": "11111111-1111-4111-8111-000000000006", - "binding": { - "target": { - "account_id": "account-a", - "atem_device_id": "host-a", - "host_trust_gen": 1, - "session_id": "ses-aaaaaaaaaaaa", - "process_gen": "22222222-2222-4222-8222-222222222222" - }, - "attachment_gen": "33333333-3333-4333-8333-333333333333", - "lease_epoch": 1 - }, - "request_id": "44444444-4444-4444-8444-000000000004", - "kind": "approval_rule_revoke", - "payload": { - "rule_id": "s-0123456789abcdef" - } - } - } - }, - { - "expect": "c1", - "match": { - "op": "error", - "re": "11111111-1111-4111-8111-000000000006", - "code": "stale_target" - } - }, - { - "send": "c1", - "json": { - "type": "session", - "data": { - "v": 1, - "op": "list", - "id": "11111111-1111-4111-8111-000000000007" - } - } - }, - { - "expect": "c1", - "match": { - "op": "sessions", - "re": "11111111-1111-4111-8111-000000000007", - "sessions.#": 1 - } - }, - { - "expect_none": "c1", - "for_ms": 0 - }, - { - "assert": { - "rule_revokes": [ - "s-0123456789abcdef" - ], - "dispatches": 1 - } - } - ], - "note": "r7.2 Atem session rules: the runtime reports a session's active rules; attachments with control get approval_rules on every change and after each sessions snapshot (none when there are none). Revoking one is a kind:\"approval_rule_revoke\" submission under the current lease: an inactive rule_id is stale_target and a wrong lease epoch stale_lease (nothing recorded, nothing dispatched); an active one is dispatched to the runtime, completes when the worker dropped it, and the empty list follows. Revoking it again is stale_target." -} diff --git a/designs/protocol/fixtures/schema.md b/designs/protocol/fixtures/schema.md index 8752a4c..ef74631 100644 --- a/designs/protocol/fixtures/schema.md +++ b/designs/protocol/fixtures/schema.md @@ -1,4 +1,4 @@ -# Fixture format (atem-session v1, r7.2) +# Fixture format (atem-session v1, r7.3) One scenario per `*.json` file; `digest-vectors.json` holds the canonical-digest known-answer vectors (`vectors`: payload → canonical string → sha256; diff --git a/src/runtime/host_bridge.rs b/src/runtime/host_bridge.rs index a5dd688..097c36b 100644 --- a/src/runtime/host_bridge.rs +++ b/src/runtime/host_bridge.rs @@ -54,8 +54,10 @@ //! `rules`); its `approval_auto_allowed` becomes an `auto_allowed` card in //! the session's conversations and one runtime.log line naming the rule //! (never the command) — no approval, no push, no lease. A revoke -//! (`approval_rule_revoke`) goes to the worker as `revoke_approval_rule` -//! and completes on its `approval_rule_revoked`. Each rule carries the +//! (`approval_rule_revoke`, from any attachment with `control`, lease or +//! not: r7.3) goes to the worker as `revoke_approval_rule` and completes on +//! its `approval_rule_revoked` (one runtime.log line: rule and session, +//! never the command). Each rule carries the //! account, device and trust generation it was granted under (sent with the //! `allow_always` decision): a broker that registers an adopted worker's //! session accepts only the rules granted under its current identity and @@ -1586,6 +1588,7 @@ impl HostBridge { continue; }; self.revokes.remove(&rid); + eprintln!("{}", rule_revoked_line(sid, &rule_id, ok)); let report = if ok { DispatchReport::Completed { detail: None, @@ -2647,6 +2650,16 @@ pub(super) fn auto_allowed_line(sid: &str, tool: &str, rule_id: &str) -> String format!("atem runtime: {sid}: {tool} allowed by session rule {rule_id}") } +/// The runtime.log line for a remote rule revocation: the rule and the +/// session only, never the command. +pub(super) fn rule_revoked_line(sid: &str, rule_id: &str, ok: bool) -> String { + if ok { + format!("atem runtime: {sid}: session rule {rule_id} revoked") + } else { + format!("atem runtime: {sid}: session rule {rule_id} not revoked (no longer active)") + } +} + /// A session `error{code:"unauthorized"}` for a link without an attachment. fn unauthorized(re: Option) -> String { use crate::session_protocol::wire::{ErrorCode, Op, SessionMessage}; diff --git a/src/runtime/host_bridge_tests.rs b/src/runtime/host_bridge_tests.rs index 7ecb327..caa0bdc 100644 --- a/src/runtime/host_bridge_tests.rs +++ b/src/runtime/host_bridge_tests.rs @@ -3053,8 +3053,9 @@ fn codex_session_rules_allow_the_same_command_until_revoked() { }) .await; - // Revoked from chat (with the lease): completed, the list empties. - let lease = peer.acquire(&sid, &pg).await; + // Revoked from chat without the lease (r7.3: `control` is enough; + // no lease is held after the restart): completed, the list empties, + // and still no lease is held. let revoke = uuid::Uuid::new_v4().to_string(); let from = peer.seen.len(); peer.chat( @@ -3062,7 +3063,7 @@ fn codex_session_rules_allow_the_same_command_until_revoked() { "conv-1", &sid, &pg, - json!({"generation": pg, "requestId": revoke, "lease_epoch": lease, + json!({"generation": pg, "requestId": revoke, "approval_rule_revoke": {"rule_id": rule_id}}), ); let rv = revoke.clone(); @@ -3074,8 +3075,15 @@ fn codex_session_rules_allow_the_same_command_until_revoked() { is_session(v, "approval_rules") && v["data"]["rules"] == json!([]) }) .await; + assert!( + !peer.seen[from..] + .iter() + .any(|v| is_session(v, "lease") || is_session(v, "lease_changed")), + "a revoke never takes or moves the lease" + ); + let lease = peer.acquire(&sid, &pg).await; - // The same command asks again; an interrupt now only hands the + // The worker stops allowing at once: the same command asks again; an interrupt now only hands the // waiting approval to Codex's dialog, and says so. ask(&fake, 5, touch); let peer = &mut rt.peer; @@ -3213,6 +3221,18 @@ async fn adopted_rules_dropped_after(change: &str) { rt.shutdown().await; } +#[test] +fn the_revoke_log_line_names_the_rule_and_session_only() { + assert_eq!( + super::rule_revoked_line("ses-aaaaaaaaaaaa", "s-0123456789abcdef", true), + "atem runtime: ses-aaaaaaaaaaaa: session rule s-0123456789abcdef revoked" + ); + assert_eq!( + super::rule_revoked_line("ses-aaaaaaaaaaaa", "s-0123456789abcdef", false), + "atem runtime: ses-aaaaaaaaaaaa: session rule s-0123456789abcdef not revoked (no longer active)" + ); +} + #[test] fn the_auto_allowed_log_line_names_the_rule_never_the_command() { let line = super::auto_allowed_line("ses-aaaaaaaaaaaa", "Bash", "s-0123456789abcdef"); diff --git a/src/session_protocol/approval_tests.rs b/src/session_protocol/approval_tests.rs index 132becf..0afe5fd 100644 --- a/src/session_protocol/approval_tests.rs +++ b/src/session_protocol/approval_tests.rs @@ -1241,14 +1241,22 @@ fn rule_revoke_payloads_decode_strictly() { ] { assert!(!decodes(&with(bad.clone())), "{bad}"); } - // The lease is required: no `acquire` form. + // r7.3: the lease doesn't govern a revoke, so `lease_epoch` is optional; + // `acquire` is refused (the payload is exactly `{rule_id}`). let mut m = with(json!({"rule_id": "s-1"})); + m["data"]["binding"] + .as_object_mut() + .unwrap() + .remove("lease_epoch"); + assert!(decodes(&m)); + assert!(!decodes(&with(json!({"rule_id": "s-1", "acquire": true})))); + let mut m = with(json!({"rule_id": "s-1", "acquire": true})); m["data"]["binding"] .as_object_mut() .unwrap() .remove("lease_epoch"); assert!(!decodes(&m)); - // chat-v1: `approval_rule_revoke` instead of `text`, with the lease. + // chat-v1: `approval_rule_revoke` instead of `text`, lease or not. let chat = |revoke: Value, lease: Option| { let mut b = json!({"target": target(P1), "attachment_gen": C1}); if let Some(e) = lease { @@ -1259,8 +1267,12 @@ fn rule_revoke_payloads_decode_strictly() { "approval_rule_revoke": revoke, "binding": b}}) }; assert!(decodes(&chat(json!({"rule_id": "s-1"}), Some(1)))); - assert!(!decodes(&chat(json!({"rule_id": "s-1"}), None))); + assert!(decodes(&chat(json!({"rule_id": "s-1"}), None))); assert!(!decodes(&chat(json!({"rule_id": "s 1"}), Some(1)))); + assert!(!decodes(&chat( + json!({"rule_id": "s-1", "acquire": true}), + None + ))); // `approval_rules` from the host: ≤ 32 rules, unique ids. let rules = |n: usize| -> Vec { (0..n).map(|i| session_rule(&format!("s-{i}"))).collect() }; @@ -1311,9 +1323,9 @@ fn session_rules_reach_control_viewers_and_end_with_the_process() { assert_eq!(ops(&got(&mut rt, "r")), ["sessions"]); rt.send("r", &revoke(&rt, "r", Some(epoch), 1, "s-1")); assert_eq!(got(&mut rt, "r")[0]["code"], "forbidden"); - // The holder of a stale epoch can't revoke. - rt.send("c1", &revoke(&rt, "c1", Some(epoch + 1), 2, "s-1")); - assert_eq!(got(&mut rt, "c1")[0]["code"], "stale_lease"); + // An inactive rule is stale, whatever the lease epoch says. + rt.send("c1", &revoke(&rt, "c1", Some(epoch + 1), 2, "s-9")); + assert_eq!(got(&mut rt, "c1")[0]["code"], "stale_target"); // The process ends: the list empties for viewers, and a revoke of the // old process's rule is stale. let fx = rt.host.process_exited(S1, None); @@ -1324,6 +1336,66 @@ fn session_rules_reach_control_viewers_and_end_with_the_process() { assert!(rt.host.approval_rules(S1).is_empty()); } +/// r7.3: any attachment with `control` revokes, lease or not; the lease +/// holder keeps its lease, epoch and deadline. +#[test] +fn a_control_attachment_revokes_without_the_lease_and_leaves_it_alone() { + let mut rt = setup(); + let epoch = list_and_hold(&mut rt, "c1"); + rt.send("v", &msg("list", json!({}))); + let fx = rt + .host + .set_approval_rules(S1, P1, vec![session_rule("s-1"), session_rule("s-2")]) + .unwrap(); + rt.apply(fx); + got(&mut rt, "c1"); + got(&mut rt, "v"); + // Another process's rule: stale_target, nothing dispatched. + let mut other = revoke(&rt, "v", None, 1, "s-1"); + other["data"]["binding"]["target"]["process_gen"] = json!(P1B); + rt.send("v", &other); + assert_eq!(got(&mut rt, "v")[0]["code"], "stale_target"); + // An unknown rule: stale_target. + rt.send("v", &revoke(&rt, "v", None, 2, "s-9")); + assert_eq!(got(&mut rt, "v")[0]["code"], "stale_target"); + assert!(rt.host.receipt(&rid(1)).is_none() && rt.host.receipt(&rid(2)).is_none()); + // No lease, no lease_epoch: revoked. + rt.send("v", &revoke(&rt, "v", None, 3, "s-1")); + let v = got(&mut rt, "v"); + let states: Vec<_> = v + .iter() + .filter(|m| m["op"] == "receipt") + .map(|m| m["state"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(states, ["accepted", "executing", "completed"]); + // A lease epoch it doesn't hold (the holder's, or a stale one) is ignored. + rt.send("v", &revoke(&rt, "v", Some(epoch + 5), 4, "s-2")); + let v = got(&mut rt, "v"); + assert!( + v.iter() + .any(|m| m["op"] == "receipt" && m["state"] == "completed") + ); + assert!(rt.host.approval_rules(S1).is_empty()); + // The holder saw only the shorter lists: no lease or session change. + let c1 = got(&mut rt, "c1"); + assert_eq!(ops(&c1), ["approval_rules", "approval_rules"]); + assert_eq!(rt.host.lease_holder(S1), Some(rt.attachment("c1").as_str())); + assert_eq!(rt.host.lease_epoch(S1), Some(epoch)); + // It still renews at the same epoch. + rt.send( + "c1", + &msg("renew", json!({"binding": binding(&rt, "c1", Some(epoch))})), + ); + assert_eq!(ops(&got(&mut rt, "c1")), ["lease"]); + // Both receipts are durable approval_rule_revoke receipts. + for n in [3, 4] { + assert_eq!( + rt.host.receipt(&rid(n)).unwrap().kind, + ReceiptKind::ApprovalRuleRevoke + ); + } +} + #[test] fn a_rule_revoked_from_chat_completes_in_chat() { let mut rt = setup(); @@ -1337,8 +1409,9 @@ fn a_rule_revoked_from_chat_completes_in_chat() { let m = json!({"type": "agentChat", "data": {"v": 1, "op": "submit", "conversationId": "conv", "agentId": S1, "generation": P1, "requestId": rid(7), "approval_rule_revoke": {"rule_id": "s-1"}, - "binding": binding(&rt, "c1", Some(epoch))}}); - rt.send("c1", &m); + "binding": binding(&rt, "v", None)}}); + // r7.3: from another control attachment, without the lease c1 holds. + rt.send("v", &m); let chats: Vec = rt .effects .iter() @@ -1363,6 +1436,8 @@ fn a_rule_revoked_from_chat_completes_in_chat() { rt.host.receipt(&rid(7)).unwrap().kind, ReceiptKind::ApprovalRuleRevoke ); + assert_eq!(rt.host.lease_holder(S1), Some(rt.attachment("c1").as_str())); + assert_eq!(rt.host.lease_epoch(S1), Some(epoch)); } #[test] diff --git a/src/session_protocol/host.rs b/src/session_protocol/host.rs index 4293bbe..1743afd 100644 --- a/src/session_protocol/host.rs +++ b/src/session_protocol/host.rs @@ -44,9 +44,11 @@ //! Atem session rules (r7.2): the runtime reports a session's active rules //! (`set_approval_rules`; the worker owns them); attachments with `control` //! get `approval_rules` on every change and after each `sessions` snapshot. -//! The lease holder revokes one with a `kind:"approval_rule_revoke"` -//! submission (an active rule only, else `stale_target`), dispatched to the -//! runtime like other work. +//! Any attachment with `control` revokes one with a +//! `kind:"approval_rule_revoke"` submission (r7.3: with or without the +//! lease, which it never needs, checks, takes or moves; an active rule of +//! that exact process only, else `stale_target`), dispatched to the runtime +//! like other work. use super::canonical::{self, MAX_SAFE_INTEGER}; use super::journal::{self, Journal, JournalError, Record, Stored}; @@ -1909,7 +1911,17 @@ impl SessionHost { _ => None, }; let acquire = answer.as_ref().is_some_and(ApprovalAnswer::acquires); - if acquire { + if kind == ReceiptKind::ApprovalRuleRevoke { + // r7.3: `control` and a running session; the lease is neither + // needed nor checked (a stale `lease_epoch` is ignored), and a + // revoke never takes, renews or moves it. + if !self.attachments[att].can(Permission::Control) { + return Err(ErrorCode::Forbidden); + } + if self.sessions[sid].state != SessionState::Running { + return Err(ErrorCode::SessionClosed); + } + } else if acquire { // Takes the lease with the answer: `control` and a session that // accepts work, but no current lease needed. if !self.attachments[att].can(Permission::Control) { @@ -2749,8 +2761,7 @@ impl SessionHost { let sid = s.spec.session_id.clone(); let data = f.data.as_ref().ok_or(ErrorCode::UnsupportedVersion)?; match data["op"].as_str() { - // An approval card answered from chat: the §6a submission. - // A session rule revoked from chat (r7.2). + // A session rule revoked from chat (r7.3: `control`, no lease). Some("submit") if data.get(wire::RULE_REVOKE_FIELD).is_some() => { let request_id = data["requestId"] .as_str() diff --git a/src/session_protocol/wire.rs b/src/session_protocol/wire.rs index efe4574..b7d72f7 100644 --- a/src/session_protocol/wire.rs +++ b/src/session_protocol/wire.rs @@ -249,11 +249,14 @@ pub struct Binding { pub lease_epoch: Option, } -/// Whether a binding belongs to a read (no `lease_epoch`) or a mutation (required). +/// Whether a binding belongs to a read (no `lease_epoch`), a mutation +/// (required), or a submission the lease doesn't govern (`lease_epoch` +/// optional and ignored: a rule revocation, r7.3). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BindingUse { Read, Mutation, + LeaseIgnored, } // ── Descriptors and receipts ────────────────────────────────────────────── @@ -705,7 +708,8 @@ impl Binding { } /// Representation rules plus §2: `lease_epoch` is required for mutations - /// and absent for reads. + /// and absent for reads; a submission the lease doesn't govern may carry + /// one or not (r7.3), and it is never checked. pub fn validate(&self, use_: BindingUse) -> Result<(), String> { self.validate_shape()?; match (use_, self.lease_epoch) { @@ -761,13 +765,15 @@ impl Receipt { } /// A submission's binding is a mutation's (`lease_epoch` required), except -/// an approval answer with `acquire: true`, which takes the lease with it. +/// an approval answer with `acquire: true`, which takes the lease with it, +/// and a rule revocation, which needs only `control` (r7.3: `lease_epoch` +/// optional and ignored). pub fn submit_binding_use(kind: ReceiptKind, payload: &Value) -> BindingUse { let acquire = payload.get("acquire") == Some(&Value::Bool(true)); - if kind == ReceiptKind::Approval && acquire { - BindingUse::Read - } else { - BindingUse::Mutation + match kind { + ReceiptKind::ApprovalRuleRevoke => BindingUse::LeaseIgnored, + ReceiptKind::Approval if acquire => BindingUse::Read, + _ => BindingUse::Mutation, } } @@ -1097,9 +1103,11 @@ fn check_chat(data: Option<&Value>, binding: Option<&Binding>) -> Result<(), Dec let use_ = match op { "open" | "close" => BindingUse::Read, // An approval answer (r7) may take the lease with it. - "submit" => match m.get("approval") { - Some(answer) => submit_binding_use(ReceiptKind::Approval, answer), - None => BindingUse::Mutation, + // A rule revocation (r7.3) needs no lease: `lease_epoch` optional. + "submit" => match (m.get(RULE_REVOKE_FIELD), m.get("approval")) { + (Some(revoke), _) => submit_binding_use(ReceiptKind::ApprovalRuleRevoke, revoke), + (None, Some(answer)) => submit_binding_use(ReceiptKind::Approval, answer), + (None, None) => BindingUse::Mutation, }, _ if binding.is_some() => { return Err(fail(None, format!("agentChat {op} cannot carry a binding"))); diff --git a/tests/runtime_fixtures_binary.rs b/tests/runtime_fixtures_binary.rs index 403d4c7..7c2608c 100644 --- a/tests/runtime_fixtures_binary.rs +++ b/tests/runtime_fixtures_binary.rs @@ -1419,7 +1419,7 @@ fn scenarios() -> Vec { const NOT_RUN: &[&str] = &[ "approval-answer-with-acquire-takes-control-from-the-holder", "approval-needs-control-and-the-lease", - "approval-rule-revoke-needs-the-lease-and-an-active-rule", + "approval-rule-revoke-needs-control-not-the-lease", "approval-survives-takeover", "closed-session-refuses-work-before-the-lease-check", "receipt-query-is-scoped-to-the-exact-target",