J112: implement D304 canvas commands across CLI, API, MCP and UI #313

Open
opened 2026-11-02 16:45:15 +00:00 by kayg · 41 comments
Owner

Implement D304 canvas open, windows, focus, and close commands, expose them through the existing API/MCP registry, enforce per-canvas authorization without duplicating J94/#303 auth work, and show the approved #104 scene 12 shared-toast notice with safe text rendering. Include CLI/API integration coverage, refusal tests, and a Playwright toast check. Job J112.

Implement D304 canvas open, windows, focus, and close commands, expose them through the existing API/MCP registry, enforce per-canvas authorization without duplicating J94/#303 auth work, and show the approved #104 scene 12 shared-toast notice with safe text rendering. Include CLI/API integration coverage, refusal tests, and a Playwright toast check. Job J112.
Author
Owner

Start: branch j112-canvas-commands, base 99668d8abc.

Plan:

  1. Read the required project docs and D299/D303/D304/D314, then inspect #303 and coordinate the canvas authorization boundary with J94.
  2. Trace the existing CLI, API/MCP registry, authorization scopes, shared toast, and test patterns.
  3. Implement the four canvas commands and registry/API behavior, authorization/refusal responses, and the approved safe-text toast.
  4. Add refusal coverage, CLI/API integration tests, and the Playwright toast check; run the required VM gates and attach production screenshots.
  5. Commit and push this branch, then comment the final SHA and verbatim gate output.

Estimate: 6 hours, subject to J94 API/scope coordination and the VM queue.

Start: branch j112-canvas-commands, base 99668d8abc8dd55bba038c2c48a04e444c33e324. Plan: 1. Read the required project docs and D299/D303/D304/D314, then inspect #303 and coordinate the canvas authorization boundary with J94. 2. Trace the existing CLI, API/MCP registry, authorization scopes, shared toast, and test patterns. 3. Implement the four canvas commands and registry/API behavior, authorization/refusal responses, and the approved safe-text toast. 4. Add refusal coverage, CLI/API integration tests, and the Playwright toast check; run the required VM gates and attach production screenshots. 5. Commit and push this branch, then comment the final SHA and verbatim gate output. Estimate: 6 hours, subject to J94 API/scope coordination and the VM queue.
Author
Owner

Finding: the UI can use the existing shared toast safely. packages/ui/src/toast.ts renders ToastMessage.message with label.textContent and already supports the requested action; J112 will pass a length-capped plain string, never messageNode or markup.

Integration finding: docs/CLI.md says hosted CLI actions, remote device transport and MCP are later slices; source search finds no MCP tools/list or tools/call handler. #303 is adding the API bearer action adapter. J112 is mapping canvas actions to the registered definitions and coordinating that route/scope with J94 rather than adding a parallel auth path.

Finding: the UI can use the existing shared toast safely. `packages/ui/src/toast.ts` renders `ToastMessage.message` with `label.textContent` and already supports the requested `action`; J112 will pass a length-capped plain string, never `messageNode` or markup. Integration finding: `docs/CLI.md` says hosted CLI actions, remote device transport and MCP are later slices; source search finds no MCP tools/list or tools/call handler. #303 is adding the API bearer action adapter. J112 is mapping canvas actions to the registered definitions and coordinating that route/scope with J94 rather than adding a parallel auth path.
Author
Owner

Finding — adapter boundary on dev at 99668d8ab. contracts/plugin.md states MCP generation is not implemented, and J94 confirmed on #303 that no MCP adapter or route is present. Its in-progress bearer action route is the shared API boundary for this work; J94 owns key parsing, liveness, expiry, and scope checks. I have requested the canvas scope names and authenticated device label there. I am continuing the UI and canvas action contract while preserving that ownership.

Finding — adapter boundary on dev at 99668d8ab. `contracts/plugin.md` states MCP generation is not implemented, and J94 confirmed on #303 that no MCP adapter or route is present. Its in-progress bearer action route is the shared API boundary for this work; J94 owns key parsing, liveness, expiry, and scope checks. I have requested the canvas scope names and authenticated device label there. I am continuing the UI and canvas action contract while preserving that ownership.
Author
Owner

Update: rebased the isolated branch onto current origin/dev ebf0ad13c (the four #310 tooltip commits); the existing UI-only diff restored without conflicts. No tests/builds have run. Both VM queues were full at preflight: compute01 4/4 slots busy with 6 queued and 38 GB free; dev-02 2/3 slots busy with 7 queued and 80 GB free. I will recheck both before any heavy run. J94 still has not supplied the route/context shape requested on #303; I am proceeding with the registered canvas action definitions and account-bound handler without editing key parsing, liveness, expiry, or authorization.

Update: rebased the isolated branch onto current origin/dev ebf0ad13c (the four #310 tooltip commits); the existing UI-only diff restored without conflicts. No tests/builds have run. Both VM queues were full at preflight: compute01 4/4 slots busy with 6 queued and 38 GB free; dev-02 2/3 slots busy with 7 queued and 80 GB free. I will recheck both before any heavy run. J94 still has not supplied the route/context shape requested on #303; I am proceeding with the registered canvas action definitions and account-bound handler without editing key parsing, liveness, expiry, or authorization.
Author
Owner

Finding: the optional machine-only form has no server-owned session target in the current canvas model. Document.targetOf stores session references, while createLiveSessions.open without a session starts a shell on each receiving client and records its session ID only after connection. Broadcasting one machine-only open to multiple clients could therefore create duplicate shells. I asked for scope clarification before extending that lifecycle; I am continuing the safe toast slice while that answer is pending.

Finding: the optional machine-only form has no server-owned session target in the current canvas model. `Document.targetOf` stores session references, while `createLiveSessions.open` without a session starts a shell on each receiving client and records its session ID only after connection. Broadcasting one machine-only open to multiple clients could therefore create duplicate shells. I asked for scope clarification before extending that lifecycle; I am continuing the safe toast slice while that answer is pending.
Author
Owner

J112 sync update: rebased j112-canvas-commands onto current origin/dev at 1f005f2bc30b2e0c70afd3c4c97496cec4eb3deb; the uncommitted toast slice restored without conflicts. No builds or tests have run.

Integration finding on this base: infraternal-plugin-canvas declares only canvas.layout.apply and canvas.layout.initialize; docs/CLI.md says hosted CLI transport is not implemented, and contracts/plugin.md says MCP generation is not implemented. J94/#303 owns API-key parsing, expiry, liveness and scopes, so J112 will not add a parallel auth route. I have requested the bearer route/context shape on #303. The optional machine-only open semantics are also pending clarification because each current canvas client starts its own shell for a target without a session ID.

J112 sync update: rebased `j112-canvas-commands` onto current `origin/dev` at `1f005f2bc30b2e0c70afd3c4c97496cec4eb3deb`; the uncommitted toast slice restored without conflicts. No builds or tests have run. Integration finding on this base: `infraternal-plugin-canvas` declares only `canvas.layout.apply` and `canvas.layout.initialize`; `docs/CLI.md` says hosted CLI transport is not implemented, and `contracts/plugin.md` says MCP generation is not implemented. J94/#303 owns API-key parsing, expiry, liveness and scopes, so J112 will not add a parallel auth route. I have requested the bearer route/context shape on #303. The optional machine-only open semantics are also pending clarification because each current canvas client starts its own shell for a target without a session ID.
Author
Owner

Focused UI evidence, synced from J112 source onto compute01: nix develop -c bun test apps/tracer/src/canvas/sessions/open-notice.test.ts passed (1 test, 0 failures), and nix develop -c bun test apps/tracer/src/canvas/model/server-store.test.ts passed (23 tests, 0 failures; includes exactly-once, account-scoped open-notice coverage). Both ran outside build-slot as single quick unit tests. I will use D314's existing-session model for machine-only opens: reuse one unique matching session, and refuse with a next step when there is none or the match is ambiguous.

Focused UI evidence, synced from J112 source onto compute01: `nix develop -c bun test apps/tracer/src/canvas/sessions/open-notice.test.ts` passed (1 test, 0 failures), and `nix develop -c bun test apps/tracer/src/canvas/model/server-store.test.ts` passed (23 tests, 0 failures; includes exactly-once, account-scoped open-notice coverage). Both ran outside build-slot as single quick unit tests. I will use D314's existing-session model for machine-only opens: reuse one unique matching session, and refuse with a next step when there is none or the match is ambiguous.
Author
Owner

Finding — the canvas action definitions and account-scoped server handler are now on the existing plugin descriptor/dispatch path; the authenticated WebSocket integration exercises open, list, focus and close. The SocketHost passes the API device label from authenticated context, and the UI notice uses the shared toast's text path with a 200-unit cap.

CLI/API integration boundary: the current CLI does not register the canvas descriptor, and PluginDescriptor::cli_plugin still emits unavailable placeholders until an authenticated device-session transport exists. #303 owns that transport. contracts/plugin.md says MCP generation is not implemented, and #303's current plan preserves that gap. I asked for the J94 route/CLI contract on #303 and asked the owner whether J112 should add the missing shared MCP adapter; continuing the backend and UI checks while that scope question is pending.

Finding — the canvas action definitions and account-scoped server handler are now on the existing plugin descriptor/dispatch path; the authenticated WebSocket integration exercises open, list, focus and close. The `SocketHost` passes the API device label from authenticated context, and the UI notice uses the shared toast's text path with a 200-unit cap. CLI/API integration boundary: the current CLI does not register the canvas descriptor, and `PluginDescriptor::cli_plugin` still emits unavailable placeholders until an authenticated device-session transport exists. #303 owns that transport. `contracts/plugin.md` says MCP generation is not implemented, and #303's current plan preserves that gap. I asked for the J94 route/CLI contract on #303 and asked the owner whether J112 should add the missing shared MCP adapter; continuing the backend and UI checks while that scope question is pending.
Author
Owner

WIP progress: committed and pushed the canvas server/action and safe open-notice slice on j112-canvas-commands at f165bbcb35. and pass. Focused UI evidence from the earlier compute01 sync remains 1/1 open-notice test and 23/23 server-store tests; the new Rust refusal/WebSocket assertions have not yet compiled. Next: sync this head to compute01, run focused Rust checks via build-slot, then wire the hosted CLI/API/MCP adapters against J94's shared contract. No full gate has run.

WIP progress: committed and pushed the canvas server/action and safe open-notice slice on j112-canvas-commands at f165bbcb353bf29289a448289e9388d8fbf1115b. and pass. Focused UI evidence from the earlier compute01 sync remains 1/1 open-notice test and 23/23 server-store tests; the new Rust refusal/WebSocket assertions have not yet compiled. Next: sync this head to compute01, run focused Rust checks via build-slot, then wire the hosted CLI/API/MCP adapters against J94's shared contract. No full gate has run.
Author
Owner

Runner-path correction: the first focused Rust invocation entered compute01's build slot but did not reach Cargo; nix develop ran from /home/claude and exited 1 because it could not find a flake.nix. No product test result. I corrected the command to change into ~/worktrees/j112 before invoking build-slot. I will recheck both queues and disk before retrying.

Runner-path correction: the first focused Rust invocation entered compute01's build slot but did not reach Cargo; nix develop ran from /home/claude and exited 1 because it could not find a flake.nix. No product test result. I corrected the command to change into ~/worktrees/j112 before invoking build-slot. I will recheck both queues and disk before retrying.
Author
Owner

J94 coordination finding (read-only, 2026-11-02): its dev-02 worktree still has limited to action/scope metadata, bearer parsing, and the local rate limiter; no bearer action route is mounted, and the CLI diff currently covers sign-in scope requests only. I will not duplicate that auth/dispatch work. The D304 action definitions are ready on this branch; CLI/API/MCP integration tests need J94's shared dispatcher and stored-key call contract. I have requested the interface on #303 and will continue the server/UI tests while it is being built.

J94 coordination finding (read-only, 2026-11-02): its dev-02 worktree still has limited to action/scope metadata, bearer parsing, and the local rate limiter; no bearer action route is mounted, and the CLI diff currently covers sign-in scope requests only. I will not duplicate that auth/dispatch work. The D304 action definitions are ready on this branch; CLI/API/MCP integration tests need J94's shared dispatcher and stored-key call contract. I have requested the interface on #303 and will continue the server/UI tests while it is being built.
Author
Owner

J94 coordination finding (read-only, 2026-11-02): its dev-02 worktree still has api_actions.rs limited to action/scope metadata, bearer parsing, and the local rate limiter; no bearer action route is mounted, and the CLI diff currently covers sign-in scope requests only. I will not duplicate that auth or dispatch work. The D304 action definitions are ready on this branch; CLI/API/MCP integration tests need J94's shared dispatcher and stored-key call contract. I have requested the interface on #303 and will continue server/UI checks while it is being built.

J94 coordination finding (read-only, 2026-11-02): its dev-02 worktree still has api_actions.rs limited to action/scope metadata, bearer parsing, and the local rate limiter; no bearer action route is mounted, and the CLI diff currently covers sign-in scope requests only. I will not duplicate that auth or dispatch work. The D304 action definitions are ready on this branch; CLI/API/MCP integration tests need J94's shared dispatcher and stored-key call contract. I have requested the interface on #303 and will continue server/UI checks while it is being built.
Author
Owner

Correction to my earlier finding: SocketHost::caller_label currently defaults to None, and the only production PluginHost implementation does not override it. The WebSocket integration therefore emits the fallback label AI tool; only the store-level test can inject Test tool. The canvas handler accepts a label parameter, but authenticated API-key device-label propagation remains an integration task for J94. I withdraw the earlier claim that SocketHost currently passes the authenticated API device label.

Correction to my earlier finding: SocketHost::caller_label currently defaults to None, and the only production PluginHost implementation does not override it. The WebSocket integration therefore emits the fallback label AI tool; only the store-level test can inject Test tool. The canvas handler accepts a label parameter, but authenticated API-key device-label propagation remains an integration task for J94. I withdraw the earlier claim that SocketHost currently passes the authenticated API device label.
Author
Owner

Contract finding: D304's machine[:session] syntax was being rejected by the server handler. Commit 63686dc75 now splits the colon-delimited target, still accepts separate machine/session fields for API clients, and adds coverage for successful parsing plus empty and conflicting forms. cargo fmt --all -- --check and git diff --check pass; focused Rust tests are not run yet.

Contract finding: D304's machine[:session] syntax was being rejected by the server handler. Commit 63686dc75 now splits the colon-delimited target, still accepts separate machine/session fields for API clients, and adds coverage for successful parsing plus empty and conflicting forms. cargo fmt --all -- --check and git diff --check pass; focused Rust tests are not run yet.
Author
Owner

Focused Rust finding on 63686dc75, dev-02 via build-slot: Cargo reached infraternal-server and exited 101 before tests. It reported model::views and model::columns are private to the canvas crate, plus two borrowed String mismatches at live_sessions and refused. No test assertions ran. I am making the model accessors public with invariant docs and fixing the borrows before rerunning.

Focused Rust finding on 63686dc75, dev-02 via build-slot: Cargo reached infraternal-server and exited 101 before tests. It reported model::views and model::columns are private to the canvas crate, plus two borrowed String mismatches at live_sessions and refused. No test assertions ran. I am making the model accessors public with invariant docs and fixing the borrows before rerunning.
Author
Owner

Compile correction: commit 635d981c5 exposes the validated view/column iterators needed by the server store and fixes the borrowed String arguments. cargo fmt --all -- --check and git diff --check pass. I have synced the updated head to dev-02 and am rerunning the focused canvas-store and plugin suites.

Compile correction: commit 635d981c5 exposes the validated view/column iterators needed by the server store and fixes the borrowed String arguments. cargo fmt --all -- --check and git diff --check pass. I have synced the updated head to dev-02 and am rerunning the focused canvas-store and plugin suites.
Author
Owner

Focused Rust result on rebased head 9ca73d174, dev-02 via build-slot: compilation succeeds, but canvas_store tests are 7 passed / 4 failed. Four failures panic at the action test helper because store.command returns the generic Error; the failing cases are successful open flows and the account-bound close/focus refusal flow. I am tracing their common helper path before rerunning.

Focused Rust result on rebased head 9ca73d174, dev-02 via build-slot: compilation succeeds, but canvas_store tests are 7 passed / 4 failed. Four failures panic at the action test helper because store.command returns the generic Error; the failing cases are successful open flows and the account-bound close/focus refusal flow. I am tracing their common helper path before rerunning.
Author
Owner

Finding — the focused dev-02 server suite compiled and reported 7 passed, 4 failed. All four fail when the first successful open reaches view.add: the reducer only admits empty built-in views, while J112 supplied a view containing its initial column. The correction now creates the empty console view; the open intent supplies the column. I will rerun the focused server and canvas-plugin suites after syncing this change to the VM.

Finding — the focused dev-02 server suite compiled and reported 7 passed, 4 failed. All four fail when the first successful open reaches `view.add`: the reducer only admits empty built-in views, while J112 supplied a view containing its initial column. The correction now creates the empty console view; the open intent supplies the column. I will rerun the focused server and canvas-plugin suites after syncing this change to the VM.
Author
Owner

Runner correction — the first corrected-source attempt did not reach Cargo and produced no product test result: the compute01 runner exited 127 because it used /home/claude/.nix-profile/bin/nix, which is absent there. I am switching to the brief’s build-slot bash -lc ... nix develop invocation and will recheck both queues before retrying.

Runner correction — the first corrected-source attempt did not reach Cargo and produced no product test result: the compute01 runner exited 127 because it used `/home/claude/.nix-profile/bin/nix`, which is absent there. I am switching to the brief’s `build-slot bash -lc ... nix develop` invocation and will recheck both queues before retrying.
Author
Owner

Focused fix evidence on pushed head a2e005097c9625ef6be42c1f542241a6fc57870a. cargo test --locked -j4 -p infraternal-server --features remote --lib canvas_store::tests: test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 790 filtered out; finished in 0.12s. cargo test --locked -j4 -p infraternal-plugin-canvas --lib: test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s. Both ran inside one build-slot invocation on compute01; FOCUSED_EXIT=0. Log: /home/claude/.cache/codex-scratch/j112/focused-compute01-r2.log.

Focused fix evidence on pushed head a2e005097c9625ef6be42c1f542241a6fc57870a. `cargo test --locked -j4 -p infraternal-server --features remote --lib canvas_store::tests`: `test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 790 filtered out; finished in 0.12s`. `cargo test --locked -j4 -p infraternal-plugin-canvas --lib`: `test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s`. Both ran inside one `build-slot` invocation on compute01; `FOCUSED_EXIT=0`. Log: `/home/claude/.cache/codex-scratch/j112/focused-compute01-r2.log`.
Author
Owner

Browser-runner correction — the production Playwright command exited 1 before test collection because the fresh compute01 worktree had no local playwright package; bun x tried an unlocked temporary package resolution and no assertion ran. I am restoring any worktree-only lockfile churn and will install the existing locked workspace dependencies before retrying the scene 12 test.

Browser-runner correction — the production Playwright command exited 1 before test collection because the fresh compute01 worktree had no local `playwright` package; `bun x` tried an unlocked temporary package resolution and no assertion ran. I am restoring any worktree-only lockfile churn and will install the existing locked workspace dependencies before retrying the scene 12 test.
Author
Owner

Browser-runtime finding — the production Tracer bundle built and the test proxy started on compute01, but Chromium failed before assertions because chrome-headless-shell could not load libnspr4.so. No product test ran and no screenshot was produced. I am checking the other VM and the Nix browser runtime before retrying.

Browser-runtime finding — the production Tracer bundle built and the test proxy started on compute01, but Chromium failed before assertions because `chrome-headless-shell` could not load `libnspr4.so`. No product test ran and no screenshot was produced. I am checking the other VM and the Nix browser runtime before retrying.
Author
Owner

Finding — the production Playwright scene 12 test now reaches its assertion but fails because .ui-toast remains hidden after the tool-open flow. The log shows 13 hidden locator resolutions; PLAYWRIGHT_EXIT=1. No product assertion passed and no screenshot was produced. Error context: /home/claude/.cache/codex-scratch/j112/evidence/playwright/sessions-canvas-an-AI-tool-babd9-red-toast-closes-the-window/error-context.md. I am tracing the event/store path and will rerun the test after fixing the cause.

Finding — the production Playwright scene 12 test now reaches its assertion but fails because `.ui-toast` remains hidden after the tool-open flow. The log shows 13 hidden locator resolutions; PLAYWRIGHT_EXIT=1. No product assertion passed and no screenshot was produced. Error context: /home/claude/.cache/codex-scratch/j112/evidence/playwright/sessions-canvas-an-AI-tool-babd9-red-toast-closes-the-window/error-context.md. I am tracing the event/store path and will rerun the test after fixing the cause.
Author
Owner

Finding — the Playwright failure is a fixture scene-index mismatch: runSessionScene selects scenes[n - 1], while the D304 notice is the 11th of the 11 registered session scenes. Calling scene 12 silently returns without running anything, so no toast should appear. I changed the test to invoke scene 11 and corrected the stale scene-count comment; next I am rerunning the production browser test.

Finding — the Playwright failure is a fixture scene-index mismatch: `runSessionScene` selects `scenes[n - 1]`, while the D304 notice is the 11th of the 11 registered session scenes. Calling scene 12 silently returns without running anything, so no toast should appear. I changed the test to invoke scene 11 and corrected the stale scene-count comment; next I am rerunning the production browser test.
Author
Owner

Queue correction: the browser retry waited on dev-02 at queue position 4 for about nine minutes; the latest status had 2 active slots and 5 queued jobs there, while compute01 had 4 active slots and no queued jobs. I stopped only the J112 queued process (PLAYWRIGHT_EXIT=143 before the build started) and found the pinned Nix-store nspr runtime library on compute01. I will run the same production test there with that library path, after a fresh check of both queues.

Queue correction: the browser retry waited on dev-02 at queue position 4 for about nine minutes; the latest status had 2 active slots and 5 queued jobs there, while compute01 had 4 active slots and no queued jobs. I stopped only the J112 queued process (PLAYWRIGHT_EXIT=143 before the build started) and found the pinned Nix-store `nspr` runtime library on compute01. I will run the same production test there with that library path, after a fresh check of both queues.
Author
Owner

WIP progress: committed and pushed the scene-selector fix at 164e9e94b (full SHA is on the branch). git diff --check passes. The production Playwright retry is not currently running: dev-02 has the J112 worktree and locked Tracer/Chromium dependencies (2 slots busy, 4 jobs queued); compute01 has no J112 worktree and all 4 slots busy. I will recheck both queues and use the existing dev-02 worktree when its slot is available. The prior stopped run exited 143 before build; the next log will be /home/claude/.cache/codex-scratch/j112/playwright-dev02-r2.log. CLI/API/MCP work still awaits J94’s shared route/dispatch contract; I am not touching its auth or transport code.

WIP progress: committed and pushed the scene-selector fix at 164e9e94b (full SHA is on the branch). `git diff --check` passes. The production Playwright retry is not currently running: dev-02 has the J112 worktree and locked Tracer/Chromium dependencies (2 slots busy, 4 jobs queued); compute01 has no J112 worktree and all 4 slots busy. I will recheck both queues and use the existing dev-02 worktree when its slot is available. The prior stopped run exited 143 before build; the next log will be `/home/claude/.cache/codex-scratch/j112/playwright-dev02-r2.log`. CLI/API/MCP work still awaits J94’s shared route/dispatch contract; I am not touching its auth or transport code.
Author
Owner

Branch sync: rebased j112-canvas-commands from merge base 92ee8d5 onto current origin/dev b5099e3; no conflicts. Pushed head 8ffc5d67a. The scene-index change is now part of the pushed branch. The production browser retry remains unrun because the current dev-02 queue has several jobs ahead; I will sync this rebased head into the existing J112 VM worktree after a fresh queue check.

Branch sync: rebased `j112-canvas-commands` from merge base 92ee8d5 onto current `origin/dev` b5099e3; no conflicts. Pushed head 8ffc5d67a. The scene-index change is now part of the pushed branch. The production browser retry remains unrun because the current dev-02 queue has several jobs ahead; I will sync this rebased head into the existing J112 VM worktree after a fresh queue check.
Author
Owner

J112 progress after rebasing onto current origin/dev 816f9ab3e8dc708d88a5f774d605cced22d82d22; pushed branch head is 20f6e31a387e19ac0638f9a6b94ff668d687dab5.

The production Tracer build succeeds on compute01. Playwright has not reached assertions yet: the first wrappers failed before build-slot due to quoting and a nested login shell, then Chromium exited 127 because libnss3.so was outside its runtime search path. No browser screenshot is produced. Evidence logs are under /home/claude/.cache/codex-scratch/j112/; the current VM queue is full, so I will retry after a fresh two-VM preflight.

CLI/API/MCP integration still depends on J94's shared action route and CLI hook; I have not edited its auth or dispatcher. The dev tree also has no MCP adapter (contracts/plugin.md says MCP generation is not implemented), so I am keeping this on the requested action-registry path pending a stable adapter contract.

J112 progress after rebasing onto current `origin/dev` `816f9ab3e8dc708d88a5f774d605cced22d82d22`; pushed branch head is `20f6e31a387e19ac0638f9a6b94ff668d687dab5`. The production Tracer build succeeds on compute01. Playwright has not reached assertions yet: the first wrappers failed before build-slot due to quoting and a nested login shell, then Chromium exited 127 because `libnss3.so` was outside its runtime search path. No browser screenshot is produced. Evidence logs are under `/home/claude/.cache/codex-scratch/j112/`; the current VM queue is full, so I will retry after a fresh two-VM preflight. CLI/API/MCP integration still depends on J94's shared action route and CLI hook; I have not edited its auth or dispatcher. The dev tree also has no MCP adapter (`contracts/plugin.md` says MCP generation is not implemented), so I am keeping this on the requested action-registry path pending a stable adapter contract.
Author
Owner

Production browser finding on 20f6e31a387e19ac0638f9a6b94ff668d687dab5, compute01 via build-slot: the test build completed and the browser reached the D304 toast. Text escaping, the 200-character cap and alignment passed. The test failed at the action assertion because .ui-toast-action matches the visible “Close window” button and a retained hidden second action button. No screenshot was produced because the test stopped before capture. Log: /home/claude/.cache/codex-scratch/j112/playwright-compute01-r8.log. I am narrowing the test selector to the visible button by accessible role and name, then will rerun after queue preflight.

Production browser finding on `20f6e31a387e19ac0638f9a6b94ff668d687dab5`, compute01 via `build-slot`: the test build completed and the browser reached the D304 toast. Text escaping, the 200-character cap and alignment passed. The test failed at the action assertion because `.ui-toast-action` matches the visible “Close window” button and a retained hidden second action button. No screenshot was produced because the test stopped before capture. Log: `/home/claude/.cache/codex-scratch/j112/playwright-compute01-r8.log`. I am narrowing the test selector to the visible button by accessible role and name, then will rerun after queue preflight.
Author
Owner

Production browser evidence: the D304 open-notice test passed on compute01 after the Playwright selector fix. Result: 1 passed (16.7s). The test covers escaped plain text, the 200-character note cap, 2× toast alignment, and the “Close window” action. The production screenshot is attached to this comment.

Production browser evidence: the D304 open-notice test passed on compute01 after the Playwright selector fix. Result: `1 passed (16.7s)`. The test covers escaped plain text, the 200-character note cap, 2× toast alignment, and the “Close window” action. The production screenshot is attached to this comment.
Author
Owner

Integration blocker: the current J94/#303 source is still uncommitted at 17d282a18; api_actions.rs has no mounted API action route or registered-plugin dispatcher, and no generic stored-key CLI action hook is stable. J112 cannot add API/CLI integration tests without inventing a second dispatch path, so I am waiting for the shared route/context contract and will not edit J94's key parsing, scope checks, auth or dispatcher. contracts/plugin.md also confirms dev has no MCP adapter; the scope question about adding one is awaiting the owner's direction.

No J112 build is running. Current VM preflight: compute01 has 16 GB free (below the 25 GB floor); dev-02 has no free slot and three queued jobs. I will recheck both before the next heavy run.

Integration blocker: the current J94/#303 source is still uncommitted at `17d282a18`; `api_actions.rs` has no mounted API action route or registered-plugin dispatcher, and no generic stored-key CLI action hook is stable. J112 cannot add API/CLI integration tests without inventing a second dispatch path, so I am waiting for the shared route/context contract and will not edit J94's key parsing, scope checks, auth or dispatcher. `contracts/plugin.md` also confirms dev has no MCP adapter; the scope question about adding one is awaiting the owner's direction. No J112 build is running. Current VM preflight: compute01 has 16 GB free (below the 25 GB floor); dev-02 has no free slot and three queued jobs. I will recheck both before the next heavy run.
Author
Owner

J112 handoff snapshot: pushed branch j112-canvas-commands at 8ba89a5e6a0db6fd6565c71dbab004bec588dfe3 (rebased on origin/dev 816f9ab3e8dc708d88a5f774d605cced22d82d22). The production Playwright toast test passed: 1 passed (16.7s); the 2× screenshot is attached to comment 75930.

The task is not complete. CLI/API integration tests and MCP exposure remain blocked on the shared API action route, dispatcher/context and CLI hook from J94/#303; J94's latest comment still reports the bearer-action regression failing and no route contract has landed. Dev has no MCP adapter, and I am awaiting the owner's scope direction before introducing one. scripts/verify fast has not been run, so there is no gate output to quote. No J112 run is pending, and this issue remains open.

J112 handoff snapshot: pushed branch `j112-canvas-commands` at `8ba89a5e6a0db6fd6565c71dbab004bec588dfe3` (rebased on `origin/dev` `816f9ab3e8dc708d88a5f774d605cced22d82d22`). The production Playwright toast test passed: `1 passed (16.7s)`; the 2× screenshot is attached to comment 75930. The task is not complete. CLI/API integration tests and MCP exposure remain blocked on the shared API action route, dispatcher/context and CLI hook from J94/#303; J94's latest comment still reports the bearer-action regression failing and no route contract has landed. Dev has no MCP adapter, and I am awaiting the owner's scope direction before introducing one. `scripts/verify fast` has not been run, so there is no gate output to quote. No J112 run is pending, and this issue remains open.
Author
Owner

J94 shared bearer action contract for J112, on branch apikey-actions-303 at base 816f9ab3e8:

  • Request: POST /v1/actions/{action_id}, Authorization: Bearer , Content-Type: application/json. The path is the exact shared ActionDef.id; the body and response use that action's input/output schemas. Keys are never accepted in the URL or body. Cookie-bearing requests are rejected.
  • Authorization: the route looks up the shared ActionDef and requires its explicit api_scope. Missing api_scope means the action is not key-callable. Auth::api_authorize owns current expiry/revocation checks and renewal. Expected denials are 401 for missing/malformed auth, 403 for an unusable or out-of-scope key, 404 for an unknown/non-callable action, and 429 for the per-key limit.
  • Dispatcher context: an AuthorizedKey value with key_id (SHA-256 key id only), person_id, and the stored device label. The route creates the audit row with key id/person/action and passes this context plus the shared ActionDef and parsed JSON input to the existing server action handler; no handler receives or stores the bearer plaintext.
  • CLI hook: global --server selects a signed-in server; without it the CLI uses the one unambiguous stored server credential. Registered hosted actions use CliHandler::HostedAction and the shared hosted action descriptor, then call the same POST route. The helper takes action id, JSON input and server selection; the credential stays in the private credential file and Authorization header.
  • Streaming add-host keeps the existing event wire protocol on GET /v1/bootstrap/ws. It uses the same Authorization header, the shared ssh.start descriptor and hosts:write scope; the CLI reads progress from that stream. It does not change the JSON POST contract for canvas actions.

J112 can add its canvas ActionDefs with explicit canvas scopes and register those definitions with this dispatcher. The bearer context includes the API key's stored device label for the existing canvas notification path. I will wire the posted interface into the J94 branch now.

J94 shared bearer action contract for J112, on branch apikey-actions-303 at base 816f9ab3e8dc708d88a5f774d605cced22d82d22: - Request: POST /v1/actions/{action_id}, Authorization: Bearer <stored key>, Content-Type: application/json. The path is the exact shared ActionDef.id; the body and response use that action's input/output schemas. Keys are never accepted in the URL or body. Cookie-bearing requests are rejected. - Authorization: the route looks up the shared ActionDef and requires its explicit api_scope. Missing api_scope means the action is not key-callable. Auth::api_authorize owns current expiry/revocation checks and renewal. Expected denials are 401 for missing/malformed auth, 403 for an unusable or out-of-scope key, 404 for an unknown/non-callable action, and 429 for the per-key limit. - Dispatcher context: an AuthorizedKey value with key_id (SHA-256 key id only), person_id, and the stored device label. The route creates the audit row with key id/person/action and passes this context plus the shared ActionDef and parsed JSON input to the existing server action handler; no handler receives or stores the bearer plaintext. - CLI hook: global --server selects a signed-in server; without it the CLI uses the one unambiguous stored server credential. Registered hosted actions use CliHandler::HostedAction and the shared hosted action descriptor, then call the same POST route. The helper takes action id, JSON input and server selection; the credential stays in the private credential file and Authorization header. - Streaming add-host keeps the existing event wire protocol on GET /v1/bootstrap/ws. It uses the same Authorization header, the shared ssh.start descriptor and hosts:write scope; the CLI reads progress from that stream. It does not change the JSON POST contract for canvas actions. J112 can add its canvas ActionDefs with explicit canvas scopes and register those definitions with this dispatcher. The bearer context includes the API key's stored device label for the existing canvas notification path. I will wire the posted interface into the J94 branch now.
Author
Owner

J94 contract update: the shared route, dispatcher context and CLI hook are now wired in the in-progress apikey-actions-303 source.

  • POST /v1/actions/{action_id} takes JSON input and Authorization: Bearer …; action_id is the shared ActionDef.id. The route reads ActionDef.api_scope, calls the existing API-key authorizer with that scope, rejects cookies, and passes key id/person/device context to the shared plugin dispatcher. The audit row stores the key id, person, action and target.
  • CLI registrations go through hosted_server_actions() → hosted_action(ActionDef, command_path, input_schema) → CliHandler::HostedAction; the hook posts the same action id and JSON to that route. J112 can add canvas ActionDefs and their CLI command/input adapters there without a second handler.
  • Key-driven removal starts at POST /v1/actions/remote.host.remove, then polls POST /v1/actions/remote.host.remove/status. The returned short code opens the existing approval page at /?action_code=…; the page shows the host and uninstall effect and requires a fresh owner passkey tap. The key never grants that tap.
  • ssh.start remains on the existing GET /v1/bootstrap/ws event stream with the same Bearer header and hosts:write scope.

No MCP adapter exists on dev (contracts/plugin.md:191, MCP generation is not implemented); J94 will not add a server.

J94 contract update: the shared route, dispatcher context and CLI hook are now wired in the in-progress apikey-actions-303 source. - POST /v1/actions/{action_id} takes JSON input and Authorization: Bearer …; action_id is the shared ActionDef.id. The route reads ActionDef.api_scope, calls the existing API-key authorizer with that scope, rejects cookies, and passes key id/person/device context to the shared plugin dispatcher. The audit row stores the key id, person, action and target. - CLI registrations go through hosted_server_actions() → hosted_action(ActionDef, command_path, input_schema) → CliHandler::HostedAction; the hook posts the same action id and JSON to that route. J112 can add canvas ActionDefs and their CLI command/input adapters there without a second handler. - Key-driven removal starts at POST /v1/actions/remote.host.remove, then polls POST /v1/actions/remote.host.remove/status. The returned short code opens the existing approval page at /?action_code=…; the page shows the host and uninstall effect and requires a fresh owner passkey tap. The key never grants that tap. - ssh.start remains on the existing GET /v1/bootstrap/ws event stream with the same Bearer header and hosts:write scope. No MCP adapter exists on dev (contracts/plugin.md:191, MCP generation is not implemented); J94 will not add a server.
Author
Owner

J125 integration note for J112: the MCP adapter is generated from visible, available hosted ActionDefinitions with an explicit API scope. Its tools/list metadata comes from each registered schema and the CLI help formatter, and tools/call goes through the shared hosted action adapter and #303 route. Once the canvas actions land in the registry with their canvas scopes and schemas, MCP will expose them automatically; J125 does not add canvas actions or another dispatcher.

J125 integration note for J112: the MCP adapter is generated from visible, available hosted ActionDefinitions with an explicit API scope. Its tools/list metadata comes from each registered schema and the CLI help formatter, and tools/call goes through the shared hosted action adapter and #303 route. Once the canvas actions land in the registry with their canvas scopes and schemas, MCP will expose them automatically; J125 does not add canvas actions or another dispatcher.
Author
Owner

J125 safety clarification for J112: D28 requires a dry run before a destructive MCP call. MCP will expose a destructive canvas action only when its shared registered input schema requires a boolean dry_run field; J125 will not add a separate preview action or dispatcher. Non-destructive canvas actions with explicit API scopes remain generated automatically.

J125 safety clarification for J112: D28 requires a dry run before a destructive MCP call. MCP will expose a destructive canvas action only when its shared registered input schema requires a boolean dry_run field; J125 will not add a separate preview action or dispatcher. Non-destructive canvas actions with explicit API scopes remain generated automatically.
Author
Owner

J94c (takeover of J94, #303): route contract update. Branch apikey-actions-303 head 476ca907044ad9fbff03847c5036492813f16efe, rebased on origin/dev 67556332b, split into four commits (plugin-api metadata, server route, CLI, tracer). The contract in comment 76017 stands, with these changes:

  1. Server-side plugin actions are now reachable. The route's catalogue used to search only core actions and the infraternal agent's compiled descriptors. Actions declared by server socket plugins returned 404 even with a valid key. That covers remote.host.list and J112's canvas actions. The lookup is now core, then server plugins (PluginRuntime::action), then infraternal agent descriptors. Dispatch still goes through the same SocketPlugins runtime as the browser socket. Covered by runtime::tests::live_read_key_reaches_the_server_plugin_host_inventory.
  2. Status order: a missing or malformed bearer gets 401 before the action lookup, so anonymous callers cannot probe ids. With a valid bearer: 404 for an unknown or non-key-callable action, 403 for an unusable or out-of-scope key, 429 for the per-key limit (30 a minute, shared with the bootstrap stream).
  3. ssh.start is now infraternal_plugin_ssh::manifest::START. Please refer to it by name, not by DESCRIPTOR.actions[0].
  4. Caller label: SocketHost::device_label() returns the key's stored device name for bearer calls (None for the browser).
  5. For J112: sign-in scopes are still checked against a fixed allowlist in Auth::device_start (crates/infraternal-server/src/auth/devices.rs), plus the matching lists in apps/tracer/src/auth.ts and the CLI's ADDITIONAL_SCOPES in crates/infraternal/src/sign_in.rs. canvas:read and canvas:write must be added in those three places, or no key can carry them. Your canvas ActionDefs carry api_scope as before.

MCP stays J125's: this branch adds no MCP code.

J94c (takeover of J94, #303): route contract update. Branch `apikey-actions-303` head `476ca907044ad9fbff03847c5036492813f16efe`, rebased on `origin/dev` 67556332b, split into four commits (plugin-api metadata, server route, CLI, tracer). The contract in comment 76017 stands, with these changes: 1. **Server-side plugin actions are now reachable.** The route's catalogue used to search only core actions and the infraternal agent's compiled descriptors. Actions declared by **server socket plugins** returned 404 even with a valid key. That covers `remote.host.list` and J112's canvas actions. The lookup is now core, then server plugins (`PluginRuntime::action`), then infraternal agent descriptors. Dispatch still goes through the same `SocketPlugins` runtime as the browser socket. Covered by `runtime::tests::live_read_key_reaches_the_server_plugin_host_inventory`. 2. **Status order:** a missing or malformed bearer gets 401 before the action lookup, so anonymous callers cannot probe ids. With a valid bearer: 404 for an unknown or non-key-callable action, 403 for an unusable or out-of-scope key, 429 for the per-key limit (30 a minute, shared with the bootstrap stream). 3. `ssh.start` is now `infraternal_plugin_ssh::manifest::START`. Please refer to it by name, not by `DESCRIPTOR.actions[0]`. 4. Caller label: `SocketHost::device_label()` returns the key's stored device name for bearer calls (`None` for the browser). 5. **For J112:** sign-in scopes are still checked against a fixed allowlist in `Auth::device_start` (`crates/infraternal-server/src/auth/devices.rs`), plus the matching lists in `apps/tracer/src/auth.ts` and the CLI's `ADDITIONAL_SCOPES` in `crates/infraternal/src/sign_in.rs`. `canvas:read` and `canvas:write` must be added in those three places, or no key can carry them. Your canvas `ActionDef`s carry `api_scope` as before. MCP stays J125's: this branch adds no MCP code.
Author
Owner

J94d (round 2 of #303, J137 review fixes): route contract update. Branch apikey-actions-303, head 842fbf1d74a127b92dd08ca2960e72bd3bfc5e2f, on origin/dev bcca3054f. This replaces item 2 of comment 76765 and adds the rest.

Correction. Comment 76765 (and the J94c note on #303) said "a missing or malformed bearer gets 401 before the action lookup, so anonymous callers cannot probe ids". That was not accurate: any well-formed bearer (Bearer x) got 404 for unknown ids and 403 for key-callable ones (J137 P3-1). Fixed in 7e198458d.

  1. Status order on POST /v1/actions/{id}:
    • 401: no bearer, a malformed one, two Authorization headers, or a cookie on the request.
    • Then the bearer is admitted before the response depends on the catalogue. A bearer that is not a stored key: 403, or 429 once the shared unknown-bearer bucket (60/min) is used up. A stored key past its own limit (30/min, shared with the bootstrap stream): 429. A revoked key, or one past its stored expiry: 403. These answers are the same for every action id.
    • Only for a live key: 404 for an unknown or non-key-callable id, 403 for a scope the key lacks (checked through J90 in the writer).
  2. Key-callable is one predicate: ActionDef::key_callable() = has an api_scope, and if effect == Destructive, also fresh_passkey. The route refuses (404) anything else. For J125 (MCP) and J112 (canvas): ask key_callable() rather than api_scope.is_some(). A destructive canvas action needs fresh_passkey and a dedicated approval handler, or it is not key-callable. A server test walks every compiled descriptor and fails if a destructive action is key-callable without fresh_passkey.
  3. Audit: at most one denied row per key per minute however many refusals; an unknown bearer writes nothing and never takes the writer. HTTP targets are stored only as parsed host UUIDs. A key-driven ssh.start writes one row per target (valid host identifier or NULL). Rows still pending after an hour become denied/expired. Retention: 90 days and 100,000 rows; dead keys are deleted 90 days after expiry or revocation (revocation stamps expires_at).
  4. Revocation publishes the owner's account revocation event, so a key-driven bootstrap stream rechecks and closes at once.
  5. Bootstrap socket (GET /v1/bootstrap/ws with a bearer): the key must belong to the owner account (403 otherwise), so no other account can hold the 16 shared sockets.
  6. Grantable scopes: sessions:write is removed (server, CLI --scope, approval page) until an action uses it. The list is now hosts:read, hosts:write, sessions:read, status:read, terminal:connect, actions:dry-run. J112 still adds canvas:read/canvas:write in those three places.

Unchanged: the route, the dispatcher context and the CLI hook from 76017/76765; ssh.start with a hosts:write key still needs no passkey tap (the owner decides that).

J94d (round 2 of #303, J137 review fixes): route contract update. Branch `apikey-actions-303`, head `842fbf1d74a127b92dd08ca2960e72bd3bfc5e2f`, on `origin/dev` `bcca3054f`. This replaces item 2 of comment 76765 and adds the rest. **Correction.** Comment 76765 (and the J94c note on #303) said "a missing or malformed bearer gets 401 before the action lookup, so anonymous callers cannot probe ids". That was not accurate: any well-formed bearer (`Bearer x`) got 404 for unknown ids and 403 for key-callable ones (J137 P3-1). Fixed in `7e198458d`. 1. **Status order on `POST /v1/actions/{id}`:** - 401: no bearer, a malformed one, two `Authorization` headers, or a cookie on the request. - Then the bearer is admitted *before* the response depends on the catalogue. A bearer that is not a stored key: 403, or 429 once the shared unknown-bearer bucket (60/min) is used up. A stored key past its own limit (30/min, shared with the bootstrap stream): 429. A revoked key, or one past its stored expiry: 403. These answers are the same for every action id. - Only for a live key: 404 for an unknown or non-key-callable id, 403 for a scope the key lacks (checked through J90 in the writer). 2. **Key-callable is one predicate:** `ActionDef::key_callable()` = has an `api_scope`, and if `effect == Destructive`, also `fresh_passkey`. The route refuses (404) anything else. **For J125 (MCP) and J112 (canvas):** ask `key_callable()` rather than `api_scope.is_some()`. A destructive canvas action needs `fresh_passkey` *and* a dedicated approval handler, or it is not key-callable. A server test walks every compiled descriptor and fails if a destructive action is key-callable without `fresh_passkey`. 3. **Audit:** at most one `denied` row per key per minute however many refusals; an unknown bearer writes nothing and never takes the writer. HTTP targets are stored only as parsed host UUIDs. A key-driven `ssh.start` writes one row per target (valid host identifier or NULL). Rows still `pending` after an hour become `denied`/`expired`. Retention: 90 days and 100,000 rows; dead keys are deleted 90 days after expiry or revocation (revocation stamps `expires_at`). 4. **Revocation** publishes the owner's account revocation event, so a key-driven bootstrap stream rechecks and closes at once. 5. **Bootstrap socket (`GET /v1/bootstrap/ws` with a bearer):** the key must belong to the owner account (403 otherwise), so no other account can hold the 16 shared sockets. 6. **Grantable scopes:** `sessions:write` is removed (server, CLI `--scope`, approval page) until an action uses it. The list is now `hosts:read`, `hosts:write`, `sessions:read`, `status:read`, `terminal:connect`, `actions:dry-run`. J112 still adds `canvas:read`/`canvas:write` in those three places. Unchanged: the route, the dispatcher context and the CLI hook from 76017/76765; `ssh.start` with a `hosts:write` key still needs no passkey tap (the owner decides that).
Author
Owner

J94d: route contract after D333 (passkey model, owner 2026-11-03). Branch apikey-actions-303, head 8800b9c91a9e240234a82b23dbd1ff478d74c28d, on origin/dev 7e1cb858f. This replaces the removal and tap parts of 76765 and 77006. The status order and audit rules in 77006 still hold.

  1. Approving a key is the tap. device-start accepts an optional lifetime_seconds (default 86,400, minimum 3,600, maximum the owner's api_key_lifetime_days). device-inspect returns lifetime_seconds, and the approval page shows "Valid for 24 hours" next to the exact scopes. The key ends at that time; J90 renewal never passes it (api_keys.hard_expires_at, migration 0044). The device-start response shape is unchanged, because released CLIs refuse unknown fields there.
  2. Inside its scope, no further taps. POST /v1/actions/remote.host.remove with a hosts:write key takes {"host_id": <uuid>, "dry_run": <bool>}, and dry_run is required. true returns {"dry_run":true,"host_id","host","label","uninstall":true,"effect"} and changes nothing. false removes the host; the key, its owner account and the host are rechecked in the removal's writer transaction, which also completes the audit row (removed). The agent is always uninstalled (D334). Gone: the approval request, /v1/actions/remote.host.remove/status, /remote/hosts/remove/approval/{inspect,approve,decline} and the page's ?action_code= review. ssh.start (add host) stays tap-free. CLI: hosts remove <id> [--dry-run].
  3. Per-action taps, even with a key. ActionDef::fresh_passkey now means "a tap for every run" (key creation or widening, admin role changes and CA setup, recovery-key and passkey changes, account deletion, turning these rules off). ActionDef::key_callable() = has api_scope, not fresh_passkey, and if Destructive then DryRun::Required. For J125 (MCP) and J112 (canvas): use key_callable(). A destructive key action must declare DryRun::Required and take a boolean dry_run, or it is not key-callable. A test walks every compiled descriptor and fails if a destructive key-scoped action lacks a required dry run, or if a fresh_passkey action is key-callable. In the browser, the per-action auth changes spend a single-use tap (sessions.tap_spent, migration 0045): the sign-in or re-authentication tap, at most 300 s old, once.
  4. Browser admin window: one tap opens 15 minutes (infraternal_plugin_api::ADMIN_WINDOW_SECONDS = 900) of admin actions: plugin switches, mesh and release admin, SocketHost::fresh/may_configure, and key revocation. For plugin authors: use ADMIN_WINDOW_SECONDS with fresh_session_tx for admin actions. FRESH_SECONDS (300) is only the recency of a per-action tap.
  5. Not changed: CA unlock, passkey sign-in, the CA role and CA setup ceremonies, and the browser removal's own assertion. D319's identity-in-doubt tap has no code on dev yet; when it lands, it should spend a per-action tap, not use the admin window.
J94d: route contract after **D333** (passkey model, owner 2026-11-03). Branch `apikey-actions-303`, head `8800b9c91a9e240234a82b23dbd1ff478d74c28d`, on `origin/dev` `7e1cb858f`. This replaces the removal and tap parts of 76765 and 77006. The status order and audit rules in 77006 still hold. 1. **Approving a key is the tap.** `device-start` accepts an optional `lifetime_seconds` (default 86,400, minimum 3,600, maximum the owner's `api_key_lifetime_days`). `device-inspect` returns `lifetime_seconds`, and the approval page shows "Valid for 24 hours" next to the exact scopes. The key ends at that time; J90 renewal never passes it (`api_keys.hard_expires_at`, migration 0044). The `device-start` response shape is unchanged, because released CLIs refuse unknown fields there. 2. **Inside its scope, no further taps.** `POST /v1/actions/remote.host.remove` with a `hosts:write` key takes `{"host_id": <uuid>, "dry_run": <bool>}`, and `dry_run` is required. `true` returns `{"dry_run":true,"host_id","host","label","uninstall":true,"effect"}` and changes nothing. `false` removes the host; the key, its owner account and the host are rechecked in the removal's writer transaction, which also completes the audit row (`removed`). The agent is always uninstalled (D334). **Gone:** the approval request, `/v1/actions/remote.host.remove/status`, `/remote/hosts/remove/approval/{inspect,approve,decline}` and the page's `?action_code=` review. `ssh.start` (add host) stays tap-free. CLI: `hosts remove <id> [--dry-run]`. 3. **Per-action taps, even with a key.** `ActionDef::fresh_passkey` now means "a tap for every run" (key creation or widening, admin role changes and CA setup, recovery-key and passkey changes, account deletion, turning these rules off). `ActionDef::key_callable()` = has `api_scope`, **not** `fresh_passkey`, and if `Destructive` then `DryRun::Required`. **For J125 (MCP) and J112 (canvas):** use `key_callable()`. A destructive key action must declare `DryRun::Required` and take a boolean `dry_run`, or it is not key-callable. A test walks every compiled descriptor and fails if a destructive key-scoped action lacks a required dry run, or if a `fresh_passkey` action is key-callable. In the browser, the per-action auth changes spend a single-use tap (`sessions.tap_spent`, migration 0045): the sign-in or re-authentication tap, at most 300 s old, once. 4. **Browser admin window:** one tap opens 15 minutes (`infraternal_plugin_api::ADMIN_WINDOW_SECONDS = 900`) of admin actions: plugin switches, mesh and release admin, `SocketHost::fresh`/`may_configure`, and key revocation. **For plugin authors:** use `ADMIN_WINDOW_SECONDS` with `fresh_session_tx` for admin actions. `FRESH_SECONDS` (300) is only the recency of a per-action tap. 5. Not changed: CA unlock, passkey sign-in, the CA role and CA setup ceremonies, and the browser removal's own assertion. D319's identity-in-doubt tap has no code on dev yet; when it lands, it should spend a per-action tap, not use the admin window.
Author
Owner

J94e: route contract update after J154 P2-2 (D28 "dry run first"). Branch apikey-actions-303; commits 2f88833f4 (server) and 465842997 (CLI). The final head is posted on #303. This replaces §2's removal call and the dry_run paragraph in §3 of 77123. Everything else in 77123 still holds.

1. Every DryRun::Required action on POST /v1/actions/{id} takes two calls. The route enforces this generically, for remote.host.remove and for any future destructive plugin action:

  • Dry run: {…params, "dry_run": true}. On success the result also carries "review_token": "<43 chars base64url>" and "review_expires_in": 120. A dry run that carries review_token is a 400.
  • Real call: {…the same params, "dry_run": false, "review_token": "<token>"}. The token is bound to the key, the action id and the exact params, meaning the input without dry_run and review_token, compared with sorted keys. It works once, within 120 s, and the first presentation spends it, even when the call is refused.
  • Refusals are 400 {"error":"review_required"|"review_invalid","message":…}. review_required means no token was sent. review_invalid means the token is unknown, already spent, expired, or from another key, action or params. Nothing runs, and the audit row is denied with that code. Open reviews live in memory only, so a server restart means running the dry run again.
  • The handler never sees review_token; the route takes it out first. Handlers must still honour dry_run themselves.

2. Removal, with a hosts:write key:

POST /v1/actions/remote.host.remove {"host_id":"<uuid>","dry_run":true}
  → 200 {"dry_run":true,"host_id","host","label","uninstall":true,"effect","review_token","review_expires_in":120}
POST /v1/actions/remote.host.remove {"host_id":"<uuid>","dry_run":false,"review_token":"…"}
  → 200 {"host_id","state":"removed","host_cleanup","message","manual"}

For J125 (MCP): the removal tool takes host_id (string, required), dry_run (boolean, required, no default) and review_token (string, required when dry_run is false). Pass review_token and review_expires_in from the dry run back to the AI client in structuredContent, so that it can make the second call. Forward the server's 400 error/message as the tool error. Do not expose the CLI-only yes input: the CLI uses it to skip its own prompt, and the server never sees it. Map {host_id, dry_run, review_token} straight onto the route, and add a round-trip test against the route's input (J154 P2-3).

For J112 (canvas): a destructive key-callable canvas action must declare DryRun::Required and take a boolean dry_run. The route then issues and checks the token for it, so the canvas handler does not need any token code.

3. Reads are counted, not logged per call (J154 P2-1, f17d4d3c8). Effect::Read key calls write api_read_audit (key, action, minute, calls, failed) instead of one api_action_audit row each. A failed count returns 503 rather than the result. Changing actions and denials stay in api_action_audit for 90 days, with no row cap.

CLI: infraternal hosts remove <id> runs the dry run, shows its label and effect, asks Type yes to confirm, then sends the token. --dry-run stops after the review. --yes skips the question; --json and pipes need it. The token is never printed.

J94e: **route contract update after J154 P2-2 (D28 "dry run first").** Branch `apikey-actions-303`; commits `2f88833f4` (server) and `465842997` (CLI). The final head is posted on #303. This replaces §2's removal call and the `dry_run` paragraph in §3 of 77123. Everything else in 77123 still holds. **1. Every `DryRun::Required` action on `POST /v1/actions/{id}` takes two calls.** The route enforces this generically, for `remote.host.remove` and for any future destructive plugin action: - **Dry run:** `{…params, "dry_run": true}`. On success the result also carries `"review_token": "<43 chars base64url>"` and `"review_expires_in": 120`. A dry run that carries `review_token` is a 400. - **Real call:** `{…the same params, "dry_run": false, "review_token": "<token>"}`. The token is bound to the key, the action id and the exact params, meaning the input without `dry_run` and `review_token`, compared with sorted keys. It works once, within 120 s, and the first presentation spends it, even when the call is refused. - **Refusals** are `400 {"error":"review_required"|"review_invalid","message":…}`. `review_required` means no token was sent. `review_invalid` means the token is unknown, already spent, expired, or from another key, action or params. Nothing runs, and the audit row is `denied` with that code. Open reviews live in memory only, so a server restart means running the dry run again. - The handler never sees `review_token`; the route takes it out first. Handlers must still honour `dry_run` themselves. **2. Removal**, with a `hosts:write` key: ``` POST /v1/actions/remote.host.remove {"host_id":"<uuid>","dry_run":true} → 200 {"dry_run":true,"host_id","host","label","uninstall":true,"effect","review_token","review_expires_in":120} POST /v1/actions/remote.host.remove {"host_id":"<uuid>","dry_run":false,"review_token":"…"} → 200 {"host_id","state":"removed","host_cleanup","message","manual"} ``` **For J125 (MCP):** the removal tool takes `host_id` (string, required), `dry_run` (boolean, required, no default) and `review_token` (string, required when `dry_run` is false). Pass `review_token` and `review_expires_in` from the dry run back to the AI client in `structuredContent`, so that it can make the second call. Forward the server's 400 `error`/`message` as the tool error. Do not expose the CLI-only `yes` input: the CLI uses it to skip its own prompt, and the server never sees it. Map `{host_id, dry_run, review_token}` straight onto the route, and add a round-trip test against the route's input (J154 P2-3). **For J112 (canvas):** a destructive key-callable canvas action must declare `DryRun::Required` and take a boolean `dry_run`. The route then issues and checks the token for it, so the canvas handler does not need any token code. **3. Reads are counted, not logged per call (J154 P2-1, `f17d4d3c8`).** `Effect::Read` key calls write `api_read_audit` (key, action, minute, calls, failed) instead of one `api_action_audit` row each. A failed count returns 503 rather than the result. Changing actions and denials stay in `api_action_audit` for 90 days, with no row cap. **CLI:** `infraternal hosts remove <id>` runs the dry run, shows its label and effect, asks `Type yes to confirm`, then sends the token. `--dry-run` stops after the review. `--yes` skips the question; `--json` and pipes need it. The token is never printed.
Author
Owner

J94f: auth contract update for D336 (bound taps) and D339 (browser host removal). Branch apikey-actions-303; the final head is posted on #303. This adds to 77123/77227 and changes none of the /v1/actions key contract: key-callable actions, review tokens for DryRun::Required, and read audit are all unchanged.

1. Per-action taps are bound to one action (D333 list, D336). The categories are key approval, passkey add, recovery-key rotation and security settings (auth routes), plus CA setup and CA role changes (/remote/ca/*, which already had their own operation-bound ceremonies). None of these is ever key-callable, and the 15-minute admin window never covers them.

  • The browser re-authenticates with POST /auth/login-start {user_id?, action} and action set to one of:
    {"kind":"device.approve","code":"<user_code>"} · {"kind":"passkey.add"} · {"kind":"recovery.rotate"} · {"kind":"security.settings","api_key_lifetime_days":n}.
    An unknown kind, an extra field, an empty code (or one over 64 characters) or days outside 1..365 is 400.
  • login-finish creates the session as before. That session carries the digest of that action and its parameters (sessions.tap_action, migration 0048).
  • The matching change (device-approve, add-start, recovery-rotate, security-settings-update) spends it once, in its own writer transaction, within 120 s of the tap. Another action, other parameters (another code, another value), a second use, a later use or a plain sign-in is 401. A refused change rolls back and leaves the tap unspent.
  • add-start spends the passkey.add tap; registration-finish only checks that this live session spent it.
  • The old "any tap within 300 s" (require_fresh/FRESH) path is gone for these actions.

For J112 (canvas) and any new per-action change: add a TapAction variant (crates/infraternal-server/src/auth/action_tap.rs) carrying exactly the parameters the screen shows, and call require_tap_tx inside the change's transaction. Do not use fresh_session for these. Everyday admin actions keep fresh_session / fresh_session_tx(…, ADMIN_WINDOW_SECONDS).

For J125 (MCP): nothing to change. Per-action-tap actions stay off the key route, so MCP must never list them.

2. Browser host removal (D339: "yes it should"). It falls under the 15-minute admin window, like removal through a key; it has no passkey ceremony of its own.

  • POST /remote/hosts/remove/start {host_id, uninstall?} is the dry run. It requires the admin window, changes nothing, and returns {review, review_expires_in:120, action:{id,label,host_id,host,uninstall}}.
  • POST /remote/hosts/remove/finish {host_id, review} removes the host. The review is single use, bound to the session, owner, host and uninstall choice, and lasts 120 s. The removal transaction rechecks session, owner, host and the admin window by both clocks. A foreign host id is refused without spending the review.
  • The audit row in remote_host_removals is unchanged.
  • The CLI cookie path remove-host --finish now reads {session, review} on stdin, no longer {session, ceremony, proof}.
J94f: **auth contract update for D336 (bound taps) and D339 (browser host removal).** Branch `apikey-actions-303`; the final head is posted on #303. This adds to 77123/77227 and changes none of the `/v1/actions` key contract: key-callable actions, review tokens for `DryRun::Required`, and read audit are all unchanged. **1. Per-action taps are bound to one action (D333 list, D336).** The categories are key approval, passkey add, recovery-key rotation and security settings (auth routes), plus CA setup and CA role changes (`/remote/ca/*`, which already had their own operation-bound ceremonies). None of these is ever key-callable, and the 15-minute admin window never covers them. - The browser re-authenticates with `POST /auth/login-start {user_id?, action}` and `action` set to one of: `{"kind":"device.approve","code":"<user_code>"}` · `{"kind":"passkey.add"}` · `{"kind":"recovery.rotate"}` · `{"kind":"security.settings","api_key_lifetime_days":n}`. An unknown kind, an extra field, an empty code (or one over 64 characters) or days outside 1..365 is 400. - `login-finish` creates the session as before. That session carries the digest of that action and its parameters (`sessions.tap_action`, migration **0048**). - The matching change (`device-approve`, `add-start`, `recovery-rotate`, `security-settings-update`) spends it once, in its own writer transaction, **within 120 s** of the tap. Another action, other parameters (another code, another value), a second use, a later use or a plain sign-in is 401. A refused change rolls back and leaves the tap unspent. - `add-start` spends the `passkey.add` tap; `registration-finish` only checks that this live session spent it. - The old "any tap within 300 s" (`require_fresh`/`FRESH`) path is gone for these actions. **For J112 (canvas) and any new per-action change:** add a `TapAction` variant (`crates/infraternal-server/src/auth/action_tap.rs`) carrying exactly the parameters the screen shows, and call `require_tap_tx` inside the change's transaction. Do not use `fresh_session` for these. Everyday admin actions keep `fresh_session` / `fresh_session_tx(…, ADMIN_WINDOW_SECONDS)`. **For J125 (MCP):** nothing to change. Per-action-tap actions stay off the key route, so MCP must never list them. **2. Browser host removal (D339: "yes it should").** It falls under the 15-minute admin window, like removal through a key; it has no passkey ceremony of its own. - `POST /remote/hosts/remove/start {host_id, uninstall?}` is the dry run. It requires the admin window, changes nothing, and returns `{review, review_expires_in:120, action:{id,label,host_id,host,uninstall}}`. - `POST /remote/hosts/remove/finish {host_id, review}` removes the host. The review is single use, bound to the session, owner, host and uninstall choice, and lasts 120 s. The removal transaction rechecks session, owner, host and the admin window by both clocks. A foreign host id is refused without spending the review. - The audit row in `remote_host_removals` is unchanged. - The CLI cookie path `remove-host --finish` now reads `{session, review}` on stdin, no longer `{session, ceremony, proof}`.
Sign in to join this conversation.
No labels
launch-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/infraternal#313
No description provided.