From a24b0ec7bbf9c832f7522c2e9096fa7f97943131 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 14:15:14 -0400 Subject: [PATCH 1/9] feat(agent): add bounded inspection and guarded submission --- .changeset/bounded-agent-coordination.md | 7 + README.md | 5 +- docs/api-reference.md | 68 +++- docs/wiki/Driving-Codeman-From-An-Agent.md | 5 + plugins/codeman/skills/codeman/SKILL.md | 6 +- .../reference/inspection-and-staging.md | 84 +++++ .../codeman/skills/codeman/reference/verbs.md | 9 + skills/codeman/SKILL.md | 6 +- .../reference/inspection-and-staging.md | 84 +++++ skills/codeman/reference/verbs.md | 9 + src/agent-codex-identity.ts | 148 ++++++++ src/agent-inspection.ts | 191 ++++++++++ src/agent-staging.ts | 183 ++++++++++ src/cli-agent.ts | 138 ++++++++ src/mux-interface.ts | 3 +- src/session.ts | 162 ++++++++- src/terminal-input-classifier.ts | 62 ++++ src/tmux-manager.ts | 8 +- src/web/routes/agent-session-routes.ts | 148 ++++++++ src/web/routes/session-routes.ts | 28 +- src/web/schemas.ts | 12 + test/agent-codex-identity.test.ts | 119 +++++++ test/agent-inspection.test.ts | 161 +++++++++ test/agent-staging-session.test.ts | 329 ++++++++++++++++++ test/agent-staging.test.ts | 153 ++++++++ test/cli-agent-inspection.test.ts | 94 +++++ test/mocks/mock-session.ts | 4 + test/routes/agent-session-routes.test.ts | 220 ++++++++++++ test/terminal-input-classifier.test.ts | 37 ++ test/tmux-submit-paste.test.ts | 15 + 30 files changed, 2462 insertions(+), 36 deletions(-) create mode 100644 .changeset/bounded-agent-coordination.md create mode 100644 plugins/codeman/skills/codeman/reference/inspection-and-staging.md create mode 100644 skills/codeman/reference/inspection-and-staging.md create mode 100644 src/agent-codex-identity.ts create mode 100644 src/agent-inspection.ts create mode 100644 src/agent-staging.ts create mode 100644 src/terminal-input-classifier.ts create mode 100644 src/web/routes/agent-session-routes.ts create mode 100644 test/agent-codex-identity.test.ts create mode 100644 test/agent-inspection.test.ts create mode 100644 test/agent-staging-session.test.ts create mode 100644 test/agent-staging.test.ts create mode 100644 test/cli-agent-inspection.test.ts create mode 100644 test/routes/agent-session-routes.test.ts create mode 100644 test/terminal-input-classifier.test.ts diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md new file mode 100644 index 00000000..1122ed67 --- /dev/null +++ b/.changeset/bounded-agent-coordination.md @@ -0,0 +1,7 @@ +--- +"aicodeman": minor +--- + +Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. + +Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/README.md b/README.md index bb3c142c..1850841a 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one @@ -985,6 +985,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) +codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains codeman agent interrupt "$SID" # a bare ESC, conversation intact diff --git a/docs/api-reference.md b/docs/api-reference.md index 95a28b17..dd538bd3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -589,9 +589,75 @@ request returns. The capture runs synchronous tmux calls on the server; the | `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | | `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | +## Bounded agent inspection and guarded input + +These authenticated routes use the standard envelope and session ownership checks. +`/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before +any transcript, pane or receipt lookup. + +| Endpoint | Parameters/body | Result | +| --- | --- | --- | +| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | +| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | +| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | + +Inspection initially supports native local Codex JSONL with strict conversation +attribution, using the session's effective `CODEX_HOME`, explicit resume identity +or unique managed originator. It never selects an arbitrary newest cwd match. +The initial read selects recent complete records; `omittedBeforeBytes` explicitly +labels its tail omission. Cursor reads consume only complete appended records, +keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON +escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution +separately caps at 8192 directory entries, eight directory levels and 1 MiB of +metadata reads. Oversized/unfinished records report `blockedReason`; incremental +reads do not silently skip them. Other modes, remote/container sessions, and +unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, +`cursor:null`; these snapshots can repeat evidence. + +Attribution identifies a file, not an authoritative current-thread signal. A +configured launch resume and a new owned rollout conflict and force fallback. +An in-CLI `/resume` of an existing foreign-originator thread may leave no new +owned rollout; launch metadata cannot detect that switch. Check the returned +conversation identity and do not use these observations as proof of the active +thread, task completion or authorization. + +Opaque server-side cursors bind caller, session, pane generation, conversation, +opened-file identity, byte offset and bounded content anchors. At most 1024 live +cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh +tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign +cursors or loss of the attributed source return `409 CONFLICT`. No transcript +filesystem path is returned as cursor/identity metadata; evidence retains the +owner's native record content, which can itself contain paths. + +Guarded input initially requires a local characterized Codex composer, one +unwrapped visible line, a recognized footer and a cursor at its exact end. +Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts +at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing +whitespace (the existing mux transport trims it). Its two-minute receipt stores +only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), +never persisted prompt content. A receipt acknowledges typing; a delayed screen +paint can still make submit refuse until exact verification becomes possible. + +Direct auxiliary key operations participate in the same pending-write and input +lease checks. Complete allowlisted focus/mouse reports and empty writes preserve +input revision; mixed or unknown sequences remain keyboard-bearing. Exact +composer/caret checks also reject a mouse-moved caret. + +Submit consumes an owned receipt before awaited delivery, verifies the caller, +confirmation hash, expiry, lifecycle/input revisions and exact composer/caret, +then uses a session-level conditional Enter dispatch with a temporary input lease. +Competing Codeman writes cannot enter during that dispatch. Managed restarts and +intervening browser/API/auxiliary input invalidate receipts independently of pid +reuse or restored visible text. Direct tmux edits remain outside Codeman's +serialization. Failed, ambiguous, expired, changed or repeated submits return +`409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its +error recovery automatically replays Enter. Success acknowledges delivery of one +Enter, not native acceptance or task completion. Invalid schema input returns +`400 INVALID_INPUT`. + ## The `codeman agent` CLI (client over these endpoints) -`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`; `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 6b2b337f..776eddf1 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -44,6 +44,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) +codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -264,3 +267,5 @@ failing the spawn, because a cosmetic field must never break a worker. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way. - [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI. - [`skills/codeman/SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) - the skill itself. + +Bounded inspection and guarded stage/submit details: [API reference](../api-reference.md#bounded-agent-inspection-and-guarded-input). Stage accepts one printable UTF8 line without trailing whitespace; receipts expire after two minutes. Submit refuses changed/unprovable composers and never automatically replays Enter. `send --no-enter` alone has no receipt. diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 50133e8c..ce34728a 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -30,13 +30,15 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). +Bounded inspection and guarded stage/submit load only when needed from +[reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. -That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox -verbs `post`, `inbox`, `ack`) is the same +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`, +`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same verbs over the same endpoints, with the guards below (no control bytes, no self-delete, no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md new file mode 100644 index 00000000..394f6be4 --- /dev/null +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -0,0 +1,84 @@ +# Bounded inspection and guarded submission + +Use these commands inside a Codeman session. They use its authenticated HTTP API; +no direct tmux access, second transport, or session-bridge installation is required. +They supplement `send`, `wait` and `read`. They do not establish full parity with +session-bridge or retire that skill. + +## Inspect recent evidence + +```bash +codeman agent inspect "$SID" --max-bytes 8192 --json +codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json +``` + +Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before +interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON +metadata/envelope. The default is 8192, maximum 65536. + +- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call + selects recent complete records within a bounded tail window; + `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues + after the last consumed complete record. Partial final records remain pending. +- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered + output. It has `incremental:false` and `cursor:null`. This is the fallback for + other modes, remote/container sessions, ambiguous attribution, and exhausted + attribution budgets. Repeating a snapshot can repeat evidence. + +Attribution proves which file supplied the evidence, not which thread is now +active in the TUI. A launch resume that conflicts with a newly owned rollout +falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator +thread may leave launch metadata unchanged. Check `identity.conversationId`; +these reads do not provide authoritative current-thread tracking. + +Cursors are opaque, scoped to the requesting identity, session, pane generation +and attributed transcript, expire after 15 minutes, and disappear on server +restart. At most 1024 are retained; capacity eviction can invalidate an older +cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +or unavailable native source returns `409 CONFLICT`. Inspect again without +`--since` to establish a new tail baseline; this may omit older evidence. + +`blockedReason` explains an unfinished or oversized record. Incremental reads +never split or silently skip one. Increase the byte budget for +`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, +use `read --tail` for a labeled terminal view or start a fresh tail baseline. +Inspect scans at most 256 KiB of record data plus bounded generation anchors. +Attribution separately caps directory entries and metadata bytes. Evidence and +activity are observations, not proof of task completion or authorization. + +## Stage, inspect, then submit once + +```bash +printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt +codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json +# Review the returned stageId, sha256, expiresAt; inspect the target if needed. +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json +``` + +Stage types the file contents **without Enter**. The file must be regular, UTF8, +one printable line, at most 8192 bytes, with no control characters or trailing +whitespace/newline. It initially supports a local Codex composer with a recognized +footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +wrapped, remote/container and shell composers are refused. A customized footer or +unsupported character-width rendering can also make verification unavailable. +Self-targets are refused by the CLI. + +The receipt stores a hash and ephemeral identities, never prompt content. It +expires after two minutes; capacity is 1024 live receipts. Stage delivery can +precede the next screen paint: a receipt acknowledges typing, while submit still +requires the fully painted exact composer. If typing fails or is ambiguous, stage +issues no receipt; inspect the pane before trying again. + +Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, +exact composer and caret, then sends one conditional Enter at the session's input +boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +input and managed restarts invalidate receipts even when the visible text or pid +is restored. Direct tmux edits are outside that input serialization; captures +remain evidence at the time observed, rather than terminal isolation. + +Every owned submit attempt consumes its receipt, including hash mismatch, +verification failure and delivery failure. Concurrent/repeated submits cannot +replay Enter, and there is no autonomous retry. A successful response means one +Enter was delivered; use inspection or a suitable wait signal to observe native +acceptance. A `409` or broken connection means inspect first. Do not automatically +stage/submit again or send an unguarded Enter to recover. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 65af5c65..3f511649 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -811,3 +811,12 @@ Rules that matter: not parked, `send` it one line ("read your inbox: `codeman agent inbox`"). - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. + +## Bounded inspection and guarded submission + +Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +Use `codeman agent stage --message-file` followed by one hash-confirmed +`codeman agent submit --stage-id --confirm-sha` when the exact local Codex +composer and caret can be verified. Other targets provide labeled snapshots or +refuse guarded input. See [inspection and staging](inspection-and-staging.md) +for budgets, receipt expiry, conflict handling and supported composers. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 50133e8c..ce34728a 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -30,13 +30,15 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). +Bounded inspection and guarded stage/submit load only when needed from +[reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*. -That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox -verbs `post`, `inbox`, `ack`) is the same +That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`, +`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same verbs over the same endpoints, with the guards below (no control bytes, no self-delete, no guessed URL) enforced in code, and it is also your own way through when this machine has no `jq` for the preamble below. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md new file mode 100644 index 00000000..394f6be4 --- /dev/null +++ b/skills/codeman/reference/inspection-and-staging.md @@ -0,0 +1,84 @@ +# Bounded inspection and guarded submission + +Use these commands inside a Codeman session. They use its authenticated HTTP API; +no direct tmux access, second transport, or session-bridge installation is required. +They supplement `send`, `wait` and `read`. They do not establish full parity with +session-bridge or retire that skill. + +## Inspect recent evidence + +```bash +codeman agent inspect "$SID" --max-bytes 8192 --json +codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json +``` + +Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before +interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON +metadata/envelope. The default is 8192, maximum 65536. + +- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call + selects recent complete records within a bounded tail window; + `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues + after the last consumed complete record. Partial final records remain pending. +- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered + output. It has `incremental:false` and `cursor:null`. This is the fallback for + other modes, remote/container sessions, ambiguous attribution, and exhausted + attribution budgets. Repeating a snapshot can repeat evidence. + +Attribution proves which file supplied the evidence, not which thread is now +active in the TUI. A launch resume that conflicts with a newly owned rollout +falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator +thread may leave launch metadata unchanged. Check `identity.conversationId`; +these reads do not provide authoritative current-thread tracking. + +Cursors are opaque, scoped to the requesting identity, session, pane generation +and attributed transcript, expire after 15 minutes, and disappear on server +restart. At most 1024 are retained; capacity eviction can invalidate an older +cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +or unavailable native source returns `409 CONFLICT`. Inspect again without +`--since` to establish a new tail baseline; this may omit older evidence. + +`blockedReason` explains an unfinished or oversized record. Incremental reads +never split or silently skip one. Increase the byte budget for +`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, +use `read --tail` for a labeled terminal view or start a fresh tail baseline. +Inspect scans at most 256 KiB of record data plus bounded generation anchors. +Attribution separately caps directory entries and metadata bytes. Evidence and +activity are observations, not proof of task completion or authorization. + +## Stage, inspect, then submit once + +```bash +printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt +codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json +# Review the returned stageId, sha256, expiresAt; inspect the target if needed. +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json +``` + +Stage types the file contents **without Enter**. The file must be regular, UTF8, +one printable line, at most 8192 bytes, with no control characters or trailing +whitespace/newline. It initially supports a local Codex composer with a recognized +footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +wrapped, remote/container and shell composers are refused. A customized footer or +unsupported character-width rendering can also make verification unavailable. +Self-targets are refused by the CLI. + +The receipt stores a hash and ephemeral identities, never prompt content. It +expires after two minutes; capacity is 1024 live receipts. Stage delivery can +precede the next screen paint: a receipt acknowledges typing, while submit still +requires the fully painted exact composer. If typing fails or is ambiguous, stage +issues no receipt; inspect the pane before trying again. + +Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, +exact composer and caret, then sends one conditional Enter at the session's input +boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +input and managed restarts invalidate receipts even when the visible text or pid +is restored. Direct tmux edits are outside that input serialization; captures +remain evidence at the time observed, rather than terminal isolation. + +Every owned submit attempt consumes its receipt, including hash mismatch, +verification failure and delivery failure. Concurrent/repeated submits cannot +replay Enter, and there is no autonomous retry. A successful response means one +Enter was delivered; use inspection or a suitable wait signal to observe native +acceptance. A `409` or broken connection means inspect first. Do not automatically +stage/submit again or send an unguarded Enter to recover. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 65af5c65..3f511649 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -811,3 +811,12 @@ Rules that matter: not parked, `send` it one line ("read your inbox: `codeman agent inbox`"). - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. + +## Bounded inspection and guarded submission + +Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +Use `codeman agent stage --message-file` followed by one hash-confirmed +`codeman agent submit --stage-id --confirm-sha` when the exact local Codex +composer and caret can be verified. Other targets provide labeled snapshots or +refuse guarded input. See [inspection and staging](inspection-and-staging.md) +for budgets, receipt expiry, conflict handling and supported composers. diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts new file mode 100644 index 00000000..81a26162 --- /dev/null +++ b/src/agent-codex-identity.ts @@ -0,0 +1,148 @@ +/** + * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses + * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. + */ +import { realpathSync } from 'node:fs'; +import { open, opendir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const HEAD_BYTES = 131072; +export interface CodexAttributionOptions { + codexHome: string; + sessionId: string; + workingDir: string; + resumeSessionId?: string; +} +export interface CodexAttributionBudget { + maxEntries: number; + maxBytes: number; +} +interface Metadata { + id?: string; + cwd?: string; + originator?: string; +} +function canonical(path: string): string | undefined { + try { + return realpathSync.native(path); + } catch { + return undefined; + } +} +/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */ +export function resolveEffectiveCodexHome( + sessionHome: string | undefined, + workingDir: string, + env: Readonly> = process.env +): string { + const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex')); + return canonical(path) ?? path; +} +/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */ +function strictMetadata(bytes: Buffer): Metadata | undefined { + const rows = bytes.toString('utf8').split('\n'); + rows.pop(); + let found: Metadata | undefined; + for (const line of rows) { + try { + const row = JSON.parse(line) as { type?: string; payload?: Metadata }; + if (row.type !== 'session_meta') continue; + if ( + found || + typeof row.payload?.id !== 'string' || + typeof row.payload.cwd !== 'string' || + (row.payload.originator !== undefined && typeof row.payload.originator !== 'string') + ) + return undefined; + found = row.payload; + } catch { + /* Incomplete or malformed rows are not identity evidence. */ + } + } + return found; +} +/** + * A configured resume and a newly owned rollout conflict rather than pinning an + * old launch forever after /new. TUI resumes of foreign-originator threads cannot + * be authoritatively tracked from this metadata; this returns attributed evidence. + */ +export async function findExactCodexConversation( + options: CodexAttributionOptions, + budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 } +): Promise<{ id: string; path: string } | undefined> { + const directories = [{ path: join(options.codexHome, 'sessions'), depth: 0 }]; + const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; + const originator = `codeman_${options.sessionId}`; + const explicit = options.resumeSessionId; + let entries = 0; + let scanned = HEAD_BYTES; // Reserve an independent final verification. + while (directories.length) { + const dir = directories.pop()!; + const handle = await opendir(dir.path).catch(() => undefined); + if (!handle) return undefined; + for await (const entry of handle) { + if (++entries > budget.maxEntries) return undefined; + const path = join(dir.path, entry.name); + if (entry.isDirectory()) { + if (dir.depth >= 8) return undefined; + directories.push({ path, depth: dir.depth + 1 }); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const file = await open(path, 'r').catch(() => undefined); + if (!file) return undefined; + try { + const info = await file.stat(); + if (!info.isFile()) return undefined; + const id = entry.name.match(UUID_AT_END)?.[1]; + const prefixBytes = Math.min(info.size, 4096); + if (scanned + prefixBytes > budget.maxBytes) return undefined; + scanned += prefixBytes; + const prefix = Buffer.alloc(prefixBytes); + const first = await file.read(prefix, 0, prefixBytes, 0); + // Cheap negative filter only; a match still needs full parsed metadata. + if ( + id !== explicit && + !prefix.subarray(0, first.bytesRead).toString('utf8').includes(JSON.stringify(originator)) + ) + continue; + const size = Math.min(info.size, HEAD_BYTES); + if (scanned + size > budget.maxBytes) return undefined; + scanned += size; + const bytes = Buffer.alloc(size); + const read = await file.read(bytes, 0, size, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if (!meta || !id || meta.id !== id) continue; + if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta }); + } finally { + await file.close(); + } + } + } + } + if (candidates.length !== 1) return undefined; + const candidate = candidates[0]; + if ( + !candidate.meta.cwd || + canonical(candidate.meta.cwd) !== canonical(options.workingDir) || + !canonical(options.workingDir) + ) + return undefined; + const handle = await open(candidate.path, 'r').catch(() => undefined); + if (!handle) return undefined; + try { + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if ( + !meta || + meta.id !== candidate.id || + meta.cwd !== candidate.meta.cwd || + meta.originator !== candidate.meta.originator + ) + return undefined; + return { id: candidate.id, path: candidate.path }; + } finally { + await handle.close(); + } +} diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts new file mode 100644 index 00000000..1fc15b4e --- /dev/null +++ b/src/agent-inspection.ts @@ -0,0 +1,191 @@ +/** + * @fileoverview Bounded append-only JSONL evidence reads for agent inspection. + * Opaque, expiring server-side cursors contain no public filesystem paths. A cursor + * binds the caller, pane generation, attributed conversation and opened file identity. + * Complete records only; an oversized row stalls explicitly rather than losing evidence. + */ +import { open, type FileHandle } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { isUtf8 } from 'node:buffer'; + +export const INSPECT_MAX_BYTES = 65536; +export const INSPECT_SCAN_BYTES = 262144; +export interface InspectionScope { + owner: string; + sessionId: string; + generation: string; + conversationId: string; +} +interface CursorState { + scope: string; + path: string; + identity: string; + offset: number; + observedSize: number; + modifiedAt: number; + changedAt: number; + anchor: string; + expiresAt: number; +} +export class InspectionConflict extends Error {} + +/** UTF8-safe suffix, also used for explicitly non-incremental pane evidence. */ +export function boundedUtf8Tail(text: string, maxBytes: number): string { + // A UTF8 suffix fits within this many UTF16 units. Include a pair of extra + // units so slicing cannot introduce a surrogate replacement inside the result. + const bytes = Buffer.from(text.slice(-maxBytes - 2)); + let start = Math.max(0, bytes.length - maxBytes); + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; + return bytes.subarray(start).toString('utf8'); +} +async function anchor(handle: FileHandle, offset: number): Promise { + const count = Math.min(offset, 4096); + const head = Buffer.alloc(count); + const tail = Buffer.alloc(count); + await handle.read(head, 0, count, 0); + await handle.read(tail, 0, count, Math.max(0, offset - count)); + return createHash('sha256').update(head).update(tail).digest('hex'); +} + +export class InspectionReader { + private readonly cursors = new Map(); + constructor( + private readonly now = Date.now, + private readonly capacity = 1024, + private readonly ttlMs = 900000 + ) {} + + async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) { + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > INSPECT_MAX_BYTES) + throw new Error('Invalid byte budget'); + const scopeKey = JSON.stringify(scope); + const previous = since ? this.cursors.get(since) : undefined; + if ( + since && + (!previous || previous.expiresAt <= this.now() || previous.scope !== scopeKey || previous.path !== path) + ) { + throw new InspectionConflict('Invalid or expired inspection cursor; inspect without since to start again'); + } + const handle = await open(path, 'r'); + try { + const before = await handle.stat(); + if (!before.isFile()) throw new InspectionConflict('Inspection source is not a regular file'); + const identity = `${before.dev}:${before.ino}:${before.birthtimeMs}`; + let offset = previous?.offset ?? Math.max(0, before.size - INSPECT_SCAN_BYTES + 1); + if ( + previous && + (identity !== previous.identity || + before.size < previous.observedSize || + (before.size === previous.observedSize && + (before.mtimeMs !== previous.modifiedAt || before.ctimeMs !== previous.changedAt)) || + (await anchor(handle, offset)) !== previous.anchor) + ) { + throw new InspectionConflict('Inspection cursor source changed; inspect without since to start again'); + } + let startsAtBoundary = offset === 0; + if (!previous && offset > 0) { + const preceding = Buffer.alloc(1); + await handle.read(preceding, 0, 1, offset - 1); + startsAtBoundary = preceding[0] === 10; + } + const scan = Buffer.alloc( + Math.min(INSPECT_SCAN_BYTES - (!previous && offset > 0 ? 1 : 0), Math.max(0, before.size - offset)) + ); + const { bytesRead } = await handle.read(scan, 0, scan.length, offset); + let selectedStart = 0; + let consumed = 0; + let blockedReason: string | undefined; + let omittedBeforeBytes = 0; + if (!previous) { + const leading = startsAtBoundary ? 0 : scan.indexOf(10) + 1; + const completeEnd = scan.lastIndexOf(10, bytesRead - 1) + 1; + if (!startsAtBoundary && leading === 0) { + // The tail lies wholly inside one unfinished oversized record. Its + // beginning is not known: retain the known file boundary rather than + // manufacture a cursor pointing into a JSON/UTF8 fragment. + offset = 0; + blockedReason = 'record-exceeds-scan-limit'; + } else { + selectedStart = completeEnd; + consumed = completeEnd; + while (selectedStart > leading) { + const recordStart = Math.max(leading, scan.lastIndexOf(10, selectedStart - 2) + 1); + if (completeEnd - recordStart > maxBytes) { + if (selectedStart === completeEnd) { + selectedStart = recordStart; + consumed = recordStart; + blockedReason = 'record-exceeds-output-budget'; + } + break; + } + selectedStart = recordStart; + } + if (completeEnd < bytesRead && !blockedReason) blockedReason = 'partial-record'; + // If the only known boundary is the end of a skipped leading row, + // the remainder is a partial record and stays pending there. + if (completeEnd < leading) selectedStart = consumed = leading; + omittedBeforeBytes = offset + selectedStart; + } + } else { + while (consumed < bytesRead) { + const end = scan.indexOf(10, consumed); + if (end < 0) { + blockedReason = bytesRead === INSPECT_SCAN_BYTES ? 'record-exceeds-scan-limit' : 'partial-record'; + break; + } + if (end + 1 > maxBytes) { + blockedReason = 'record-exceeds-output-budget'; + break; + } + consumed = end + 1; + } + } + const evidenceBytes = scan.subarray(selectedStart, consumed); + if (!isUtf8(evidenceBytes)) throw new InspectionConflict('Inspection source contains invalid UTF8'); + for (const row of evidenceBytes.toString('utf8').split('\n').slice(0, -1)) { + try { + JSON.parse(row); + } catch { + throw new InspectionConflict('Inspection source contains invalid JSONL'); + } + } + const after = await handle.stat(); + if ( + after.size < before.size || + after.ino !== before.ino || + (after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)) + ) + throw new InspectionConflict('Inspection source changed during read'); + const nextAnchor = await anchor(handle, offset + consumed); + const cursor = randomUUID(); + // No await between capacity enforcement and insertion: concurrent reads + // must not all reserve the same remaining slot while hashing anchors. + for (const [key, value] of this.cursors) if (value.expiresAt <= this.now()) this.cursors.delete(key); + while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!); + this.cursors.set(cursor, { + scope: scopeKey, + path, + identity, + offset: offset + consumed, + observedSize: before.size, + modifiedAt: before.mtimeMs, + changedAt: before.ctimeMs, + anchor: nextAnchor, + expiresAt: this.now() + this.ttlMs, + }); + return { + evidence: evidenceBytes.toString('utf8'), + cursor, + omittedBeforeBytes, + truncated: omittedBeforeBytes > 0 || offset + consumed < before.size, + blockedReason, + scannedBytes: bytesRead, + }; + } finally { + await handle.close(); + } + } + clear(): void { + this.cursors.clear(); + } +} diff --git a/src/agent-staging.ts b/src/agent-staging.ts new file mode 100644 index 00000000..ff8cc8c9 --- /dev/null +++ b/src/agent-staging.ts @@ -0,0 +1,183 @@ +/** + * @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is + * retained: only caller/session identity, lifecycle/input revisions and SHA-256. + * A receipt is consumed synchronously before verification/delivery, including failures. + */ +import { createHash, randomUUID } from 'node:crypto'; +import { stripAnsi } from './utils/regex-patterns.js'; +import { visibleWidth } from './tui/tui-ansi.js'; +export const STAGE_MAX_BYTES = 8192; +export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); +export interface GuardedInputSnapshot { + generation: string; + revision: number; + composerSha: string; +} +export interface GuardedInputTarget { + id: string; + snapshot(): GuardedInputSnapshot | null; + write(text: string): Promise; + submit(expected: GuardedInputSnapshot): Promise; +} +interface Receipt extends GuardedInputSnapshot { + owner: string; + sessionId: string; + expiresAt: number; + sha256: string; +} +export class GuardedInputConflict extends Error {} + +/** + * Conservatively read a Codex composer: exactly one visible, unwrapped row and a + * known footer. Never remove internal spaces, infer an unknown shell prompt, or + * interpret dialog options as authority. An unsupported frame yields no proof. + */ +export function readGuardedCodexComposer(screen: string): string | null { + const lines = stripAnsi(screen) + .split('\n') + .map((line) => line.trimEnd()); + const rows = lines.map((line, index) => ({ line, index })).filter((row) => /^\s*›(?:\s|$)/.test(row.line)); + const last = rows.at(-1); + if (!last) return null; + const below = lines.slice(last.index + 1).filter((line) => line.trim()); + if ( + below.length === 0 || + below.length > 3 || + below.some( + (line) => + !/^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:default|low|medium|high|xhigh|max) · )/.test( + line + ) + ) + ) + return null; + // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. + const raw = screen.split('\n')[last.index]; + let dim = false; + let visible = ''; + // eslint-disable-next-line no-control-regex + for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) { + // eslint-disable-next-line no-control-regex + if (/^\x1b\[/.test(token)) { + const codes = token.slice(2, -1).split(';').map(Number); + for (let i = 0; i < codes.length; i++) { + // Extended color payloads may contain 0, 2 or 22; those are not SGR modes. + if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) { + i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0; + } else if (codes[i] === 0 || codes[i] === 22) dim = false; + else if (codes[i] === 2) dim = true; + } + } else if (!dim) visible += token; + } + return stripAnsi(visible).trimStart().slice(2).trimEnd(); +} + +/** Require the capture's caret at the exact end of the unwrapped composer. */ +export function readGuardedCodexSnapshot(snapshot: string): string | null { + // formatPaneSnapshot emits one absolute row address per line, then the caret. + // Plain captures lack caret evidence and cannot prove invisible trailing edits. + // eslint-disable-next-line no-control-regex + const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)]; + if (positions.length < 2) return null; + const caret = positions.at(-1)!; + if (caret.index! + caret[0].length !== snapshot.length) return null; + const rows: string[] = []; + for (let i = 0; i < positions.length - 1; i++) { + const position = positions[i]; + if (Number(position[1]) !== i + 1 || position[2] !== '1') return null; + rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index)); + } + const composer = readGuardedCodexComposer(rows.join('\n')); + if (composer === null) return null; + let composerRow = -1; + for (let i = rows.length - 1; i >= 0; i--) + if (/^\s*›(?:\s|$)/.test(stripAnsi(rows[i]))) { + composerRow = i; + break; + } + const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); + if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) + return null; + return composer; +} + +export class GuardedStageStore { + private readonly receipts = new Map(); + private readonly busy = new Set(); + constructor( + private readonly now = Date.now, + private readonly capacity = 1024, + private readonly ttlMs = 120000 + ) {} + async stage(target: GuardedInputTarget, owner: string, message: string) { + // eslint-disable-next-line no-control-regex + const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message); + if ( + !message || + message !== message.trimEnd() || + Buffer.byteLength(message) > STAGE_MAX_BYTES || + Buffer.from(message).toString('utf8') !== message || + hasControls + ) + throw new GuardedInputConflict( + 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace' + ); + const key = target.id; + if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress'); + this.busy.add(key); + try { + const before = target.snapshot(); + if (!before || before.composerSha !== promptSha256('')) + throw new GuardedInputConflict('An empty, verifiable composer is required'); + for (const [id, receipt] of this.receipts) + if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id); + // Refuse capacity before typing. Do not evict a caller's still-live receipt. + // Include in-flight stages (this operation already owns one busy slot), + // so different sessions cannot type concurrently into the same last slot. + if (this.receipts.size + this.busy.size > this.capacity) + throw new GuardedInputConflict('Guarded stage receipt capacity reached'); + if (!(await target.write(message))) + throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); + const after = target.snapshot(); + if (!after || after.generation !== before.generation || after.revision !== before.revision + 1) + throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); + const stageId = randomUUID(); + const expiresAt = this.now() + this.ttlMs; + const sha256 = promptSha256(message); + this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256 }); + return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const }; + } finally { + this.busy.delete(key); + } + } + async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) { + const receipt = this.receipts.get(stageId); + if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id) + throw new GuardedInputConflict('Unknown, used, or foreign stage receipt'); + // One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter. + this.receipts.delete(stageId); + if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress'); + this.busy.add(target.id); + try { + if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha) + throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch'); + const current = target.snapshot(); + if ( + !current || + current.generation !== receipt.generation || + current.revision !== receipt.revision || + current.composerSha !== receipt.sha256 + ) + throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted'); + if (!(await target.submit(current))) + throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane'); + return { stageId, sessionId: target.id, submitted: true as const }; + } finally { + this.busy.delete(target.id); + } + } + clear(): void { + this.receipts.clear(); + this.busy.clear(); + } +} diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad47..9f0ccb8b 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -31,6 +31,10 @@ */ import http from 'node:http'; import https from 'node:https'; +import { open, stat } from 'node:fs/promises'; +import { isUtf8 } from 'node:buffer'; +import { STAGE_MAX_BYTES } from './agent-staging.js'; +import { INSPECT_MAX_BYTES } from './agent-inspection.js'; import type { Command } from 'commander'; import { basicAuthHeader, @@ -757,6 +761,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise< return waitExitCode(data?.wait); } +/** Bounded incremental evidence, with an opaque server-owned cursor. */ +export async function agentInspect( + deps: AgentDeps, + options: { id: string; maxBytes: number; since?: string } +): Promise { + if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES) + return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`, + query: { maxBytes: options.maxBytes, since: options.since }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else { + const data = response.json.data as { + source?: string; + evidence?: string; + truncated?: boolean; + cursor?: string; + blockedReason?: string; + }; + deps.io.out( + `[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}` + ); + deps.io.out(data.evidence ?? ''); + if (data.cursor) deps.io.out(`cursor: ${data.cursor}`); + } + return EXIT.ok; +} + +/** Stage exactly the printable UTF8 file contents; never sends Enter. */ +export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise { + if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); + let message: string; + try { + const info = await stat(options.messageFile); + if (!info.isFile() || info.size > STAGE_MAX_BYTES) + return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused); + const handle = await open(options.messageFile, 'r'); + try { + const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes)) + return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused); + message = bytes.toString('utf8'); + } finally { + await handle.close(); + } + } catch (error) { + return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused); + } + const refusal = inputRefusal(message); + if (refusal || message !== message.trimEnd()) + return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`, + body: { message }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else { + const data = response.json.data as { stageId: string; sha256: string; expiresAt: number }; + deps.io.out( + `staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}` + ); + } + return EXIT.ok; +} + +/** Submit one verified staged composer; failure never automatically replays Enter. */ +export async function agentSubmit( + deps: AgentDeps, + options: { id: string; stageId: string; confirmSha: string } +): Promise { + if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused); + if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId)) + return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused); + const target = await resolveSessionId(deps, options.id); + if ('error' in target) return fail(deps, target.error, target.code); + const response = await deps.request(deps.ctx, { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`, + body: { stageId: options.stageId, confirmSha: options.confirmSha }, + }); + if (!response.json?.success) return fail(deps, describeFailure(response)); + if (deps.json) emitJson(deps, response.json.data); + else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).'); + return EXIT.ok; +} + export interface ReadOptions { id: string; /** Bytes of raw terminal to fetch; ANSI is stripped for humans. */ @@ -1169,6 +1270,43 @@ export function registerAgentCommands(program: Command): Command { ) ); + agent + .command('inspect ') + .description('Bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') + .option('--since ', 'Resume a cursor from the same owner, pane and transcript') + .option('--json', 'Machine-readable output') + .action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) => + run(Boolean(options.json), (deps) => + agentInspect(deps, { + id, + maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'), + since: options.since, + }) + ) + ); + + agent + .command('stage ') + .description('Type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer') + .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') + .option('--json', 'Machine-readable output') + .action((id: string, options: { messageFile: string; json?: boolean }) => + run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile })) + ); + + agent + .command('submit ') + .description('Send Enter once only when the staged composer, pane and hash still match') + .requiredOption('--stage-id ', 'One-shot stage receipt') + .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') + .option('--json', 'Machine-readable output') + .action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) => + run(Boolean(options.json), (deps) => + agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha }) + ) + ); + agent .command('read ') .description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal") diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 0f37758f..94c36283 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -316,7 +316,8 @@ export interface TerminalMultiplexer extends EventEmitter { /** * Send input to a session via tmux send-keys. */ - sendInput(sessionId: string, input: string): Promise; + /** Internal conditional Enter guard, checked synchronously before transport dispatch. */ + sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise; // ========== Metadata ========== diff --git a/src/session.ts b/src/session.ts index 1cb18fe9..6f673222 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,6 +129,9 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; +import { promptSha256, readGuardedCodexSnapshot, type GuardedInputSnapshot } from './agent-staging.js'; +import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; +import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; @@ -1308,6 +1311,7 @@ export class Session extends EventEmitter { /** Set the owning username (used by recovery to restore ownership). */ set owner(username: string | undefined) { + if (this._owner !== username) this._guardedPaneRevision++; this._owner = username; } @@ -1412,11 +1416,13 @@ export class Session extends EventEmitter { * tmux session whose record is about to be deleted (Ark0N/Codeman#446). */ markClosing(closing: boolean): void { + if (this._closing !== closing) this._guardedPaneRevision++; this._closing = closing; } /** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */ private async _withPaneLifecycle(op: () => Promise): Promise { + this._guardedPaneRevision++; this._paneLifecycleOps++; try { return await op(); @@ -1443,6 +1449,7 @@ export class Session extends EventEmitter { * Ark0N/Codeman#446 review; both now persist on their success path. */ private clearPaneExitForNewPane(): void { + this._guardedPaneRevision++; this.setPaneExit(undefined); if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName); } @@ -4206,6 +4213,11 @@ export class Session extends EventEmitter { prompt: string, options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void } ): Promise<{ result: string; cost: number }> { + if (this._guardedSubmitInFlight) { + this._guardedInputRevision++; + throw new Error('Guarded submission in progress'); + } + this._guardedPaneRevision++; return new Promise((resolve, reject) => { if (this.ptyProcess) { reject(new Error('Session already has a running process')); @@ -4733,7 +4745,109 @@ export class Session extends EventEmitter { * discards the data, but it used to do so with no signal at all — which is how * input could disappear while the caller believed it had been delivered. */ + private readonly _inspectionInstance = uuidv4(); + private _guardedPaneRevision = 0; + private _guardedInputRevision = 0; + private _guardedPendingWrites = 0; + private _guardedSubmitInFlight = false; + /** Ephemeral identity, never persisted or based on a potentially reused pid alone. */ + get inspectionGeneration(): string { + return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; + } + get guardedInputPendingWrites(): number { + return this._guardedPendingWrites; + } + /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ + getCodexHomeOverride(): string | undefined { + return this._envOverrides?.CODEX_HOME || undefined; + } + private _observeGuardedKeyboardInput(data: string): void { + if (data && isKeyboardTerminalInput(data)) this._guardedInputRevision++; + } + guardedInputSnapshot(): GuardedInputSnapshot | null { + return this._readGuardedInputSnapshot(false); + } + private _readGuardedInputSnapshot(allowLease: boolean): GuardedInputSnapshot | null { + if ( + this._isStopped || + this._closing || + this.paneLifecycleInFlight || + this._guardedPendingWrites || + (!allowLease && this._guardedSubmitInFlight) || + this.remote || + this.docker || + this.paneExit || + getCli(this.mode)?.capabilities.transcript !== 'codex-rollout' || + !this._muxSession + ) + return null; + const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName); + if (!screen) return null; + // tmux's replay has CUP addresses rather than line breaks. Read the SAME + // fresh frame for dialog/work chrome and exact composer/caret proof. + // eslint-disable-next-line no-control-regex + const plain = stripAnsi(screen.replace(/\x1b\[\d+;\d+H/g, '\n')); + const working = this._workingLinePattern(); + working.lastIndex = 0; + if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; + const composer = readGuardedCodexSnapshot(screen); + return composer === null + ? null + : { + generation: this.inspectionGeneration, + revision: this._guardedInputRevision, + composerSha: promptSha256(composer), + }; + } + /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ + async stageGuardedInput(message: string): Promise { + if (/[\r\n]/.test(message)) return false; + if (this._guardedPendingWrites || this._guardedSubmitInFlight) { + this._guardedInputRevision++; + return false; + } + this._submitVerifier?.cancel(); + return this.writeViaMux(message, { fromUser: true }); + } + /** One conditional Enter. An ambiguous acknowledgement is never replayed. */ + async submitGuardedInput(expected: GuardedInputSnapshot): Promise { + const matches = (current: GuardedInputSnapshot | null): boolean => + !!current && + current.generation === expected.generation && + current.revision === expected.revision && + current.composerSha === expected.composerSha; + if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false; + this._guardedSubmitInFlight = true; + let dispatched = false; + try { + const sent = await this._mux.sendInput(this.id, '\r', { + beforeDispatch: () => { + if (!matches(this._readGuardedInputSnapshot(true))) return false; + this._guardedInputRevision++; + dispatched = true; + return true; + }, + }); + if (sent && dispatched) this._emitSubmittedPrompt(this._trackSubmit('\r', { fromUser: true })); + return sent && dispatched; + } finally { + this._guardedSubmitInFlight = false; + } + } + /** Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. */ + async performAuxiliaryInput(operation: () => Promise): Promise { + this._guardedInputRevision++; + if (this._guardedSubmitInFlight) throw new Error('Guarded submission in progress'); + this._guardedPendingWrites++; + try { + await operation(); + } finally { + this._guardedPendingWrites--; + } + } write(data: string, options: SessionWriteOptions = {}): boolean { + this._observeGuardedKeyboardInput(data); + if (this._guardedSubmitInFlight) return false; const submittedPrompt = this._trackSubmit(data, options); if (!this.ptyProcess) return false; this.ptyProcess.write(data); @@ -4782,6 +4896,7 @@ export class Session extends EventEmitter { * separator. Reports submissions like a write would (a line feed never is one). */ trackUserInput(data: string): void { + this._observeGuardedKeyboardInput(data); if (!this._acceptsPrompts) return; this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data)); } @@ -4803,6 +4918,7 @@ export class Session extends EventEmitter { * their prompt must count as real and alert normally. */ markPromptSubmitted(prompt?: string): void { + this._guardedInputRevision++; if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0; this._stampSubmit(); } @@ -4898,22 +5014,29 @@ export class Session extends EventEmitter { * ``` */ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise { - const submittedPrompt = this._trackSubmit(data, options); - if (this._mux && this._muxSession) { - const sent = await this._mux.sendInput(this.id, data); - if (sent) { - this._emitSubmittedPrompt(submittedPrompt); - this._verifySubmitted(data); + this._observeGuardedKeyboardInput(data); + if (this._guardedSubmitInFlight) return false; + this._guardedPendingWrites++; + try { + const submittedPrompt = this._trackSubmit(data, options); + if (this._mux && this._muxSession) { + const sent = await this._mux.sendInput(this.id, data); + if (sent) { + this._emitSubmittedPrompt(submittedPrompt); + this._verifySubmitted(data); + } + return sent; } - return sent; + // Fallback to PTY write + if (this.ptyProcess) { + this.ptyProcess.write(data); + this._emitSubmittedPrompt(submittedPrompt); + return true; + } + return false; + } finally { + this._guardedPendingWrites--; } - // Fallback to PTY write - if (this.ptyProcess) { - this.ptyProcess.write(data); - this._emitSubmittedPrompt(submittedPrompt); - return true; - } - return false; } /** @@ -4941,7 +5064,10 @@ export class Session extends EventEmitter { this._isStopped || !this._mux || !this._muxSession ? null : this._mux.capturePaneText?.(this._muxSession.muxName), - sendEnter: () => this._mux?.sendInput(this.id, '\r'), + sendEnter: () => { + if (this._guardedSubmitInFlight) this._submitVerifier?.cancel(); + return this.writeViaMux('\r'); + }, // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. // Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back // to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and @@ -4970,6 +5096,7 @@ export class Session extends EventEmitter { * `resize()` skipped a real resize that happened to match them. */ private _notePtySpawnGeometry(cols: number, rows: number): void { + this._guardedPaneRevision++; this._ptyCols = cols; this._ptyRows = rows; } @@ -5092,6 +5219,7 @@ export class Session extends EventEmitter { } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { + this._guardedPaneRevision++; this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { @@ -5108,6 +5236,10 @@ export class Session extends EventEmitter { // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise { + if (this._guardedSubmitInFlight) { + this._guardedInputRevision++; + throw new Error('Guarded submission in progress'); + } this._status = 'busy'; this._markActivity(true); this.runPrompt(input).catch((err) => { diff --git a/src/terminal-input-classifier.ts b/src/terminal-input-classifier.ts new file mode 100644 index 00000000..34ea2e85 --- /dev/null +++ b/src/terminal-input-classifier.ts @@ -0,0 +1,62 @@ +/** @fileoverview Distinguish complete terminal focus/mouse reports from keyboard-bearing input. */ +const FOCUS_REPORT_LENGTH = 3; +const X10_REPORT_LENGTH = 6; + +function sgrMouseReportLength(rest: string): number { + if (!rest.startsWith('\x1b[<')) return 0; + + let offset = 3; + for (let field = 0; field < 3; field += 1) { + const digitStart = offset; + while (offset < rest.length && rest.charCodeAt(offset) >= 48 && rest.charCodeAt(offset) <= 57) { + offset += 1; + } + if (offset === digitStart) return 0; + + if (field < 2) { + if (rest[offset] !== ';') return 0; + offset += 1; + } + } + + return rest[offset] === 'M' || rest[offset] === 'm' ? offset + 1 : 0; +} + +function x10MouseReportLength(rest: string): number { + if (!rest.startsWith('\x1b[M') || rest.length < X10_REPORT_LENGTH) return 0; + + const button = rest.charCodeAt(3); + const x = rest.charCodeAt(4); + const y = rest.charCodeAt(5); + if (button < 32 || button > 255 || x < 33 || x > 255 || y < 33 || y > 255) return 0; + + return X10_REPORT_LENGTH; +} + +function terminalReportLengthAt(data: string, offset: number): number { + const rest = data.slice(offset); + if (rest.startsWith('\x1b[I') || rest.startsWith('\x1b[O')) return FOCUS_REPORT_LENGTH; + + const sgrLength = sgrMouseReportLength(rest); + if (sgrLength > 0) return sgrLength; + + return x10MouseReportLength(rest); +} + +/** + * Whether terminal input contains any keyboard-bearing bytes. + * + * Returns false only when every byte is consumed as an allowlisted mouse or + * focus report. Unknown, malformed, mixed, and empty input fails closed. + */ +export function isKeyboardTerminalInput(data: string): boolean { + if (data.length === 0) return true; + + let offset = 0; + while (offset < data.length) { + const reportLength = terminalReportLengthAt(data, offset); + if (reportLength === 0) return true; + offset += reportLength; + } + return false; +} diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 15888850..0c52cf39 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3983,7 +3983,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ - async sendInput(sessionId: string, input: string): Promise { + async sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise { + if (options?.beforeDispatch && input !== '\r') return false; const session = this.sessions.get(sessionId); if (!session) { console.error( @@ -3996,7 +3997,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // inputRunner() so a test subclass can record what WOULD reach tmux. const run = this.inputRunner(); if (!run) { - return true; + return options?.beforeDispatch?.() ?? true; } console.log( @@ -4050,7 +4051,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else if (hasCarriageReturn) { - // Enter only + // Enter only: no await between the guard and dispatch. + if (options?.beforeDispatch && !options.beforeDispatch()) return false; await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts new file mode 100644 index 00000000..a3892b34 --- /dev/null +++ b/src/web/routes/agent-session-routes.ts @@ -0,0 +1,148 @@ +/** + * @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints. + * Ownership is checked before any disk or pane read. Native evidence comes only + * from strict conversation attribution; all other modes get labeled snapshots. + */ +import type { FastifyInstance } from 'fastify'; +import type { Session } from '../../session.js'; +import type { SessionPort, InfraPort } from '../ports/index.js'; +import { getCli } from '../../config/cli-registry/registry.js'; +import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; +import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; +import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { findSessionOrFail, getAuthUser, parseBody } from '../route-helpers.js'; +import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; + +export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { + const reader = new InspectionReader(); + const stages = new GuardedStageStore(); + app.addHook('onClose', async () => { + reader.clear(); + stages.clear(); + }); + const generation = (session: Session) => session.inspectionGeneration; + const target = (session: Session): GuardedInputTarget => ({ + id: session.id, + snapshot: () => { + if (ctx.sessions.get(session.id) !== session) return null; + const snapshot = session.guardedInputSnapshot?.(); + return snapshot ? { ...snapshot, generation: generation(session) } : null; + }, + write: (text) => session.stageGuardedInput(text), + submit: (expected) => session.submitGuardedInput(expected), + }); + app.get('/api/sessions/:id/inspect', async (req, reply) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id, req); + const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query); + const initialGeneration = generation(session); + const identity = { sessionId: session.id, mode: session.mode }; + const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() }; + try { + if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) { + const conversation = await findExactCodexConversation( + { + codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir), + sessionId: session.id, + workingDir: session.workingDir, + resumeSessionId: session.codexConfig?.resumeSessionId, + }, + { maxEntries: 8192, maxBytes: 1048576 } + ); + if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session) + throw new InspectionConflict('Session changed during inspection'); + if (conversation) { + const result = await reader.read( + conversation.path, + { + owner: getAuthUser(req).username, + sessionId: id, + generation: initialGeneration, + conversationId: conversation.id, + }, + maxBytes, + since + ); + if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session) + throw new InspectionConflict('Session changed during inspection'); + return { + success: true, + data: { + source: 'codex-jsonl', + identity: { ...identity, conversationId: conversation.id }, + activity, + incremental: true, + maxBytes, + ...result, + }, + }; + } + } + if (since) + throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot'); + const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null; + const evidence = frame ?? session.terminalBuffer; + return { + success: true, + data: { + source: 'pane-snapshot', + identity, + activity, + incremental: false, + cursor: null, + maxBytes, + evidence: boundedUtf8Tail(evidence, maxBytes), + truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes, + }, + }; + } catch (error) { + if (error instanceof InspectionConflict) + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message)); + // Native paths and arbitrary IO errors must never escape into the response. + return reply + .code(409) + .send(createErrorResponse(ApiErrorCode.CONFLICT, 'Inspection source unavailable; retry without a cursor')); + } + }); + app.post('/api/sessions/:id/stage', async (req, reply) => { + const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); + const { message } = parseBody(AgentStageSchema, req.body); + try { + const receipt = await stages.stage(target(session), getAuthUser(req).username, message); + return { success: true, data: receipt }; + } catch (error) { + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + error instanceof GuardedInputConflict + ? error.message + : 'Stage delivery failed; inspect the pane before retrying' + ) + ); + } + }); + app.post('/api/sessions/:id/submit', async (req, reply) => { + const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); + const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body); + try { + return { + success: true, + data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha), + }; + } catch (error) { + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + error instanceof GuardedInputConflict + ? error.message + : 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane' + ) + ); + } + }); +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7569a4dc..507ef7ab 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,6 +5,7 @@ */ import { claudeProjectDirName } from '../../case-rename.js'; +import { registerAgentSessionRoutes } from './agent-session-routes.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; @@ -1072,6 +1073,8 @@ export function registerSessionRoutes( // ========== Logout ========== + registerAgentSessionRoutes(app, ctx); + app.post('/api/logout', async (req, reply) => { // Invalidate server-side session token (not just the browser cookie) const sessionToken = req.cookies[AUTH_COOKIE_NAME]; @@ -2428,17 +2431,20 @@ export function registerSessionRoutes( try { // Route through the dedicated Codeman socket — bare `tmux` would target the // user's default server and never find this session (same #80 regression class). - await new Promise((resolve, reject) => { - execFile( - 'tmux', - ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], - { timeout: 5000 }, - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await session.performAuxiliaryInput( + () => + new Promise((resolve, reject) => { + execFile( + 'tmux', + ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], + { timeout: 5000 }, + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }) + ); } catch (err) { console.error('[Server] send-key failed:', err); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 8336a2e3..295441d2 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2410,3 +2410,15 @@ export const CustomModelSelectionSchema = z.union([ }), z.object({ clear: z.literal(true) }), ]); + +/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */ +export const AgentInspectQuerySchema = z + .object({ + maxBytes: z.coerce.number().int().min(1).max(65536).default(8192), + since: z.string().uuid().optional(), + }) + .strict(); +export const AgentStageSchema = z.object({ message: z.string().min(1).max(8192) }).strict(); +export const AgentSubmitSchema = z + .object({ stageId: z.string().uuid(), confirmSha: z.string().regex(/^[a-f0-9]{64}$/) }) + .strict(); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts new file mode 100644 index 00000000..b568b3d5 --- /dev/null +++ b/test/agent-codex-identity.test.ts @@ -0,0 +1,119 @@ +/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */ +import { afterEach, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { findExactCodexConversation, resolveEffectiveCodexHome } from '../src/agent-codex-identity.js'; +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const A = '11111111-1111-4111-8111-111111111111'; +const B = '22222222-2222-4222-8222-222222222222'; +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'agent-attribution-')); + roots.push(root); + const home = join(root, 'home'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const options = { codexHome: home, sessionId: 'managed', workingDir: root }; + const put = async (id: string, payload: object = {}, text?: string) => { + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + text ?? + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + }; + return { root, home, options, put }; +} +it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => { + const f = await fixture(); + const path = await f.put(A); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + await f.put(A, { id: B }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('never chooses newest cwd files or an ambiguous managed originator', async () => { + const f = await fixture(); + await f.put(A, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + await f.put(A); + await f.put(B); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('allows an explicit resume identity but still requires native metadata and the workspace', async () => { + const f = await fixture(); + const path = await f.put(A, { originator: 'original-launch' }); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path }); + await f.put(B); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined(); + await f.put(A, { cwd: f.home }); + expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined(); +}); +it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => { + const f = await fixture(); + const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }); + await f.put( + A, + {}, + kind === 'partial' + ? row + : kind === 'duplicate' + ? row + '\n' + row + '\n' + : row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n' + ); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); +}); +it('enforces traversal/read caps and ignores symlinked directories', async () => { + const f = await fixture(); + await f.put(A); + expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined(); + expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined(); + await mkdir(join(f.root, 'elsewhere')); + await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias')); + expect((await findExactCodexConversation(f.options))?.id).toBe(A); +}); +it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => { + expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom'); + expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process'); + expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex'); +}); + +it.each([ + ['trailing-space', 'codex-home ', 'codex-home'], + ['whitespace-only', ' ', '.codex'], +])( + 'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper', + async (_label, configured, wrongHome) => { + const { Session } = await import('../src/session.js'); + const f = await fixture(); + const expectedHome = join(f.root, configured); + const siblingHome = join(f.root, wrongHome); + const record = + JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n'; + for (const home of [expectedHome, siblingHome]) { + await mkdir(join(home, 'sessions'), { recursive: true }); + await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record); + } + const session = new Session({ + id: 'managed', + workingDir: f.root, + mode: 'codex', + envOverrides: { CODEX_HOME: configured }, + }); + try { + const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root }); + expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({ + id: A, + path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`), + }); + expect(session.getCodexHomeOverride()).toBe(configured); + } finally { + await session.stop(false); + } + } +); diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts new file mode 100644 index 00000000..2a1f4270 --- /dev/null +++ b/test/agent-inspection.test.ts @@ -0,0 +1,161 @@ +/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */ +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { InspectionReader } from '../src/agent-inspection.js'; +let dir: string; +let file: string; +let reader: InspectionReader; +const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' }; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'inspect-')); + file = join(dir, 'rollout.jsonl'); + reader = new InspectionReader(); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); +it('returns complete UTF8 records within the byte budget and resumes exactly', async () => { + const row = JSON.stringify({ text: 'é😀' }) + '\n'; + await writeFile(file, row + row); + const first = await reader.read(file, scope, Buffer.byteLength(row)); + expect(first.evidence).toBe(row); + expect(first.truncated).toBe(true); + await appendFile(file, row); + const next = await reader.read(file, scope, 1024, first.cursor); + expect(next.evidence).toBe(row); + expect(next.truncated).toBe(false); +}); +it('does not consume partial final records', async () => { + await writeFile(file, '{"text":"hello'); + const first = await reader.read(file, scope, 100); + expect(first.evidence).toBe(''); + await appendFile(file, '"}\n'); + const next = await reader.read(file, scope, 100, first.cursor); + expect(next.evidence).toBe('{"text":"hello"}\n'); +}); +it('reports an oversized incremental record without splitting it or advancing past it', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n'); + const result = await reader.read(file, scope, 100, initial.cursor); + expect(result.evidence).toBe(''); + expect(result.truncated).toBe(true); + expect(result.blockedReason).toBe('record-exceeds-scan-limit'); + expect(result.scannedBytes).toBeLessThanOrEqual(262144); +}); +it('rejects malformed, cross-owner and changed-session cursors', async () => { + await writeFile(file, '{}\n'); + const first = await reader.read(file, scope, 100); + for (const changed of [ + { ...scope, owner: 'bob' }, + { ...scope, sessionId: 'other' }, + { ...scope, generation: 'new' }, + { ...scope, conversationId: 'new' }, + ]) { + await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i); + } + await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i); +}); +it('rejects truncation, replacement and rewritten consumed evidence', async () => { + await writeFile(file, '{}\n{}\n'); + const first = await reader.read(file, scope, 100); + await writeFile(file, '{}\n'); + await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i); + await writeFile(file, '{}\n{}\n'); + const second = await reader.read(file, scope, 100); + await writeFile(file, '[]\n[]\n'); + await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i); + const third = await reader.read(file, scope, 100); + await rename(file, join(dir, 'old')); + await writeFile(file, '[]\n[]\n'); + await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i); +}); +it('expires cursors and bounds retained state', async () => { + await writeFile(file, '{}\n'); + let now = 0; + reader = new InspectionReader(() => now, 2, 1000); + const first = await reader.read(file, scope, 100); + await reader.read(file, scope, 100); + await reader.read(file, scope, 100); + await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i); + const last = await reader.read(file, scope, 100); + now = 1001; + await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i); +}); +it('bounds strict attribution itself and refuses an incomplete inventory', async () => { + const { mkdir } = await import('node:fs/promises'); + const { findExactCodexConversation } = await import('../src/agent-codex-identity.js'); + const home = join(dir, 'codex'); + const sessions = join(home, 'sessions'); + await mkdir(sessions, { recursive: true }); + const id = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(sessions, `rollout-${id}.jsonl`), + JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n' + ); + await writeFile(join(sessions, 'other.txt'), 'other'); + const options = { codexHome: home, sessionId: 's', workingDir: dir }; + expect((await findExactCodexConversation(options))?.id).toBe(id); + expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined(); +}); + +it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => { + const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n'; + const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n'; + await writeFile(file, old + latest); + const result = await reader.read(file, scope, Buffer.byteLength(latest)); + expect(result.evidence).toBe(latest); + expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old)); + expect(result.truncated).toBe(true); + await appendFile(file, '{"assistant":"next"}\n'); + expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n'); +}); +it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => { + const { mkdir } = await import('node:fs/promises'); + const { findExactCodexConversation } = await import('../src/agent-codex-identity.js'); + const home = join(dir, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const id = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(home, 'sessions', `rollout-${id}.jsonl`), + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) }, + }) + '\n' + ); + expect( + ( + await findExactCodexConversation( + { codexHome: home, sessionId: 's', workingDir: dir }, + { maxEntries: 8192, maxBytes: 1048576 } + ) + )?.id + ).toBe(id); +}); +it('rejects same-size rewrites outside the bounded cursor anchors', async () => { + const { utimes } = await import('node:fs/promises'); + const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n'; + await writeFile(file, row); + await utimes(file, 1, 1); + const first = await reader.read(file, scope, 30000); + await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001)); + await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i); +}); +it('keeps cursor capacity bounded across concurrent reads', async () => { + await writeFile(file, '{}\n'); + reader = new InspectionReader(Date.now, 2); + const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100))); + const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor))); + expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2); +}); +it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => { + const row = Buffer.from('{"text":"😀"}\n'); + const split = row.indexOf(Buffer.from('😀')) + 2; + await writeFile(file, row.subarray(0, split)); + const first = await reader.read(file, scope, 100); + expect(first.evidence).toBe(''); + await appendFile(file, row.subarray(split)); + expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8')); +}); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts new file mode 100644 index 00000000..f0bb1328 --- /dev/null +++ b/test/agent-staging-session.test.ts @@ -0,0 +1,329 @@ +/** @fileoverview Guarded input observes all production write and lifecycle primitives. */ +import { expect, it, vi } from 'vitest'; +import { formatPaneSnapshot } from '../src/tmux-manager.js'; +import { Session } from '../src/session.js'; +import { promptSha256 } from '../src/agent-staging.js'; +it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => { + let screen = '› \n GPT-6-Luna default · ~/case\n'; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(screen.trimEnd().split('\n'), { + cols: 100, + rows: 20, + cursorX: screen.startsWith('› hello') ? 7 : 2, + cursorY: 0, + }), + sendInput: async () => true, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 }, + } as unknown as ConstructorParameters[0]); + const before = session.guardedInputSnapshot(); + expect(before?.composerSha).toBe(promptSha256('')); + await session.writeViaMux('hello'); + screen = '› hello\n GPT-6-Luna default · ~/case\n'; + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1); + session.trackUserInput('x'); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2); + await session.performAuxiliaryInput(async () => {}); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3); + const generation = session.inspectionGeneration; + await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle( + async () => { + expect(session.guardedInputSnapshot()).toBeNull(); + } + ); + expect(session.inspectionGeneration).not.toBe(generation); + session.markPromptSubmitted(); + expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4); + await session.stop(false); + expect(session.guardedInputSnapshot()).toBeNull(); +}); + +it('checks conditional Enter at the dispatch boundary and rejects concurrent browser input', async () => { + let dispatch: (() => void) | undefined; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + await new Promise((resolve) => { + dispatch = resolve; + }); + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + const expected = session.guardedInputSnapshot()!; + const sending = session.submitGuardedInput(expected); + expect(await session.writeViaMux('other')).toBe(false); + dispatch!(); + expect(await sending).toBe(false); + expect(writes).toEqual([]); + const next = session.submitGuardedInput(session.guardedInputSnapshot()!); + dispatch!(); + expect(await next).toBe(true); + expect(writes).toEqual(['\r']); + const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!); + await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle( + async () => {} + ); + dispatch!(); + expect(await restarting).toBe(false); + expect(writes).toEqual(['\r']); + const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!); + await expect( + session.performAuxiliaryInput(async () => { + throw new Error('must not execute'); + }) + ).rejects.toThrow('Guarded submission'); + dispatch!(); + expect(await auxiliary).toBe(false); + expect(writes).toEqual(['\r']); + } finally { + await session.stop(false); + } +}); + +it('cancels an older automatic submit verifier before staging an identical prompt', async () => { + vi.useFakeTimers(); + let composer = ''; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }), + capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`, + sendInput: async (_id: string, text: string) => { + writes.push(text); + composer += text; + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + session.verifySubmitted('hello'); + expect(await session.stageGuardedInput('hello')).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(writes).toEqual(['hello']); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('routes automatic verifier Enter through the shared guarded input boundary', async () => { + vi.useFakeTimers(); + let release!: () => void; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case', + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch) { + await new Promise((resolve) => { + release = resolve; + }); + if (!options.beforeDispatch()) return false; + } + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + session.verifySubmitted('hello'); + const submission = session.submitGuardedInput(session.guardedInputSnapshot()!); + await vi.advanceTimersByTimeAsync(2000); + release(); + expect(await submission).toBe(false); + await vi.advanceTimersByTimeAsync(60000); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); + +it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => { + let release!: () => void; + const writes: string[] = []; + const mux = { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + await new Promise((resolve) => { + release = resolve; + }); + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + return true; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const access = session as unknown as { ptyProcess: { write(data: string): void } | null }; + try { + const submission = session.submitGuardedInput(session.guardedInputSnapshot()!); + // This also prevents a real spawn if the lease guard regresses. + access.ptyProcess = { + write: () => { + throw new Error('must not write'); + }, + }; + await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission'); + await expect(session.sendInput('other')).rejects.toThrow('Guarded submission'); + access.ptyProcess = null; + release(); + expect(await submission).toBe(false); + expect(writes).toEqual([]); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('counts an ordinary transport pending window before any awaited acknowledgement', async () => { + let release!: () => void; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + sendInput: async () => { + await new Promise((resolve) => { + release = resolve; + }); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + const writing = session.writeViaMux('hello'); + expect(session.guardedInputPendingWrites).toBe(1); + release(); + await writing; + expect(session.guardedInputPendingWrites).toBe(0); + } finally { + release?.(); + await session.stop(false); + } +}); + +it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => { + let working = false; + const capture = vi.fn(() => + formatPaneSnapshot( + [working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'], + { cols: 100, rows: 20, cursorX: 7, cursorY: 1 } + ) + ); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: capture, + capturePaneText: () => { + throw Error('must use same frame'); + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked'); + expect(session.guardedInputSnapshot()).not.toBeNull(); + expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n'); + working = true; + expect(session.guardedInputSnapshot()).toBeNull(); + blocked.mockReturnValue(true); + working = false; + expect(session.guardedInputSnapshot()).toBeNull(); + } finally { + await session.stop(false); + } +}); + +it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }), + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const access = session as unknown as { + ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null; + }; + access.ptyProcess = { + write: (text) => { + writes.push(text); + }, + resize: () => {}, + }; + try { + const before = session.guardedInputSnapshot()!; + for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) { + expect(session.write(data, { fromUser: true })).toBe(true); + expect(writes.at(-1)).toBe(data); + expect(session.guardedInputSnapshot()?.revision).toBe(before.revision); + } + session.write('\x1b[Oedit'); + expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1); + const generation = session.inspectionGeneration; + session.owner = 'other'; + session.owner = undefined; + expect(session.inspectionGeneration).not.toBe(generation); + const priorResize = session.inspectionGeneration; + session.resize(80, 24); + expect(session.inspectionGeneration).not.toBe(priorResize); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts new file mode 100644 index 00000000..1cd920a6 --- /dev/null +++ b/test/agent-staging.test.ts @@ -0,0 +1,153 @@ +/** @fileoverview Guarded staging one-shot and content-verification safety. */ +import { beforeEach, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; +import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; +const sha = (s: string) => createHash('sha256').update(s).digest('hex'); +let store: GuardedStageStore; +let time: number; +let generation: string; +let revision: number; +let composer: string | null; +let writes: string[]; +let fail: boolean; +let target: GuardedInputTarget; +beforeEach(() => { + time = 0; + generation = 'pane-1'; + revision = 0; + composer = ''; + writes = []; + fail = false; + store = new GuardedStageStore(() => time, 2, 1000); + target = { + id: 's', + snapshot: () => (composer === null ? null : { generation, revision, composerSha: sha(composer) }), + submit: async () => target.write('\r'), + write: async (text) => { + writes.push(text); + revision++; + if (fail) return false; + composer = text === '\r' ? '' : composer + text; + return true; + }, + }; +}); +it('stages without Enter, stores only a hash, and submits exactly once', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(writes).toEqual(['hello']); + expect(receipt.sha256).toBe(sha('hello')); + expect(JSON.stringify(receipt)).not.toContain('hello'); + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(writes).toEqual(['hello', '\r']); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); +}); +it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => { + for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800']) + await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + composer = 'existing'; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + composer = null; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + expect(writes).toEqual([]); +}); +it('refuses altered composer including whitespace-only differences', async () => { + const receipt = await store.stage(target, 'alice', 'hello world'); + composer = 'helloworld'; + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + expect(writes).toEqual(['hello world']); +}); +it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => { + for (const change of [ + () => { + generation = 'pane-2'; + }, + () => { + revision++; + }, + () => { + time += 1001; + }, + ]) { + composer = ''; + const receipt = await store.stage(target, 'alice', 'hello'); + change(); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + } + composer = ''; + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow(); + await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); + await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow(); + expect(writes.filter((s) => s === '\r')).toHaveLength(0); +}); +it('consumes before write so concurrent and failed deliveries never replay Enter', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + fail = true; + const results = await Promise.allSettled([ + store.submit(target, 'alice', receipt.stageId, receipt.sha256), + store.submit(target, 'alice', receipt.stageId, receipt.sha256), + ]); + expect(results.every((r) => r.status === 'rejected')).toBe(true); + expect(writes).toEqual(['hello', '\r']); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(); +}); +it('does not issue a receipt on failed staging or intervening input during its write', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(); + fail = false; + composer = ''; + const altered = { + ...target, + write: async (s: string) => { + await target.write(s); + revision++; + return true; + }, + }; + await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow(); +}); + +it('reads exact composer spaces and refuses wrapped or modal frames', async () => { + const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); + expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world'); + expect( + readGuardedCodexComposer( + '› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts' + ) + ).toBe(''); + expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull(); + expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull(); + expect(readGuardedCodexComposer('› hello\n')).toBeNull(); +}); +it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => { + const { readGuardedCodexSnapshot } = await import('../src/agent-staging.js'); + const { formatPaneSnapshot } = await import('../src/tmux-manager.js'); + const frame = (text: string, x: number, y = 0) => + formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y }); + expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello'); + expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull(); + expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull(); + expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull(); + expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull(); + expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好'); + expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello'); +}); +it('reserves receipt capacity before concurrent stages type', async () => { + store = new GuardedStageStore(() => time, 1, 1000); + let release: (() => void) | undefined; + const firstTarget = { + ...target, + write: async (text: string) => { + await new Promise((resolve) => { + release = resolve; + }); + return target.write(text); + }, + }; + const first = store.stage(firstTarget, 'alice', 'hello'); + const otherTarget = { ...target, id: 'other' }; + await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i); + expect(writes).toEqual([]); + release!(); + await first; +}); diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts new file mode 100644 index 00000000..a0b023a7 --- /dev/null +++ b/test/cli-agent-inspection.test.ts @@ -0,0 +1,94 @@ +/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */ +import { expect, it } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + agentInspect, + agentStage, + agentSubmit, + registerAgentCommands, + type AgentDeps, + type RequestOptions, +} from '../src/cli-agent.js'; +import { Command } from 'commander'; +const SELF = '11111111-1111-4111-8111-111111111111'; +const OTHER = '22222222-2222-4222-8222-222222222222'; +function deps() { + const calls: RequestOptions[] = [], + output: string[] = []; + const deps: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json: true, + now: () => 1, + io: { out: (line) => output.push(line), err: () => {} }, + request: async (_ctx, options) => { + calls.push(options); + return { + status: 200, + text: '', + json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } }, + }; + }, + }; + return { deps, calls, output }; +} +it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => { + const d = deps(); + expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0); + expect(d.calls[0]).toMatchObject({ + method: 'GET', + path: `/api/v1/sessions/${OTHER}/inspect`, + query: { maxBytes: 42, since: 'cursor' }, + }); + expect(JSON.parse(d.output[0]).evidence).toBe('hello'); + expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4); + expect(d.calls).toHaveLength(1); +}); +it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const d = deps(); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0); + expect(d.calls[0]).toMatchObject({ + method: 'POST', + path: `/api/v1/sessions/${OTHER}/stage`, + body: { message: 'hello' }, + }); + expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0); + expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) }); + await writeFile(path, 'hello\n'); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + await writeFile(path, Buffer.from([0xff])); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + await writeFile(path, 'x'.repeat(8193)); + expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4); + expect(d.calls).toHaveLength(2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); +it('refuses self-target and malformed submission before transport', async () => { + const d = deps(); + expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4); + expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4); + expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4); + expect(d.calls).toHaveLength(0); +}); +it('registers required file and confirmation options and bounded inspect flags', () => { + const program = new Command(); + registerAgentCommands(program); + const commands = program.commands.find((command) => command.name() === 'agent')!.commands; + for (const name of ['inspect', 'stage', 'submit']) + expect(commands.some((command) => command.name() === name)).toBe(true); + expect( + commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file') + ?.mandatory + ).toBe(true); + expect( + commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha') + ?.mandatory + ).toBe(true); +}); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 665cffbe..fa2b8c5e 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -107,6 +107,10 @@ export class MockSession extends EventEmitter { /** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */ trackUserInput(_data: string): void {} + async performAuxiliaryInput(operation: () => Promise): Promise { + await operation(); + } + private _muxName: string | null = null; constructor(id: string = 'mock-session-id') { diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts new file mode 100644 index 00000000..02cec22b --- /dev/null +++ b/test/routes/agent-session-routes.test.ts @@ -0,0 +1,220 @@ +/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; + +let harness: RouteTestHarness; +afterEach(async () => { + await harness?.app.close(); +}); +describe('agent session routes', () => { + it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` }); + expect(result.statusCode).toBe(200); + const body = result.json(); + expect(body.data.source).toBe('pane-snapshot'); + expect(body.data.cursor).toBeNull(); + expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20); + }); + it('refuses guarded staging for an uncharacterized composer', async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + const result = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'hello' }, + }); + expect(result.statusCode).toBe(409); + }); +}); + +it('checks ownership before inspection, stage and submit', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + try { + harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'user' } }); + const session = [...harness.ctx.sessions.values()][0]; + session.owner = 'bob'; + for (const request of [ + { method: 'GET' as const, url: `/api/sessions/${session.id}/inspect` }, + { method: 'POST' as const, url: `/api/sessions/${session.id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${session.id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]) + expect((await harness.app.inject(request)).statusCode).toBe(404); + expect(session.writeBuffer).toEqual([]); + } finally { + delete process.env.CODEMAN_MULTIUSER; + } +}); +it('validates output budgets and refuses to resume a pane fallback', async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + session.terminalBuffer = '😀é'.repeat(20); + for (const value of ['0', '-1', '65537', '1.5', 'NaN']) + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=${value}` })).statusCode + ).toBe(400); + const result = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=7` })).json(); + expect(result.data.evidence).not.toContain('�'); + expect(Buffer.byteLength(result.data.evidence)).toBeLessThanOrEqual(7); + expect(result.data.truncated).toBe(true); + expect( + ( + await harness.app.inject({ + url: `/api/sessions/${session.id}/inspect?since=11111111-1111-4111-8111-111111111111`, + }) + ).statusCode + ).toBe(409); +}); +it('uses real session writes to stage without Enter and refuses intervening API input', async () => { + const { Session } = await import('../../src/session.js'); + const { formatPaneSnapshot } = await import('../../src/tmux-manager.js'); + harness = await createRouteTestHarness(registerSessionRoutes); + let composer = ''; + const writes: string[] = []; + const session = new Session({ + id: 'guarded-session', + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: () => + formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }), + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + composer += text; + return true; + }, + }, + muxSession: { muxName: 'codeman-test', sessionId: 'guarded-session', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + try { + const stage = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'hello' }, + }); + expect(stage.statusCode).toBe(200); + expect(writes).toEqual(['hello']); + const { stageId, sha256 } = stage.json().data; + const submit = () => + harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/submit`, + payload: { stageId, confirmSha: sha256 }, + }); + expect((await submit()).statusCode).toBe(200); + expect(writes).toEqual(['hello', '\r']); + expect((await submit()).statusCode).toBe(409); + composer = ''; + const next = ( + await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: 'again' }, + }) + ).json().data; + await session.writeViaMux('x'); + composer = 'again'; // Even restoring the visible text cannot erase the input revision. + const stale = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/submit`, + payload: { stageId: next.stageId, confirmSha: next.sha256 }, + }); + expect(stale.statusCode).toBe(409); + expect(writes.filter((s) => s === '\r')).toHaveLength(1); + } finally { + await session.stop(false); + } +}); +it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its owner', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-route-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-session', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const row = + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'; + await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n'); + harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'admin' } }); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + try { + const first = await harness.app.inject({ + url: `/api/sessions/${session.id}/inspect?maxBytes=${Buffer.byteLength(row)}`, + }); + expect(first.statusCode).toBe(200); + const data = first.json().data; + expect(data.source).toBe('codex-jsonl'); + expect(data.evidence).toBe('{\"text\":\"hello\"}\n'); + expect(data.omittedBeforeBytes).toBe(Buffer.byteLength(row)); + expect(data.identity).toEqual({ sessionId: session.id, mode: 'codex', conversationId: id }); + expect(data.cursor).not.toContain(home); + const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` }); + expect(next.json().data.evidence).toBe(''); + session.owner = 'changed-owner'; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(409); + } finally { + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); + +it('refuses a stale launch-resume cursor once a new managed rollout conflicts with it', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-resume-conflict-')); + const home = join(root, 'codex'); + const old = '11111111-1111-4111-8111-111111111111', + fresh = '22222222-2222-4222-8222-222222222222'; + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'resumed-session', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + codexConfig: { resumeSessionId: old }, + }); + const row = (id: string, originator: string) => + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n'; + await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch')); + harness = await createRouteTestHarness(registerSessionRoutes); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + try { + const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; + expect(initial.source).toBe('codex-jsonl'); + await writeFile(join(home, 'sessions', `rollout-${fresh}.jsonl`), row(fresh, `codeman_${session.id}`)); + const stale = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${initial.cursor}` }); + expect(stale.statusCode).toBe(409); + const fallback = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; + expect(fallback.source).toBe('pane-snapshot'); + expect(fallback.cursor).toBeNull(); + } finally { + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/terminal-input-classifier.test.ts b/test/terminal-input-classifier.test.ts new file mode 100644 index 00000000..3f5b6bf8 --- /dev/null +++ b/test/terminal-input-classifier.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { isKeyboardTerminalInput } from '../src/terminal-input-classifier.js'; + +describe('isKeyboardTerminalInput', () => { + it.each([ + ['focus in', '\x1b[I'], + ['focus out', '\x1b[O'], + ['SGR press', '\x1b[<0;10;5M'], + ['SGR release', '\x1b[<0;10;5m'], + ['SGR wheel', '\x1b[<64;999;1000M'], + ['X10 mouse', '\x1b[M !!'], + ['coalesced reports', '\x1b[<0;10;5M\x1b[O\x1b[M !!'], + ])('classifies %s as report-only', (_name, data) => { + expect(isKeyboardTerminalInput(data)).toBe(false); + }); + + it.each([ + ['empty', ''], + ['text', 'continue'], + ['paste', 'git status'], + ['Enter', '\r'], + ['Escape', '\x1b'], + ['arrow', '\x1b[A'], + ['control key', '\x03'], + ['truncated SGR', '\x1b[<0;10;5'], + ['truncated X10', '\x1b[M !'], + ['X10 with a control button byte', '\x1b[M\r!!'], + ['X10 with a control coordinate byte', '\x1b[M \x1b!'], + ['X10-shaped navigation input', '\x1b[M\x1b[A'], + ['X10 with a zero coordinate', '\x1b[M !'], + ['report then text', '\x1b[<0;10;5Mx'], + ['text then report', 'x\x1b[<0;10;5M'], + ['report then Enter', '\x1b[O\r'], + ])('classifies %s as keyboard-bearing', (_name, data) => { + expect(isKeyboardTerminalInput(data)).toBe(true); + }); +}); diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index e0a3fa88..c131c4e5 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -229,6 +229,21 @@ describe('sendInput line breaks', () => { return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string; } + it('checks a conditional Enter before the actual inputRunner command', async () => { + const manager = managerWithSession(); + try { + expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => false })).toBe(false); + expect(manager.ran).toEqual([]); + expect(await manager.sendInput('s1', '\r', { beforeDispatch: () => true })).toBe(true); + expect(manager.ran).toEqual([expect.stringMatching(/ Enter$/)]); + manager.ran = []; + expect(await manager.sendInput('s1', 'draft\r', { beforeDispatch: () => true })).toBe(false); + expect(manager.ran).toEqual([]); + } finally { + manager.destroy(); + } + }); + it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => { const manager = managerWithSession(); expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true); From 4c2f050a96dbd4cf4c11bd2fd3e76bdc4ba59d02 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 16:22:46 -0400 Subject: [PATCH 2/9] fix(agent): address inspection and staging review feedback --- .changeset/bounded-agent-coordination.md | 2 + CLAUDE.md | 5 +- docs/api-reference.md | 19 +- docs/architecture-invariants.md | 27 +++ .../reference/inspection-and-staging.md | 50 ++++- .../codeman/skills/codeman/reference/verbs.md | 6 +- .../reference/inspection-and-staging.md | 50 ++++- skills/codeman/reference/verbs.md | 6 +- src/agent-codex-identity.ts | 34 ++-- src/agent-inspection.ts | 2 +- src/agent-staging.ts | 34 ++-- src/mux-interface.ts | 4 +- src/session.ts | 60 +++--- src/web/routes/agent-session-routes.ts | 2 +- src/web/routes/index.ts | 2 + src/web/routes/session-routes.ts | 3 - src/web/server.ts | 2 + test/agent-codex-identity.test.ts | 37 ++++ test/agent-inspection.test.ts | 19 ++ test/agent-staging-session.test.ts | 173 ++++++++++++++++-- test/agent-staging.test.ts | 19 +- test/routes/agent-session-routes.test.ts | 39 ++-- test/tmux-submit-paste.test.ts | 10 + test/webserver-bound-port.test.ts | 28 +++ 24 files changed, 517 insertions(+), 116 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 1122ed67..1239817a 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -5,3 +5,5 @@ Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. + +SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/CLAUDE.md b/CLAUDE.md index bd2594ac..683084f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,9 +129,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked +`agent-session-routes.ts` module; the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. +**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) diff --git a/docs/api-reference.md b/docs/api-reference.md index dd538bd3..ca04f1cf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -608,8 +608,13 @@ The initial read selects recent complete records; `omittedBeforeBytes` explicitl labels its tail omission. Cursor reads consume only complete appended records, keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution -separately caps at 8192 directory entries, eight directory levels and 1 MiB of -metadata reads. Oversized/unfinished records report `blockedReason`; incremental +separately caps at 8192 directory entries, eight directory levels, 8 MiB of +negative-filter prefix reads and 1 MiB of candidate metadata reads (including +final identity verification). Unreachable workspace/home/metadata directories +are probed with a bounded past-cap probe before canonical reads and remain +unattributed. A full incremental page can be continued with its cursor; only a +first pending record too large for the budget reports an output-budget block. +Oversized/unfinished records report `blockedReason`; incremental reads do not silently skip them. Other modes, remote/container sessions, and unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, `cursor:null`; these snapshots can repeat evidence. @@ -622,7 +627,8 @@ conversation identity and do not use these observations as proof of the active thread, task completion or authorization. Opaque server-side cursors bind caller, session, pane generation, conversation, -opened-file identity, byte offset and bounded content anchors. At most 1024 live +opened-file identity, byte offset and bounded content anchors. Terminal resizes +do not change this inspection scope. At most 1024 live cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign cursors or loss of the attributed source return `409 CONFLICT`. No transcript @@ -633,7 +639,10 @@ Guarded input initially requires a local characterized Codex composer, one unwrapped visible line, a recognized footer and a cursor at its exact end. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing -whitespace (the existing mux transport trims it). Its two-minute receipt stores +whitespace (the existing mux transport trims it). Before typing, it checks the +actual captured pane width and rendered composer indentation, refusing any +message that would reach the right edge or wrap. Every declared Codex effort +word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), never persisted prompt content. A receipt acknowledges typing; a delayed screen paint can still make submit refuse until exact verification becomes possible. @@ -644,7 +653,7 @@ input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, -confirmation hash, expiry, lifecycle/input revisions and exact composer/caret, +confirmation hash, expiry, lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb6..140d394d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1260,3 +1260,30 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all. + +## Agent inspection and guarded submission + +`agent-session-routes.ts` is registered by `WebServer` with the other route +modules. Ownership is checked before transcript/pane/receipt lookup. Inspection +uses strict native metadata attribution, bounded traversal, separate prefix and +candidate-metadata budgets, bounded path probes before canonical reads, and +complete UTF8 JSONL records. A full page advances its cursor without claiming a +blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane +geometry is excluded so changing devices does not invalidate transcript paging. + +Staging requires a fresh, known local Codex composer/caret capture. The footer +uses the shared `CODEX_REASONING_EFFORTS` vocabulary. Before any write, available +columns are calculated from actual captured geometry and the rendered prefix, +including indentation; reaching the last column or wrapping is refused. Stage +cancels older verifier retries and sends no Enter. Receipts contain hashes and +bounded ephemeral identities only, expire, and are consumed before awaited +submission, including failures. + +Submission additionally binds input and geometry revisions and rechecks the +exact composer/caret at the conditional Enter boundary. The route must retain +the Session snapshot's staging generation rather than replacing it with the +inspection generation. Managed input and auxiliary named keys participate in the +same pending/lease boundary. Complete focus/mouse reports and empty writes are +not edits. Never retry ambiguous Enter delivery or clear an unverified composer. +The ordinary SubmitVerifier uses the shared write boundary, so each re-press +updates `lastSubmitAt` just like any other Enter. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index 394f6be4..bb15a5e6 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -1,9 +1,8 @@ # Bounded inspection and guarded submission -Use these commands inside a Codeman session. They use its authenticated HTTP API; -no direct tmux access, second transport, or session-bridge installation is required. -They supplement `send`, `wait` and `read`. They do not establish full parity with -session-bridge or retire that skill. +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. ## Inspect recent evidence @@ -31,7 +30,8 @@ falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator thread may leave launch metadata unchanged. Check `identity.conversationId`; these reads do not provide authoritative current-thread tracking. -Cursors are opaque, scoped to the requesting identity, session, pane generation +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server restart. At most 1024 are retained; capacity eviction can invalidate an older cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor @@ -43,7 +43,8 @@ never split or silently skip one. Increase the byte budget for `record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, use `read --tail` for a labeled terminal view or start a fresh tail baseline. Inspect scans at most 256 KiB of record data plus bounded generation anchors. -Attribution separately caps directory entries and metadata bytes. Evidence and +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and activity are observations, not proof of task completion or authorization. ## Stage, inspect, then submit once @@ -58,7 +59,9 @@ codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --jso Stage types the file contents **without Enter**. The file must be regular, UTF8, one printable line, at most 8192 bytes, with no control characters or trailing whitespace/newline. It initially supports a local Codex composer with a recognized -footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, wrapped, remote/container and shell composers are refused. A customized footer or unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. @@ -70,7 +73,7 @@ requires the fully painted exact composer. If typing fails or is ambiguous, stag issues no receipt; inspect the pane before trying again. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, -exact composer and caret, then sends one conditional Enter at the session's input +geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures @@ -82,3 +85,34 @@ replay Enter, and there is no autonomous retry. A successful response means one Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. + +## Recover after a refused submit + +A consumed receipt cannot be reused. First observe whether the prompt was +accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 3f511649..dd68d64d 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index 394f6be4..bb15a5e6 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -1,9 +1,8 @@ # Bounded inspection and guarded submission -Use these commands inside a Codeman session. They use its authenticated HTTP API; -no direct tmux access, second transport, or session-bridge installation is required. -They supplement `send`, `wait` and `read`. They do not establish full parity with -session-bridge or retire that skill. +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. ## Inspect recent evidence @@ -31,7 +30,8 @@ falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator thread may leave launch metadata unchanged. Check `identity.conversationId`; these reads do not provide authoritative current-thread tracking. -Cursors are opaque, scoped to the requesting identity, session, pane generation +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server restart. At most 1024 are retained; capacity eviction can invalidate an older cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor @@ -43,7 +43,8 @@ never split or silently skip one. Increase the byte budget for `record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, use `read --tail` for a labeled terminal view or start a fresh tail baseline. Inspect scans at most 256 KiB of record data plus bounded generation anchors. -Attribution separately caps directory entries and metadata bytes. Evidence and +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and activity are observations, not proof of task completion or authorization. ## Stage, inspect, then submit once @@ -58,7 +59,9 @@ codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --jso Stage types the file contents **without Enter**. The file must be regular, UTF8, one printable line, at most 8192 bytes, with no control characters or trailing whitespace/newline. It initially supports a local Codex composer with a recognized -footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, wrapped, remote/container and shell composers are refused. A customized footer or unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. @@ -70,7 +73,7 @@ requires the fully painted exact composer. If typing fails or is ambiguous, stag issues no receipt; inspect the pane before trying again. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, -exact composer and caret, then sends one conditional Enter at the session's input +geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures @@ -82,3 +85,34 @@ replay Enter, and there is no autonomous retry. A successful response means one Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. + +## Recover after a refused submit + +A consumed receipt cannot be reused. First observe whether the prompt was +accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 3f511649..dd68d64d 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 81a26162..97cfe50c 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -2,13 +2,14 @@ * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. */ -import { realpathSync } from 'node:fs'; -import { open, opendir } from 'node:fs/promises'; +import { open, opendir, realpath } from 'node:fs/promises'; +import { probePathKind } from './utils/bounded-path-probe.js'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const HEAD_BYTES = 131072; +const PREFIX_SCAN_BYTES = 8 * 1024 * 1024; export interface CodexAttributionOptions { codexHome: string; sessionId: string; @@ -18,15 +19,18 @@ export interface CodexAttributionOptions { export interface CodexAttributionBudget { maxEntries: number; maxBytes: number; + /** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */ + maxPrefixBytes?: number; } interface Metadata { id?: string; cwd?: string; originator?: string; } -function canonical(path: string): string | undefined { +async function canonical(path: string): Promise { + if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined; try { - return realpathSync.native(path); + return await realpath(path); } catch { return undefined; } @@ -38,7 +42,7 @@ export function resolveEffectiveCodexHome( env: Readonly> = process.env ): string { const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex')); - return canonical(path) ?? path; + return path; } /** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */ function strictMetadata(bytes: Buffer): Metadata | undefined { @@ -72,11 +76,18 @@ export async function findExactCodexConversation( options: CodexAttributionOptions, budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 } ): Promise<{ id: string; path: string } | undefined> { - const directories = [{ path: join(options.codexHome, 'sessions'), depth: 0 }]; + const workspace = await canonical(options.workingDir); + const home = await canonical(options.codexHome); + if (!workspace || !home) return undefined; + const sessions = join(home, 'sessions'); + if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; + const directories = [{ path: sessions, depth: 0 }]; const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; const originator = `codeman_${options.sessionId}`; const explicit = options.resumeSessionId; let entries = 0; + let prefixes = 0; + const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES); let scanned = HEAD_BYTES; // Reserve an independent final verification. while (directories.length) { const dir = directories.pop()!; @@ -96,8 +107,8 @@ export async function findExactCodexConversation( if (!info.isFile()) return undefined; const id = entry.name.match(UUID_AT_END)?.[1]; const prefixBytes = Math.min(info.size, 4096); - if (scanned + prefixBytes > budget.maxBytes) return undefined; - scanned += prefixBytes; + if (prefixes + prefixBytes > prefixLimit) return undefined; + prefixes += prefixBytes; const prefix = Buffer.alloc(prefixBytes); const first = await file.read(prefix, 0, prefixBytes, 0); // Cheap negative filter only; a match still needs full parsed metadata. @@ -122,12 +133,7 @@ export async function findExactCodexConversation( } if (candidates.length !== 1) return undefined; const candidate = candidates[0]; - if ( - !candidate.meta.cwd || - canonical(candidate.meta.cwd) !== canonical(options.workingDir) || - !canonical(options.workingDir) - ) - return undefined; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; const handle = await open(candidate.path, 'r').catch(() => undefined); if (!handle) return undefined; try { diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts index 1fc15b4e..c48c90fc 100644 --- a/src/agent-inspection.ts +++ b/src/agent-inspection.ts @@ -134,7 +134,7 @@ export class InspectionReader { break; } if (end + 1 > maxBytes) { - blockedReason = 'record-exceeds-output-budget'; + if (consumed === 0) blockedReason = 'record-exceeds-output-budget'; break; } consumed = end + 1; diff --git a/src/agent-staging.ts b/src/agent-staging.ts index ff8cc8c9..2dbd2382 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -6,12 +6,18 @@ import { createHash, randomUUID } from 'node:crypto'; import { stripAnsi } from './utils/regex-patterns.js'; import { visibleWidth } from './tui/tui-ansi.js'; +import { CODEX_REASONING_EFFORTS } from './types/session.js'; +const FOOTER_LINE = new RegExp( + String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )` +); export const STAGE_MAX_BYTES = 8192; export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); export interface GuardedInputSnapshot { generation: string; revision: number; composerSha: string; + /** Actual captured pane width minus the rendered composer prefix (including indentation). */ + availableColumns: number; } export interface GuardedInputTarget { id: string; @@ -40,17 +46,7 @@ export function readGuardedCodexComposer(screen: string): string | null { const last = rows.at(-1); if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); - if ( - below.length === 0 || - below.length > 3 || - below.some( - (line) => - !/^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:default|low|medium|high|xhigh|max) · )/.test( - line - ) - ) - ) - return null; + if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -74,6 +70,10 @@ export function readGuardedCodexComposer(screen: string): string | null { /** Require the capture's caret at the exact end of the unwrapped composer. */ export function readGuardedCodexSnapshot(snapshot: string): string | null { + return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null; +} +/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ +export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null { // formatPaneSnapshot emits one absolute row address per line, then the caret. // Plain captures lack caret evidence and cannot prove invisible trailing edits. // eslint-disable-next-line no-control-regex @@ -98,7 +98,7 @@ export function readGuardedCodexSnapshot(snapshot: string): string | null { const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) return null; - return composer; + return { composer, prefixWidth: visibleWidth(prefix[1]) }; } export class GuardedStageStore { @@ -129,6 +129,8 @@ export class GuardedStageStore { const before = target.snapshot(); if (!before || before.composerSha !== promptSha256('')) throw new GuardedInputConflict('An empty, verifiable composer is required'); + if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns) + throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane'); for (const [id, receipt] of this.receipts) if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id); // Refuse capacity before typing. Do not evict a caller's still-live receipt. @@ -139,7 +141,12 @@ export class GuardedStageStore { if (!(await target.write(message))) throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); const after = target.snapshot(); - if (!after || after.generation !== before.generation || after.revision !== before.revision + 1) + if ( + !after || + after.generation !== before.generation || + after.revision !== before.revision + 1 || + after.availableColumns !== before.availableColumns + ) throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); const stageId = randomUUID(); const expiresAt = this.now() + this.ttlMs; @@ -166,6 +173,7 @@ export class GuardedStageStore { !current || current.generation !== receipt.generation || current.revision !== receipt.revision || + current.availableColumns !== receipt.availableColumns || current.composerSha !== receipt.sha256 ) throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted'); diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 94c36283..57fa360e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -314,9 +314,9 @@ export interface TerminalMultiplexer extends EventEmitter { // ========== Input ========== /** - * Send input to a session via tmux send-keys. + * Send input to a session via tmux send-keys. The optional internal Enter-only + * guard runs synchronously immediately before transport dispatch. */ - /** Internal conditional Enter guard, checked synchronously before transport dispatch. */ sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise; // ========== Metadata ========== diff --git a/src/session.ts b/src/session.ts index 6f673222..8d3a5791 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,7 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; -import { promptSha256, readGuardedCodexSnapshot, type GuardedInputSnapshot } from './agent-staging.js'; +import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js'; import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; @@ -4727,26 +4727,9 @@ export class Session extends EventEmitter { // Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps) - /** - * Sends input directly to the PTY process. - * - * For interactive sessions, this is how you send user input to Claude. - * Remember to include `\r` (carriage return) to simulate pressing Enter. - * - * @example - * ```typescript - * session.write('hello world'); // Text only, no Enter - * session.write('\r'); // Enter key - * session.write('ls -la\r'); // Command with Enter - * ``` - * - * @param data - The input data to send (text, escape sequences, etc.) - * @returns true if the data reached a PTY. A session whose PTY is gone still - * discards the data, but it used to do so with no signal at all — which is how - * input could disappear while the caller believed it had been delivered. - */ private readonly _inspectionInstance = uuidv4(); private _guardedPaneRevision = 0; + private _guardedGeometryRevision = 0; private _guardedInputRevision = 0; private _guardedPendingWrites = 0; private _guardedSubmitInFlight = false; @@ -4754,6 +4737,10 @@ export class Session extends EventEmitter { get inspectionGeneration(): string { return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; } + /** Staging additionally binds geometry; transcript cursors survive a terminal resize. */ + get guardedInputGeneration(): string { + return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`; + } get guardedInputPendingWrites(): number { return this._guardedPendingWrites; } @@ -4781,7 +4768,8 @@ export class Session extends EventEmitter { !this._muxSession ) return null; - const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName); + const capture: import('./mux-interface.js').PaneCaptureOptions = {}; + const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); if (!screen) return null; // tmux's replay has CUP addresses rather than line breaks. Read the SAME // fresh frame for dialog/work chrome and exact composer/caret proof. @@ -4790,13 +4778,16 @@ export class Session extends EventEmitter { const working = this._workingLinePattern(); working.lastIndex = 0; if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; - const composer = readGuardedCodexSnapshot(screen); + const composer = readGuardedCodexSnapshotDetails(screen); return composer === null ? null : { - generation: this.inspectionGeneration, + generation: this.guardedInputGeneration, revision: this._guardedInputRevision, - composerSha: promptSha256(composer), + composerSha: promptSha256(composer.composer), + availableColumns: Number.isFinite(capture.capturedGeometry?.cols) + ? capture.capturedGeometry!.cols - composer.prefixWidth + : 0, }; } /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ @@ -4815,7 +4806,8 @@ export class Session extends EventEmitter { !!current && current.generation === expected.generation && current.revision === expected.revision && - current.composerSha === expected.composerSha; + current.composerSha === expected.composerSha && + current.availableColumns === expected.availableColumns; if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false; this._guardedSubmitInFlight = true; let dispatched = false; @@ -4845,6 +4837,24 @@ export class Session extends EventEmitter { this._guardedPendingWrites--; } } + /** + * Sends input directly to the PTY process. + * + * For interactive sessions, this is how you send user input to Claude. + * Remember to include `\r` (carriage return) to simulate pressing Enter. + * + * @example + * ```typescript + * session.write('hello world'); // Text only, no Enter + * session.write('\r'); // Enter key + * session.write('ls -la\r'); // Command with Enter + * ``` + * + * @param data - The input data to send (text, escape sequences, etc.) + * @returns true if the data reached a PTY. A session whose PTY is gone still + * discards the data, but it used to do so with no signal at all — which is how + * input could disappear while the caller believed it had been delivered. + */ write(data: string, options: SessionWriteOptions = {}): boolean { this._observeGuardedKeyboardInput(data); if (this._guardedSubmitInFlight) return false; @@ -5219,7 +5229,7 @@ export class Session extends EventEmitter { } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { - this._guardedPaneRevision++; + this._guardedGeometryRevision++; this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index a3892b34..662cf41f 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -27,7 +27,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor snapshot: () => { if (ctx.sessions.get(session.id) !== session) return null; const snapshot = session.guardedInputSnapshot?.(); - return snapshot ? { ...snapshot, generation: generation(session) } : null; + return snapshot ?? null; }, write: (text) => session.stageGuardedInput(text), submit: (expected) => session.submitGuardedInput(expected), diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ec5e0e80..ed887306 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -47,3 +47,5 @@ export { type CustomModelSwapDisplacement, } from './custom-model-routes.js'; export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js'; + +export { registerAgentSessionRoutes } from './agent-session-routes.js'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 507ef7ab..fbceb684 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,7 +5,6 @@ */ import { claudeProjectDirName } from '../../case-rename.js'; -import { registerAgentSessionRoutes } from './agent-session-routes.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; @@ -1073,8 +1072,6 @@ export function registerSessionRoutes( // ========== Logout ========== - registerAgentSessionRoutes(app, ctx); - app.post('/api/logout', async (req, reply) => { // Invalidate server-side session token (not just the browser cookie) const sessionToken = req.cookies[AUTH_COOKIE_NAME]; diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c..3e2d07b8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -213,6 +213,7 @@ import { registerCaseRoutes, registerWorktreeRoutes, registerSessionRoutes, + registerAgentSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, @@ -1249,6 +1250,7 @@ export class WebServer extends EventEmitter { // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); + registerAgentSessionRoutes(this.app, ctx); registerRespawnRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index b568b3d5..d3bc9cad 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -117,3 +117,40 @@ it.each([ } } ); + +it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => { + const f = await fixture(); + const path = await f.put(A); + await Promise.all( + Array.from({ length: 320 }, (_, n) => { + const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`; + return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) }); + }) + ); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + expect( + await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 }) + ).toBeUndefined(); +}); + +it.each(['workspace', 'metadata'] as const)( + 'probes %s bounded past the cap before canonical filesystem reads', + async (where) => { + const { vi } = await import('vitest'); + const probe = await import('../src/utils/bounded-path-probe.js'); + const f = await fixture(); + const alias = join(f.root, 'workspace-alias'); + await symlink(f.root, alias); + await f.put(A, { cwd: where === 'metadata' ? alias : f.root }); + const blocked = where === 'workspace' ? f.root : alias; + const spy = vi + .spyOn(probe, 'probePathKind') + .mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory')); + try { + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true }); + } finally { + spy.mockRestore(); + } + } +); diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts index 2a1f4270..989f005b 100644 --- a/test/agent-inspection.test.ts +++ b/test/agent-inspection.test.ts @@ -159,3 +159,22 @@ it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete await appendFile(file, row.subarray(split)); expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8')); }); + +it('treats a full incremental page as pageable rather than a blocked oversized record', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, '{}\n{}\n'); + const page = await reader.read(file, scope, 3, initial.cursor); + expect(page.evidence).toBe('{}\n'); + expect(page.truncated).toBe(true); + expect(page.blockedReason).toBeUndefined(); + const next = await reader.read(file, scope, 3, page.cursor); + expect(next.evidence).toBe('{}\n'); +}); + +it('still labels the first selected oversized tail record after omitted older bytes', async () => { + await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n'); + const page = await reader.read(file, scope, 10); + expect(page.evidence).toBe(''); + expect(page.blockedReason).toBe('record-exceeds-output-budget'); +}); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index f0bb1328..8d97cc23 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -7,13 +7,19 @@ it('fences writes and reused-pid pane lifecycles without persisting prompt conte let screen = '› \n GPT-6-Luna default · ~/case\n'; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(screen.trimEnd().split('\n'), { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: screen.startsWith('› hello') ? 7 : 2, cursorY: 0, - }), + }); + }, sendInput: async () => true, }; const session = new Session({ @@ -49,8 +55,19 @@ it('checks conditional Enter at the dispatch boundary and rejects concurrent bro const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { await new Promise((resolve) => { dispatch = resolve; @@ -104,13 +121,19 @@ it('cancels an older automatic submit verifier before staging an identical promp const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 2 + composer.length, cursorY: 0, - }), + }); + }, capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`, sendInput: async (_id: string, text: string) => { writes.push(text); @@ -141,8 +164,19 @@ it('routes automatic verifier Enter through the shared guarded input boundary', const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case', sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { if (options?.beforeDispatch) { @@ -180,8 +214,19 @@ it('refuses legacy one-shot prompt entrypoints during a guarded submission befor const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { await new Promise((resolve) => { release = resolve; @@ -259,7 +304,14 @@ it('uses fresh normalized pane rows for working/dialog denial before exact compo mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: capture, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return capture(); + }, capturePaneText: () => { throw Error('must use same frame'); }, @@ -287,13 +339,19 @@ it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0, - }), + }); + }, }, muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters[0]); @@ -319,11 +377,90 @@ it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop session.owner = 'other'; session.owner = undefined; expect(session.inspectionGeneration).not.toBe(generation); - const priorResize = session.inspectionGeneration; + const priorResize = session.guardedInputGeneration; session.resize(80, 24); - expect(session.inspectionGeneration).not.toBe(priorResize); + expect(session.guardedInputGeneration).not.toBe(priorResize); } finally { access.ptyProcess = null; await session.stop(false); } }); + +it('refuses a message that would wrap using the actual captured indent and width before any write', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane: string, + opts: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 30, rows: 20 }; + return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], { + cols: 30, + rows: 20, + cursorX: 6, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string) => { + writes.push(text); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const { GuardedStageStore } = await import('../src/agent-staging.js'); + try { + const store = new GuardedStageStore(); + const target = { + id: session.id, + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters[0]) => session.submitGuardedInput(snap), + }; + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + } +}); + +it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => { + const session = new Session({ workingDir: '/tmp', mode: 'codex' }); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + try { + const inspection = session.inspectionGeneration; + const staging = session.guardedInputGeneration; + session.resize(80, 24); + expect(session.inspectionGeneration).toBe(inspection); + expect(session.guardedInputGeneration).not.toBe(staging); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('records a verifier re-press as the latest submit time', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10000); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + await session.writeViaMux('hello\r'); + const initial = session.lastSubmitAt; + await vi.advanceTimersByTimeAsync(2000); + expect(session.lastSubmitAt).toBe(initial + 2000); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 1cd920a6..6a9c1ad3 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -1,6 +1,7 @@ /** @fileoverview Guarded staging one-shot and content-verification safety. */ import { beforeEach, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; const sha = (s: string) => createHash('sha256').update(s).digest('hex'); let store: GuardedStageStore; @@ -21,7 +22,8 @@ beforeEach(() => { store = new GuardedStageStore(() => time, 2, 1000); target = { id: 's', - snapshot: () => (composer === null ? null : { generation, revision, composerSha: sha(composer) }), + snapshot: () => + composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, submit: async () => target.write('\r'), write: async (text) => { writes.push(text); @@ -151,3 +153,18 @@ it('reserves receipt capacity before concurrent stages type', async () => { release!(); await first; }); + +it.each([...CODEX_REASONING_EFFORTS, 'default'])( + 'accepts the shared Codex effort word %s in the footer', + async (word) => { + const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); + expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); + } +); + +it('refuses messages that reach the available pane columns before typing', async () => { + target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 }); + for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + expect(writes).toEqual([]); + await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); +}); diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 02cec22b..27fbd415 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -1,6 +1,6 @@ /** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ import { afterEach, describe, expect, it } from 'vitest'; -import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; let harness: RouteTestHarness; @@ -9,7 +9,7 @@ afterEach(async () => { }); describe('agent session routes', () => { it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` }); expect(result.statusCode).toBe(200); @@ -19,7 +19,7 @@ describe('agent session routes', () => { expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20); }); it('refuses guarded staging for an uncharacterized composer', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; const result = await harness.app.inject({ method: 'POST', @@ -33,7 +33,9 @@ describe('agent session routes', () => { it('checks ownership before inspection, stage and submit', async () => { process.env.CODEMAN_MULTIUSER = '1'; try { - harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'user' } }); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'user' }, + }); const session = [...harness.ctx.sessions.values()][0]; session.owner = 'bob'; for (const request of [ @@ -52,7 +54,7 @@ it('checks ownership before inspection, stage and submit', async () => { } }); it('validates output budgets and refuses to resume a pane fallback', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; session.terminalBuffer = '😀é'.repeat(20); for (const value of ['0', '-1', '65537', '1.5', 'NaN']) @@ -74,7 +76,7 @@ it('validates output budgets and refuses to resume a pane fallback', async () => it('uses real session writes to stage without Enter and refuses intervening API input', async () => { const { Session } = await import('../../src/session.js'); const { formatPaneSnapshot } = await import('../../src/tmux-manager.js'); - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); let composer = ''; const writes: string[] = []; const session = new Session({ @@ -83,13 +85,19 @@ it('uses real session writes to stage without Enter and refuses intervening API mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 2 + composer.length, cursorY: 0, - }), + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { if (options?.beforeDispatch && !options.beforeDispatch()) return false; writes.push(text); @@ -157,7 +165,9 @@ it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its const row = JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'; await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n'); - harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'admin' } }); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'admin' }, + }); (harness.ctx.sessions as unknown as Map).set(session.id, session); try { const first = await harness.app.inject({ @@ -172,6 +182,13 @@ it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its expect(data.cursor).not.toContain(home); const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` }); expect(next.json().data.evidence).toBe(''); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + session.resize(80, 24); + access.ptyProcess = null; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(200); session.owner = 'changed-owner'; expect( (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode @@ -202,7 +219,7 @@ it('refuses a stale launch-resume cursor once a new managed rollout conflicts wi const row = (id: string, originator: string) => JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n'; await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch')); - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); (harness.ctx.sessions as unknown as Map).set(session.id, session); try { const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index c131c4e5..614cb904 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -326,3 +326,13 @@ describe('sendInput line breaks', () => { expect(answer.ran[1]).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); }); }); + +it('terminates literal operands in both sendInputToPane branches', async () => { + const { readFile } = await import('node:fs/promises'); + const src = await readFile(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); + const start = src.indexOf(' sendInputToPane('); + const body = src.slice(start, src.indexOf('\n }\n', start)); + const literal = body.match(/send-keys[^`\n]*-l[^`\n]*/g) ?? []; + expect(literal).toHaveLength(2); + for (const command of literal) expect(command).toContain(' -l -- '); +}); diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index e81612b9..6bce845a 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -40,6 +40,34 @@ describe('WebServer on an ephemeral port', () => { expect(ctx.port).toBe(socketPort()); }); + it('registers bounded agent routes with the server route modules', async () => { + const { Session } = await import('../src/session.js'); + const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); + const sessions = (server as unknown as { sessions: Map }).sessions; + sessions.set(session.id, session); + try { + const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + const inspected = await fetch(prefix + '/inspect'); + expect(inspected.status).toBe(200); + expect((await inspected.json()).data.source).toBe('pane-snapshot'); + for (const [verb, body] of [ + ['stage', { message: 'hello' }], + ['submit', { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }], + ] as const) { + const response = await fetch(prefix + '/' + verb, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(409); + expect((await response.json()).errorCode).toBe('CONFLICT'); + } + } finally { + sessions.delete(session.id); + await session.stop(false); + } + }); + it('exports the real port to the panes as CODEMAN_API_URL', () => { expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`); }); From 1a2c3be0ea3aea93680842398390ace17207babb Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 17:06:45 -0400 Subject: [PATCH 3/9] docs(agent): mark coordination API experimental --- .changeset/bounded-agent-coordination.md | 4 +++- README.md | 2 +- docs/api-reference.md | 9 ++++++++- docs/architecture-invariants.md | 4 ++++ docs/versioning-policy.md | 14 ++++++++++++++ plugins/codeman/skills/codeman/SKILL.md | 2 +- .../codeman/reference/inspection-and-staging.md | 9 ++++++++- plugins/codeman/skills/codeman/reference/verbs.md | 5 ++++- skills/codeman/SKILL.md | 2 +- skills/codeman/reference/inspection-and-staging.md | 9 ++++++++- skills/codeman/reference/verbs.md | 5 ++++- src/cli-agent.ts | 8 +++++--- 12 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 1239817a..b88bcef9 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -2,7 +2,9 @@ "aicodeman": minor --- -Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. +Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. + +Explicitly exclude the three new CLI verbs and matching HTTP routes (including `/api/v1` and `/api` aliases) from the stable contract while experimental; their shape and behavior may change in minor or patch releases. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/README.md b/README.md index 1850841a..36f0bf74 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The new `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental**: commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases. Pin a compatible version when automating them; see the [stability exception](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/api-reference.md b/docs/api-reference.md index ca04f1cf..2bcbebb6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -589,7 +589,14 @@ request returns. The capture runs synchronous tmux calls on the server; the | `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | | `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | -## Bounded agent inspection and guarded input +## Experimental bounded agent inspection and guarded input + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. These authenticated routes use the standard envelope and session ownership checks. `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 140d394d..3feb4f96 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1263,6 +1263,10 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ## Agent inspection and guarded submission +These three operations are experimental across both CLI and HTTP, including the +`/api/v1` paths and unversioned aliases. See `docs/versioning-policy.md` for the +explicit stability exception; do not infer stability from the URL prefix. + `agent-session-routes.ts` is registered by `WebServer` with the other route modules. Ownership is checked before transcript/pane/receipt lookup. Inspection uses strict native metadata attribution, bounded traversal, separate prefix and diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 6ec92a2f..bf42c095 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -62,6 +62,20 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: anything labeled experimental in the UI or docs. These may change or be removed at any time. +### Experimental agent coordination + +`codeman agent inspect`, `codeman agent stage` and `codeman agent submit` are +experimental. The matching `GET /api/v1/sessions/:id/inspect`, +`POST /api/v1/sessions/:id/stage` and `POST /api/v1/sessions/:id/submit` routes, +including their unversioned `/api` aliases, share this designation. This is an +explicit exception to both the CLI and HTTP stability promises above: these +commands, flags, endpoint paths, payloads and behavior may change or be removed +in a MINOR or PATCH release without a stable-surface deprecation period. Pin a +compatible Codeman version when automating them. Existing authentication, +ownership and shared response-envelope rules still apply. Other agent commands +and API routes retain their existing stability guarantees. Promotion to stable +requires an explicit documented decision. + ## Deprecation policy When we need to change a covered surface: diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index ce34728a..80f26c41 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -30,7 +30,7 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). -Bounded inspection and guarded stage/submit load only when needed from +Experimental bounded inspection and guarded stage/submit load only when needed from [reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index bb15a5e6..d624f5d7 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -1,4 +1,11 @@ -# Bounded inspection and guarded submission +# Experimental bounded inspection and guarded submission + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index dd68d64d..54a8899c 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -814,7 +814,10 @@ Rules that matter: - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. -## Bounded inspection and guarded submission +## Experimental bounded inspection and guarded submission + +These three commands and their HTTP routes may change or be removed in minor or +patch releases; they are excluded from the stable CLI/HTTP contract. Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index ce34728a..80f26c41 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -30,7 +30,7 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). -Bounded inspection and guarded stage/submit load only when needed from +Experimental bounded inspection and guarded stage/submit load only when needed from [reference/inspection-and-staging.md](reference/inspection-and-staging.md). This skill works the same whichever CLI you are: Codeman puts it where each CLI looks diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index bb15a5e6..d624f5d7 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -1,4 +1,11 @@ -# Bounded inspection and guarded submission +# Experimental bounded inspection and guarded submission + +**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their +`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), +are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +payloads and behavior may change or be removed in a minor or patch release. +Pin a compatible Codeman version when automating them. Authentication and +session ownership checks still apply. Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index dd68d64d..54a8899c 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -814,7 +814,10 @@ Rules that matter: - **Lifetime.** An inbox goes with its session on delete. A server restart empties all inboxes unless the user turned on *Keep agent inbox across restarts* in Settings. -## Bounded inspection and guarded submission +## Experimental bounded inspection and guarded submission + +These three commands and their HTTP routes may change or be removed in minor or +patch releases; they are excluded from the stable CLI/HTTP contract. Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 9f0ccb8b..8db5ad3e 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -1272,7 +1272,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('inspect ') - .description('Bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .description('Experimental: bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') .option('--since ', 'Resume a cursor from the same owner, pane and transcript') .option('--json', 'Machine-readable output') @@ -1288,7 +1288,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage ') - .description('Type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer') + .description( + 'Experimental: type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + ) .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') .action((id: string, options: { messageFile: string; json?: boolean }) => @@ -1297,7 +1299,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('submit ') - .description('Send Enter once only when the staged composer, pane and hash still match') + .description('Experimental: send Enter once only when the staged composer, pane and hash still match') .requiredOption('--stage-id ', 'One-shot stage receipt') .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') .option('--json', 'Machine-readable output') From c78635a3d3d7010192344fbe4c8ff6833a6714ca Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 22:54:17 -0400 Subject: [PATCH 4/9] feat(agent): make coordination routes opt-in and address review Gate inspect, stage and submit behind the synced agentCoordinationEnabled setting (default OFF, App Settings > Cross-session features, zh-CN strings). While off, all three routes answer 403 FORBIDDEN naming the setting, checked before session lookup; the CLI verbs print it verbatim. Remember the attributed Codex rollout per session and re-verify only its head on later calls, rescanning only date directories not older than it for a /new conflict. The first walk reads 1 KiB prefixes first, so a store of 2,100+ unrelated rollouts still attributes. Answer unexpected inspection errors with 422 OPERATION_FAILED and log the session id and error code only. Read the guarded composer glyph and footer from the CLI registry: codex workDetect.promptGlyph and a new workDetect.composerFooterLine capability, compiled through compileVersionRegex. Codex footer effort words are one shared list with modelDetect.screenLine. Fix the wiki API reference link, frame the versioning policy entry as a case of the experimental rule, join the CLAUDE.md Agent CLI paragraph, and update the changeset, skill reference and architecture notes. --- .changeset/bounded-agent-coordination.md | 6 +- CLAUDE.md | 5 +- README.md | 2 +- docs/api-reference.md | 25 ++- docs/architecture-invariants.md | 33 ++- docs/cli-registry.md | 19 +- docs/versioning-policy.md | 27 ++- docs/wiki/Driving-Codeman-From-An-Agent.md | 17 +- docs/wiki/Settings-Reference.md | 8 + .../reference/inspection-and-staging.md | 7 +- .../codeman/skills/codeman/reference/verbs.md | 9 +- .../reference/inspection-and-staging.md | 7 +- skills/codeman/reference/verbs.md | 9 +- src/agent-codex-identity.ts | 188 ++++++++++++++---- src/agent-staging.ts | 70 +++++-- src/cli-agent.ts | 22 +- src/config/cli-registry/schema.ts | 10 + src/config/cli-registry/stock.ts | 16 +- src/config/cli-registry/types.ts | 9 + src/session.ts | 13 +- src/web/public/i18n.js | 3 + src/web/public/index.html | 7 + src/web/public/settings-ui.js | 3 + src/web/routes/agent-session-routes.ts | 50 ++++- src/web/schemas.ts | 7 + test/agent-codex-identity.test.ts | 85 +++++++- test/agent-coordination-setting.test.ts | 90 +++++++++ test/agent-staging.test.ts | 35 +++- test/cli-agent-inspection.test.ts | 33 +++ test/cli-registry-schema.test.ts | 21 ++ test/routes/agent-session-routes.test.ts | 111 ++++++++++- test/webserver-bound-port.test.ts | 25 +++ 32 files changed, 848 insertions(+), 124 deletions(-) create mode 100644 test/agent-coordination-setting.test.ts diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index b88bcef9..73f9513a 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -4,7 +4,11 @@ Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. -Explicitly exclude the three new CLI verbs and matching HTTP routes (including `/api/v1` and `/api` aliases) from the stable contract while experimental; their shape and behavior may change in minor or patch releases. +The three new CLI verbs (`codeman agent inspect`, `stage`, `submit`) and their HTTP routes (`/api/v1` and `/api`) are labeled experimental, so the versioning policy's rule for experimental surfaces applies: their shape and behavior may change in minor or patch releases. They are opt-in behind a new synced setting, `agentCoordinationEnabled` (App Settings, Cross-session features, "Experimental agent coordination", default OFF, translated for zh-CN): while it is off all three routes answer `403 FORBIDDEN` with a message naming it, and the CLI verbs print that refusal verbatim. + +Codex rollout attribution remembers the attributed file per session and re-verifies only that file's head on later calls, rescanning only the date directories not older than it for a codex `/new`, so a large never-pruned `~/.codex/sessions` store no longer falls back to a pane snapshot or costs a full walk per call. An unexpected inspection read error answers `422 OPERATION_FAILED` and is logged with the session id and error code only, instead of a retryable `409`. + +The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/CLAUDE.md b/CLAUDE.md index 683084f9..a5e8101f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked -`agent-session-routes.ts` module; the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. @@ -176,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) diff --git a/README.md b/README.md index 36f0bf74..19d13314 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The new `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental**: commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases. Pin a compatible version when automating them; see the [stability exception](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/api-reference.md b/docs/api-reference.md index 2bcbebb6..2d4c6161 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -593,11 +593,20 @@ request returns. The capture runs synchronous tmux calls on the server; the **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental, so the versioning policy's rule for experimental +surfaces applies (`docs/versioning-policy.md`, "Experimental agent +coordination"): they are not part of the stable contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced +`agentCoordinationEnabled` setting is off (the default; App Settings, +Cross-session features, **Experimental agent coordination**), with a message +naming it. The check runs before session lookup and body validation, and reads +the saved setting on every request, so turning it on needs no restart. +`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1). + These authenticated routes use the standard envelope and session ownership checks. `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before any transcript, pane or receipt lookup. @@ -617,7 +626,14 @@ keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution separately caps at 8192 directory entries, eight directory levels, 8 MiB of negative-filter prefix reads and 1 MiB of candidate metadata reads (including -final identity verification). Unreachable workspace/home/metadata directories +final identity verification). The negative filter reads 1 KiB of each rollout +first and 4 KiB only when that window holds no complete `originator` field. Once +a rollout is attributed, later calls re-verify only that file (same file identity, +same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not +older than its own, for a second candidate such as a codex `/new`; a failed check +falls back to one full walk. So the caps bound the first call, not every call. +An unexpected read error is `422 OPERATION_FAILED` (logged with the session id +and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain unattributed. A full incremental page can be continued with its cursor; only a first pending record too large for the budget reports an output-budget block. @@ -643,7 +659,10 @@ filesystem path is returned as cursor/identity metadata; evidence retains the owner's native record content, which can itself contain paths. Guarded input initially requires a local characterized Codex composer, one -unwrapped visible line, a recognized footer and a cursor at its exact end. +unwrapped visible line, a recognized footer and a cursor at its exact end. The +composer glyph and footer are the CLI registry's +`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that +declares no `composerFooterLine` is refused. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing whitespace (the existing mux transport trims it). Before typing, it checks the diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3feb4f96..edd12634 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -1264,8 +1264,15 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ## Agent inspection and guarded submission These three operations are experimental across both CLI and HTTP, including the -`/api/v1` paths and unversioned aliases. See `docs/versioning-policy.md` for the -explicit stability exception; do not infer stability from the URL prefix. +`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule +in `docs/versioning-policy.md`, so do not infer stability from the URL prefix. + +⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming +`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session +features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The +gate runs first, before session lookup or body validation, and reads +`settings.json` fresh on every request, so a toggle applies without a restart. +`codeman agent inspect|stage|submit` pass the 403 through verbatim. `agent-session-routes.ts` is registered by `WebServer` with the other route modules. Ownership is checked before transcript/pane/receipt lookup. Inspection @@ -1275,8 +1282,24 @@ complete UTF8 JSONL records. A full page advances its cursor without claiming a blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane geometry is excluded so changing devices does not invalidate transcript paging. -Staging requires a fresh, known local Codex composer/caret capture. The footer -uses the shared `CODEX_REASONING_EFFORTS` vocabulary. Before any write, available +⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store +walk per call. The first call walks (negative-filter prefixes read 1 KiB first, +4 KiB only when that window holds no complete `originator` field; non-UUID file +names are skipped unread), then the attributed `{path, dev:ino}` and its strict +metadata are remembered per session (`attributionMemo`, bounded LRU). A later call +re-verifies only that file's head, then scans just the date directories not older +than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is +never pruned) for a second candidate, which is how a codex `/new` or a new owned +rollout beside a configured resume is still caught. A failed check drops the memo +and falls back to one full walk. Non-`InspectionConflict` errors are `422 +OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and +error code only, never the path. + +Staging requires a fresh, known composer/caret capture. The composer glyph and +footer are registry data (`capabilities.workDetect.promptGlyph` and +`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI +declaring `composerFooterLine` takes guarded input (codex today). Codex's footer +words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, including indentation; reaching the last column or wrapping is refused. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 72c28a2d..955b6f88 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,9 +47,10 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines? } + // blockedLine?, blockedLines?, composerFooterLine? } // (how this CLI's pane shows work, work it started in the background, a turn - // that ended waiting for workers it will resume from, and a dialog it is blocked on) + // that ended waiting for workers it will resume from, a dialog it is blocked on, + // and the rows under its idle composer that guarded input relies on) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -63,7 +64,7 @@ interface CliEntry { ### Regexes that come from config -Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Seven capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine` and `capabilities.modelDetect.screenLine`. All seven go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -124,6 +125,18 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a pattern, make sure its row is one the agent cannot write. +### The idle composer guarded input reads (`composerFooterLine`) + +The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through +`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture, +that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the +exact end of its text. `composerFooterLine` is the second half of that proof: every +non-blank row below the composer (at most three) must match it, one row at a time. A CLI +that declares none never takes guarded input; only codex declares one, built from the same +effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a +codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the +foot of the screen, so a pattern must anchor on chrome only that CLI draws there. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index bf42c095..8f8581a5 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -64,17 +64,22 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: ### Experimental agent coordination -`codeman agent inspect`, `codeman agent stage` and `codeman agent submit` are -experimental. The matching `GET /api/v1/sessions/:id/inspect`, -`POST /api/v1/sessions/:id/stage` and `POST /api/v1/sessions/:id/submit` routes, -including their unversioned `/api` aliases, share this designation. This is an -explicit exception to both the CLI and HTTP stability promises above: these -commands, flags, endpoint paths, payloads and behavior may change or be removed -in a MINOR or PATCH release without a stable-surface deprecation period. Pin a -compatible Codeman version when automating them. Existing authentication, -ownership and shared response-envelope rules still apply. Other agent commands -and API routes retain their existing stability guarantees. Promotion to stable -requires an explicit documented decision. +`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and +the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage` +and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since +every `/api` route is also served under `/api/v1`), are labeled experimental, so +item 3 above applies to them: their commands, flags, endpoint paths, payloads and +behavior may change or be removed in a MINOR or PATCH release. They are also +opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced +`agentCoordinationEnabled` setting (default OFF) until it is turned on, so +automation cannot come to depend on them without choosing to. Pin a compatible +Codeman version when automating them. Existing authentication, ownership and +shared response-envelope rules still apply. The session behavior underneath them +(input revision tracking in the session's write paths, the SubmitVerifier +re-press going through the shared write boundary) runs for every session and is +covered like any other stable behavior. Other agent commands and API routes keep +their existing guarantees. Promotion to stable requires an explicit documented +decision. ## Deprecation policy diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 776eddf1..18d21b5d 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -44,9 +44,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) -codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) -codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only -codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter +codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -70,6 +70,15 @@ codeman agent rm "$SID" # any session except t `ls` shows each session's mail in an INBOX column. Mail lives in memory unless **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), and goes when its session is deleted. +- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN` + naming the setting until **Experimental agent coordination** is on (see + [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by + default), and they may change in any release. `inspect` returns bounded evidence with an + opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a + local Codex composer, with no Enter, and returns a receipt that expires after two minutes; + `submit` sends one Enter only if the composer still shows exactly that text, and never + replays it. `send --no-enter` alone has no receipt. Details: + [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. @@ -267,5 +276,3 @@ failing the spawn, because a cosmetic field must never break a worker. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way. - [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI. - [`skills/codeman/SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) - the skill itself. - -Bounded inspection and guarded stage/submit details: [API reference](../api-reference.md#bounded-agent-inspection-and-guarded-input). Stage accepts one printable UTF8 line without trailing whitespace; receipts expire after two minutes. Submit refuses changed/unprovable composers and never automatically replays Enter. `send --no-enter` alone has no receipt. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676b..43099736 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -106,6 +106,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha memory. Messages expire 12 hours after they were posted either way. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). +**Experimental agent coordination** (under **Cross-session features**, synced, off by +default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded, +paged reads of another session's output, and typing a prompt into a local Codex composer +that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP +routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are +experimental and may change in any release. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + ### Appearance | Setting | Notes | diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index d624f5d7..c53530aa 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -2,11 +2,16 @@ **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct tmux access is required. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 54a8899c..85a7b981 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -816,10 +816,11 @@ Rules that matter: ## Experimental bounded inspection and guarded submission -These three commands and their HTTP routes may change or be removed in minor or -patch releases; they are excluded from the stable CLI/HTTP contract. - -Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed `codeman agent submit --stage-id --confirm-sha` when the exact local Codex composer and caret can be verified. Other targets provide labeled snapshots or diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index d624f5d7..c53530aa 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -2,11 +2,16 @@ **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct tmux access is required. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 54a8899c..85a7b981 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -816,10 +816,11 @@ Rules that matter: ## Experimental bounded inspection and guarded submission -These three commands and their HTTP routes may change or be removed in minor or -patch releases; they are excluded from the stable CLI/HTTP contract. - -Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed `codeman agent submit --stage-id --confirm-sha` when the exact local Codex composer and caret can be verified. Other targets provide labeled snapshots or diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 97cfe50c..490f7267 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -1,11 +1,16 @@ /** * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. + * The first call walks the store under explicit budgets; the attributed file is + * then remembered per session and only re-verified (plus a scan of the date + * directories not older than it) on later calls, since codex never prunes + * `~/.codex/sessions`. */ import { open, opendir, realpath } from 'node:fs/promises'; import { probePathKind } from './utils/bounded-path-probe.js'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { LRUMap } from './utils/lru-map.js'; const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const HEAD_BYTES = 131072; @@ -67,6 +72,42 @@ function strictMetadata(bytes: Buffer): Metadata | undefined { } return found; } +/** + * The rollout a previous call attributed, per session. A later call re-verifies only + * this file's head (same `dev:ino`, same strict metadata) and then scans just the + * directories not older than its own for a second candidate (codex `/new`, or a new + * owned rollout beside a configured resume), instead of re-reading the whole store, + * which codex never prunes. Any failed check drops the entry and falls back to the + * full walk. Bounded: one entry per live session is the expected size. + */ +interface AttributionMemo { + id: string; + path: string; + dev: number; + ino: number; + meta: Metadata; + /** Directory components of `path` below `sessions/`, used to prune older siblings. */ + dirParts: string[]; +} +const attributionMemo = new LRUMap({ maxSize: 1024 }); +/** Test-only: forget every remembered attribution. */ +export function __clearCodexAttributionMemo(): void { + attributionMemo.clear(); +} +/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */ +const PREFIX_FIRST_BYTES = 1024; +/** Fallback read when the first window holds no complete `originator` field. */ +const PREFIX_MAX_BYTES = 4096; +const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/; +/** + * Is a date-shaped directory name older than the remembered one at the same depth? + * Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else + * is never pruned, so an unexpected layout costs a wider scan, not a missed rollout. + */ +function olderDatePart(name: string, memo: string | undefined): boolean { + if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false; + return Number(name) < Number(memo); +} /** * A configured resume and a newly owned rollout conflict rather than pinning an * old launch forever after /new. TUI resumes of foreign-originator threads cannot @@ -81,9 +122,92 @@ export async function findExactCodexConversation( if (!workspace || !home) return undefined; const sessions = join(home, 'sessions'); if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; - const directories = [{ path: sessions, depth: 0 }]; + const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0'); + const remembered = attributionMemo.get(key); + if (remembered) { + const verified = await verifyRemembered(remembered, workspace, budget); + if (verified) { + const others = await scanCandidates(sessions, options, budget, remembered.dirParts); + if (others && others.length === 1 && others[0].path === remembered.path) { + return { id: remembered.id, path: remembered.path }; + } + } + attributionMemo.delete(key); + // A second candidate is a conflict a full walk would report too; anything + // else (a replaced file, an exhausted budget) gets one full walk now. + if (verified) return undefined; + } + const candidates = await scanCandidates(sessions, options, budget); + if (!candidates || candidates.length !== 1) return undefined; + const candidate = candidates[0]; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; + const handle = await open(candidate.path, 'r').catch(() => undefined); + if (!handle) return undefined; + try { + const info = await handle.stat(); + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if ( + !meta || + meta.id !== candidate.id || + meta.cwd !== candidate.meta.cwd || + meta.originator !== candidate.meta.originator + ) + return undefined; + attributionMemo.set(key, { + id: candidate.id, + path: candidate.path, + dev: info.dev, + ino: info.ino, + meta, + dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean), + }); + return { id: candidate.id, path: candidate.path }; + } finally { + await handle.close(); + } +} +/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */ +async function verifyRemembered( + memo: AttributionMemo, + workspace: string, + budget: CodexAttributionBudget +): Promise { + if (HEAD_BYTES > budget.maxBytes) return false; + if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false; + const handle = await open(memo.path, 'r').catch(() => undefined); + if (!handle) return false; + try { + const info = await handle.stat(); + if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false; + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator; + } catch { + return false; + } finally { + await handle.close(); + } +} +/** + * Every rollout under `sessions` whose strict metadata names this session (or the + * configured resume id), or undefined when the walk hit a bound or an unreadable + * entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped + * directories older than those components are skipped. + */ +async function scanCandidates( + sessions: string, + options: CodexAttributionOptions, + budget: CodexAttributionBudget, + notOlderThan?: string[] +): Promise | undefined> { + // `onBoundary`: every ancestor so far equals the remembered path, so pruning applies. + const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }]; const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; const originator = `codeman_${options.sessionId}`; + const originatorJson = JSON.stringify(originator); const explicit = options.resumeSessionId; let entries = 0; let prefixes = 0; @@ -98,32 +222,43 @@ export async function findExactCodexConversation( const path = join(dir.path, entry.name); if (entry.isDirectory()) { if (dir.depth >= 8) return undefined; - directories.push({ path, depth: dir.depth + 1 }); + const part = notOlderThan?.[dir.depth]; + if (dir.onBoundary && olderDatePart(entry.name, part)) continue; + directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part }); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const id = entry.name.match(UUID_AT_END)?.[1]; + if (!id) continue; // no rollout id in the name: never a candidate, read nothing const file = await open(path, 'r').catch(() => undefined); if (!file) return undefined; try { const info = await file.stat(); if (!info.isFile()) return undefined; - const id = entry.name.match(UUID_AT_END)?.[1]; - const prefixBytes = Math.min(info.size, 4096); - if (prefixes + prefixBytes > prefixLimit) return undefined; - prefixes += prefixBytes; - const prefix = Buffer.alloc(prefixBytes); - const first = await file.read(prefix, 0, prefixBytes, 0); - // Cheap negative filter only; a match still needs full parsed metadata. - if ( - id !== explicit && - !prefix.subarray(0, first.bytesRead).toString('utf8').includes(JSON.stringify(originator)) - ) - continue; + if (id !== explicit) { + // Cheap negative filter only; a match still needs full parsed metadata. + // A short first window decides when it already holds the whole + // `originator` field; only otherwise is the wider window read. + let window = Math.min(info.size, PREFIX_FIRST_BYTES); + let prefix = ''; + for (;;) { + if (prefixes + window > prefixLimit) return undefined; + prefixes += window; + const buffer = Buffer.alloc(window); + const first = await file.read(buffer, 0, window, 0); + prefix = buffer.subarray(0, first.bytesRead).toString('utf8'); + if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break; + const wider = Math.min(info.size, PREFIX_MAX_BYTES); + if (wider <= window) break; + window = wider; + } + if (!prefix.includes(originatorJson)) continue; + } const size = Math.min(info.size, HEAD_BYTES); if (scanned + size > budget.maxBytes) return undefined; scanned += size; const bytes = Buffer.alloc(size); const read = await file.read(bytes, 0, size, 0); const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); - if (!meta || !id || meta.id !== id) continue; + if (!meta || meta.id !== id) continue; if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta }); } finally { await file.close(); @@ -131,24 +266,5 @@ export async function findExactCodexConversation( } } } - if (candidates.length !== 1) return undefined; - const candidate = candidates[0]; - if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; - const handle = await open(candidate.path, 'r').catch(() => undefined); - if (!handle) return undefined; - try { - const bytes = Buffer.alloc(HEAD_BYTES); - const read = await handle.read(bytes, 0, bytes.length, 0); - const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); - if ( - !meta || - meta.id !== candidate.id || - meta.cwd !== candidate.meta.cwd || - meta.originator !== candidate.meta.originator - ) - return undefined; - return { id: candidate.id, path: candidate.path }; - } finally { - await handle.close(); - } + return candidates; } diff --git a/src/agent-staging.ts b/src/agent-staging.ts index 2dbd2382..84795170 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -6,10 +6,30 @@ import { createHash, randomUUID } from 'node:crypto'; import { stripAnsi } from './utils/regex-patterns.js'; import { visibleWidth } from './tui/tui-ansi.js'; -import { CODEX_REASONING_EFFORTS } from './types/session.js'; -const FOOTER_LINE = new RegExp( - String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )` -); +import { compileVersionRegex } from './config/cli-registry/patterns.js'; +import type { CliEntry } from './config/cli-registry/types.js'; + +/** + * The screen chrome a guarded composer read relies on, both from the CLI registry + * (`capabilities.workDetect`): the composer glyph and the pattern every row below an + * idle composer must match. Nothing here names a CLI; a CLI takes part in guarded + * input by declaring `composerFooterLine`. + */ +export interface GuardedComposerChrome { + promptGlyph: string; + footerLine: RegExp; +} +/** The chrome a registry entry declares, or null (no guarded input for that CLI). */ +export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { + const detect = entry?.capabilities.workDetect; + if (!detect?.promptGlyph || !detect.composerFooterLine) return null; + // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. + const footerLine = compileVersionRegex(detect.composerFooterLine); + return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null; +} +const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const composerRow = (chrome: GuardedComposerChrome) => + new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); export const STAGE_MAX_BYTES = 8192; export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); export interface GuardedInputSnapshot { @@ -34,19 +54,21 @@ interface Receipt extends GuardedInputSnapshot { export class GuardedInputConflict extends Error {} /** - * Conservatively read a Codex composer: exactly one visible, unwrapped row and a - * known footer. Never remove internal spaces, infer an unknown shell prompt, or - * interpret dialog options as authority. An unsupported frame yields no proof. + * Conservatively read a composer: exactly one visible, unwrapped row and a known + * footer, both as the registry describes them. Never remove internal spaces, infer + * an unknown shell prompt, or interpret dialog options as authority. An unsupported + * frame yields no proof. */ -export function readGuardedCodexComposer(screen: string): string | null { +export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null { + const isComposer = composerRow(chrome); const lines = stripAnsi(screen) .split('\n') .map((line) => line.trimEnd()); - const rows = lines.map((line, index) => ({ line, index })).filter((row) => /^\s*›(?:\s|$)/.test(row.line)); + const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line)); const last = rows.at(-1); if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); - if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null; + if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -65,15 +87,22 @@ export function readGuardedCodexComposer(screen: string): string | null { } } else if (!dim) visible += token; } - return stripAnsi(visible).trimStart().slice(2).trimEnd(); + // The glyph and the one space after it are the prefix, not input. + return stripAnsi(visible) + .trimStart() + .slice(chrome.promptGlyph.length + 1) + .trimEnd(); } /** Require the capture's caret at the exact end of the unwrapped composer. */ -export function readGuardedCodexSnapshot(snapshot: string): string | null { - return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null; +export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { + return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; } /** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ -export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null { +export function readGuardedSnapshotDetails( + snapshot: string, + chrome: GuardedComposerChrome +): { composer: string; prefixWidth: number } | null { // formatPaneSnapshot emits one absolute row address per line, then the caret. // Plain captures lack caret evidence and cannot prove invisible trailing edits. // eslint-disable-next-line no-control-regex @@ -87,16 +116,17 @@ export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: s if (Number(position[1]) !== i + 1 || position[2] !== '1') return null; rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index)); } - const composer = readGuardedCodexComposer(rows.join('\n')); + const composer = readGuardedComposer(rows.join('\n'), chrome); if (composer === null) return null; - let composerRow = -1; + const isComposer = composerRow(chrome); + let row = -1; for (let i = rows.length - 1; i >= 0; i--) - if (/^\s*›(?:\s|$)/.test(stripAnsi(rows[i]))) { - composerRow = i; + if (isComposer.test(stripAnsi(rows[i]))) { + row = i; break; } - const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); - if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) + const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`)); + if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) return null; return { composer, prefixWidth: visibleWidth(prefix[1]) }; } diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 8db5ad3e..b8e09d51 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -2,11 +2,13 @@ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running * inside a Codeman session, in every CLI mode. * - * A thin HTTP client over endpoints that already exist (`quick-start`, `input`, - * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the - * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It - * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so - * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is + * A thin HTTP client over the server's endpoints (`quick-start`, `input`, `wait`, + * `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, the agent inbox: + * `inbox`, `inbox/ack`, `agent-inbox/summary`, and the experimental, opt-in + * `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403 + * until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no + * transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership + * and the per-session waiter cap apply unchanged. The behaviour is * the packaged agent skill's (`skills/codeman`), ported from shell prose into code * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only * preamble — has the same verbs from one line in its AGENTS.md. @@ -1272,7 +1274,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('inspect ') - .description('Experimental: bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors' + ) .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') .option('--since ', 'Resume a cursor from the same owner, pane and transcript') .option('--json', 'Machine-readable output') @@ -1289,7 +1293,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage ') .description( - 'Experimental: type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' ) .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') @@ -1299,7 +1303,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('submit ') - .description('Experimental: send Enter once only when the staged composer, pane and hash still match') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match' + ) .requiredOption('--stage-id ', 'One-shot stage receipt') .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') .option('--json', 'Machine-readable output') diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index de4a5542..3876310b 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -384,6 +384,16 @@ const capabilitiesSchema = z // marker but never the footer. Still bounded, since it is also how much text the // pattern runs over. blockedLines: z.number().int().min(1).max(24).optional(), + // Same guard again: run over the rows under the composer each time guarded + // input reads the pane, and ~/.codeman/clis.json can set it. + composerFooterLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), }) .strict() // A window with nothing to search is a typo, not a configuration. Refused at LOAD diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1b0e00ea..59bf4951 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -13,6 +13,14 @@ import type { CliEntry } from './types.js'; import { CODEX_REASONING_EFFORTS } from '../../types/session.js'; +/** + * Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what + * codex prints when no effort is configured), as one regex alternation. Shared by the two + * patterns that read that footer (`modelDetect.screenLine` and + * `workDetect.composerFooterLine`), so a new level is a one-place edit. + */ +const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|'); + const HOME_DIRS = { local: '~/.local/bin', usrLocal: '/usr/local/bin', @@ -734,6 +742,12 @@ const CODEX: CliEntry = { watchingLines: 4, blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`, blockedLines: 20, + // The rows codex draws under an idle composer, tested one at a time by guarded + // stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default · + // ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the + // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older + // `NN% context left` footer. Indented, so the `›` composer row itself never matches. + composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`, }, // The footer under the composer, measured on a live 0.147.0 pane: // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, @@ -757,7 +771,7 @@ const CODEX: CliEntry = { // and the `›` composer, and a forged footer-shaped transcript line is not followed by // an indented row, so it is not read. modelDetect: { - screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`, + screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`, screenLines: 2, }, // App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 81fd48b4..0f5a97f1 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -432,6 +432,15 @@ export interface CliCapabilities { * this is sized for the narrowest pane the CLI was measured at. Default 1. */ blockedLines?: number; + /** + * Source of a regex every non-blank row BELOW an idle composer must match (at most + * three rows): the footer and hint rows this CLI draws there and nowhere else. It is + * what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove + * that the `promptGlyph` row they read is this CLI's own composer, at rest, and not + * a dialog option or a transcript line the agent printed. A CLI that omits it never + * takes guarded input: stage and submit refuse it. Tested one row at a time. + */ + composerFooterLine?: string; }; /** * How many columns this CLI indents its transcript body by, so a copy taken from its diff --git a/src/session.ts b/src/session.ts index 8d3a5791..3b965eb8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,7 +129,12 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; -import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js'; +import { + guardedComposerChrome, + promptSha256, + readGuardedSnapshotDetails, + type GuardedInputSnapshot, +} from './agent-staging.js'; import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; @@ -4764,10 +4769,12 @@ export class Session extends EventEmitter { this.remote || this.docker || this.paneExit || - getCli(this.mode)?.capabilities.transcript !== 'codex-rollout' || !this._muxSession ) return null; + // Only a CLI whose registry entry describes its composer chrome takes guarded input. + const chrome = guardedComposerChrome(getCli(this.mode)); + if (!chrome) return null; const capture: import('./mux-interface.js').PaneCaptureOptions = {}; const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); if (!screen) return null; @@ -4778,7 +4785,7 @@ export class Session extends EventEmitter { const working = this._workingLinePattern(); working.lastIndex = 0; if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; - const composer = readGuardedCodexSnapshotDetails(screen); + const composer = readGuardedSnapshotDetails(screen, chrome); return composer === null ? null : { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0f17c78d..1e585379 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -697,6 +697,9 @@ 'New sessions for this case land in this group': '此案例的新会话将放入此分组', 'Could not save the default session group.': '无法保存默认会话分组。', 'Remote auto-reconnect': '远程自动重连', + 'Experimental agent coordination': '实验性智能体协作', + "Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.": + '允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。', 'Pin tab names on the CLI': '在 CLI 上固定标签名', "Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.": '以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。', diff --git a/src/web/public/index.html b/src/web/public/index.html index b52a2956..49ce6bf4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2178,6 +2178,13 @@ +
+
+ Experimental agent coordination + Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request. +
+ +
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f45..162fabb7 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -541,6 +541,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Agent inbox snapshot on disk: synced, default OFF. document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true; + // Experimental agent coordination (inspect/stage/submit routes): synced, default OFF. + document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. @@ -2929,6 +2931,7 @@ Object.assign(CodemanApp.prototype, { configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), ...this._configBackupRetentionPayload(_prev), agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked, + agentCoordinationEnabled: document.getElementById('appSettingsAgentCoordination').checked, readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index 662cf41f..280138ff 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -2,6 +2,11 @@ * @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints. * Ownership is checked before any disk or pane read. Native evidence comes only * from strict conversation attribution; all other modes get labeled snapshots. + * + * Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the + * setting, until the synced `agentCoordinationEnabled` is on (default OFF), the + * same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first, + * before session lookup, so a disabled install reveals nothing about its sessions. */ import type { FastifyInstance } from 'fastify'; import type { Session } from '../../session.js'; @@ -10,10 +15,32 @@ import { getCli } from '../../config/cli-registry/registry.js'; import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; -import { ApiErrorCode, createErrorResponse } from '../../types.js'; -import { findSessionOrFail, getAuthUser, parseBody } from '../route-helpers.js'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; +/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */ +export async function readAgentCoordinationEnabled(): Promise { + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + return settings.agentCoordinationEnabled === true; +} + +/** The refusal every route sends while the setting is off. Exported for the tests. */ +export const AGENT_COORDINATION_DISABLED_MESSAGE = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + +async function gate(): Promise | null> { + if (await readAgentCoordinationEnabled()) return null; + return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE); +} + +/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */ +export function errorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code; + return error instanceof Error ? error.constructor.name : 'unknown'; +} + export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { const reader = new InspectionReader(); const stages = new GuardedStageStore(); @@ -33,6 +60,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor submit: (expected) => session.submitGuardedInput(expected), }); app.get('/api/sessions/:id/inspect', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id, req); const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query); @@ -99,19 +128,24 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor } catch (error) { if (error instanceof InspectionConflict) return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message)); - // Native paths and arbitrary IO errors must never escape into the response. - return reply - .code(409) - .send(createErrorResponse(ApiErrorCode.CONFLICT, 'Inspection source unavailable; retry without a cursor')); + // Anything else (an unreadable rollout, an IO error) is not a retryable cursor + // conflict. Log the session and the error code only: the message can carry the + // native transcript path, which must never reach the log or the response. + console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`); + return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable')); } }); app.post('/api/sessions/:id/stage', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); const { message } = parseBody(AgentStageSchema, req.body); try { const receipt = await stages.stage(target(session), getAuthUser(req).username, message); return { success: true, data: receipt }; } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`); return reply .code(409) .send( @@ -125,6 +159,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor } }); app.post('/api/sessions/:id/submit', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body); try { @@ -133,6 +169,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha), }; } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`); return reply .code(409) .send( diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 295441d2..0cb55d1b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1544,6 +1544,13 @@ export const SettingsUpdateSchema = z * inboxes stay). On, pending messages between agents survive a server restart. */ agentInboxPersistEnabled: z.boolean().optional(), + /** + * Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded + * inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`. + * SYNCED, default OFF: the routes are labeled experimental and may change in a minor or + * patch release, so automation has to opt in. While OFF, all three routes answer 403. + */ + agentCoordinationEnabled: z.boolean().optional(), /** * Read My Mind predictor model override. Empty/absent = the AI-checker * default (opus: prediction quality is the product and it runs only on an diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index d3bc9cad..a1292275 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -1,9 +1,13 @@ /** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */ -import { afterEach, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { findExactCodexConversation, resolveEffectiveCodexHome } from '../src/agent-codex-identity.js'; +import { + __clearCodexAttributionMemo, + findExactCodexConversation, + resolveEffectiveCodexHome, +} from '../src/agent-codex-identity.js'; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); @@ -154,3 +158,80 @@ it.each(['workspace', 'metadata'] as const)( } } ); + +describe('remembered attribution on a large, never-pruned store', () => { + beforeEach(() => __clearCodexAttributionMemo()); + const OWNED = '33333333-3333-4333-8333-333333333333'; + const NEWER = '44444444-4444-4444-8444-444444444444'; + /** codex's own layout: sessions/YYYY/MM/DD/rollout--.jsonl */ + async function dated(f: Awaited>, day: string, id: string, payload: object = {}) { + const dir = join(f.home, 'sessions', ...day.split('/')); + await mkdir(dir, { recursive: true }); + const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + } + /** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */ + async function unrelated(f: Awaited>, count: number) { + const instructions = 'x'.repeat(19 * 1024); + for (let start = 0; start < count; start += 200) { + await Promise.all( + Array.from({ length: Math.min(200, count - start) }, (_, k) => { + const n = start + k; + const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`; + return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, { + originator: 'codex_cli', + instructions, + }); + }) + ); + } + } + const small = { maxEntries: 20, maxBytes: 1048576 }; + + it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => { + const f = await fixture(); + await unrelated(f, 2100); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }, 60_000); + + it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => { + const f = await fixture(); + await unrelated(f, 300); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 20 entries cannot cover 300 rollouts in 9 months of day directories: only the + // remembered head plus the directories not older than it fit. + expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path }); + __clearCodexAttributionMemo(); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + }); + + it('still catches a newer owned rollout (codex /new) after remembering one', async () => { + const f = await fixture(); + await unrelated(f, 50); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await dated(f, '2026/10/25', NEWER); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + }); + + it('drops the remembered file when it is replaced, and walks again', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await rm(path); + await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + const moved = await dated(f, '2026/10/23', NEWER); + expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved }); + }); +}); diff --git a/test/agent-coordination-setting.test.ts b/test/agent-coordination-setting.test.ts new file mode 100644 index 00000000..57ac54b1 --- /dev/null +++ b/test/agent-coordination-setting.test.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the + * experimental inspect/stage/submit routes: the strict settings schema admits it, App + * Settings loads and saves it under the same control id, and its row reads in Chinese + * (zh-CN) with no English left beyond the command and product names. The route gate itself + * is pinned in test/routes/agent-session-routes.test.ts. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); +const ID = 'appSettingsAgentCoordination'; + +type Api = { configure(o: Record): void; start(): void }; + +/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */ +const leftover = (text: string) => + text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById(ID)!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('agentCoordinationEnabled', () => { + it('is a boolean the strict settings schema accepts, and nothing else', () => { + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false); + }); + + it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => { + expect(INDEX.split(`id="${ID}"`)).toHaveLength(2); + expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox'); + expect(SETTINGS_UI).toContain( + `document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;` + ); + expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`); + }); + + it('sits in the synced Cross-session features group', () => { + const group = source.window.document.getElementById(ID)!.closest('.set-group')!; + expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features'); + expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced'); + }); + + it('finds the row (the translation check is not vacuous)', () => { + expect(english.label).toBe('Experimental agent coordination'); + expect(english.description).toMatch(/^Lets agents read another session's output/); + }); + + it('translates the label and the description, with no English left', () => { + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 6a9c1ad3..976e2cbd 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -2,7 +2,18 @@ import { beforeEach, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; -import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; +import { + GuardedStageStore, + guardedComposerChrome, + readGuardedComposer, + readGuardedSnapshot, + type GuardedInputTarget, +} from '../src/agent-staging.js'; +import { getCli, listClis } from '../src/config/cli-registry/registry.js'; +/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */ +const CODEX = guardedComposerChrome(getCli('codex'))!; +const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX); +const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX); const sha = (s: string) => createHash('sha256').update(s).digest('hex'); let store: GuardedStageStore; let time: number; @@ -110,7 +121,6 @@ it('does not issue a receipt on failed staging or intervening input during its w }); it('reads exact composer spaces and refuses wrapped or modal frames', async () => { - const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world'); expect( readGuardedCodexComposer( @@ -122,7 +132,6 @@ it('reads exact composer spaces and refuses wrapped or modal frames', async () = expect(readGuardedCodexComposer('› hello\n')).toBeNull(); }); it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => { - const { readGuardedCodexSnapshot } = await import('../src/agent-staging.js'); const { formatPaneSnapshot } = await import('../src/tmux-manager.js'); const frame = (text: string, x: number, y = 0) => formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y }); @@ -157,7 +166,6 @@ it('reserves receipt capacity before concurrent stages type', async () => { it.each([...CODEX_REASONING_EFFORTS, 'default'])( 'accepts the shared Codex effort word %s in the footer', async (word) => { - const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); } ); @@ -168,3 +176,22 @@ it('refuses messages that reach the available pane columns before typing', async expect(writes).toEqual([]); await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); }); + +it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.promptGlyph).toBe(codex.promptGlyph); + expect(CODEX.footerLine.source).toBe(codex.composerFooterLine); + // Another glyph drives the same reader: nothing in it is codex-specific. + const other = { promptGlyph: '>>', footerLine: CODEX.footerLine }; + expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello'); + expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull(); + const declaring = listClis() + .filter((entry) => entry.capabilities.workDetect?.composerFooterLine) + .map((entry) => entry.id); + expect(declaring).toEqual(['codex']); + expect(guardedComposerChrome(getCli('claude'))).toBeNull(); + // A pattern the ReDoS guard refuses yields no chrome, so no guarded input. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); +}); diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts index a0b023a7..d1858a3a 100644 --- a/test/cli-agent-inspection.test.ts +++ b/test/cli-agent-inspection.test.ts @@ -92,3 +92,36 @@ it('registers required file and confirmation options and bounded inspect flags', ?.mandatory ).toBe(true); }); +it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const message = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + for (const json of [false, true]) { + const lines: string[] = []; + const refusing: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json, + now: () => 1, + io: { out: (line) => lines.push(line), err: (line) => lines.push(line) }, + request: async () => ({ + status: 403, + text: '', + json: { success: false, error: message, errorCode: 'FORBIDDEN' }, + }), + }; + expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1); + expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1); + expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1); + expect(lines).toHaveLength(3); + for (const line of lines) { + const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line; + expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 9fa1c6ee..5c4a468c 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -184,6 +184,27 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerFooterLine to the same guard', () => { + // Run over the rows under the composer each time guarded input reads the pane. + for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerFooterLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + }); + + it('accepts every shipped composerFooterLine', () => { + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerFooterLine; + if (!src) continue; + expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull(); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 27fbd415..8bafbb15 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -1,11 +1,118 @@ /** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ -import { afterEach, describe, expect, it } from 'vitest'; -import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AGENT_COORDINATION_DISABLED_MESSAGE, + registerAgentSessionRoutes, +} from '../../src/web/routes/agent-session-routes.js'; +import { InspectionReader } from '../../src/agent-inspection.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; let harness: RouteTestHarness; +/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */ +function setEnabled(on: boolean | undefined): void { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on })); +} +beforeEach(() => setEnabled(true)); afterEach(async () => { await harness?.app.close(); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('the agentCoordinationEnabled gate', () => { + const requests = (id: string) => [ + { method: 'GET' as const, url: `/api/sessions/${id}/inspect` }, + { method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` }, + { method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]; + it.each([ + ['absent', undefined], + ['false', false], + ] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => { + setEnabled(value); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + // A session id that does not exist is refused the same way: the gate runs before lookup. + for (const request of [...requests(session.id), ...requests('no-such-session')]) { + const result = await harness.app.inject(request); + expect(result.statusCode).toBe(403); + expect(result.json()).toEqual({ + success: false, + error: AGENT_COORDINATION_DISABLED_MESSAGE, + errorCode: 'FORBIDDEN', + }); + } + expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled'); + expect(session.writeBuffer).toEqual([]); + }); + it('answers on every route once it is on, with no restart', async () => { + setEnabled(false); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403); + setEnabled(true); + const [inspect, invalid, stage, submit] = await Promise.all( + requests(session.id).map((request) => harness.app.inject(request)) + ); + expect(inspect.statusCode).toBe(200); + expect(inspect.json().data.source).toBe('pane-snapshot'); + expect(invalid.statusCode).toBe(400); + // The mock session has no characterized composer, so the guards refuse: the route answered. + expect(stage.statusCode).toBe(409); + expect(submit.statusCode).toBe(409); + }); +}); + +it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-fail', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n' + ); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + const read = vi + .spyOn(InspectionReader.prototype, 'read') + .mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` }); + expect(read).toHaveBeenCalled(); + expect(result.statusCode).toBe(422); + expect(result.json().errorCode).toBe('OPERATION_FAILED'); + expect(result.body).not.toContain(path); + const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(logged).toContain(session.id); + expect(logged).toContain('EACCES'); + expect(logged).not.toContain(home); + } finally { + read.mockRestore(); + warn.mockRestore(); + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } }); describe('agent session routes', () => { it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index 6bce845a..020b83a4 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -45,8 +45,32 @@ describe('WebServer on an ephemeral port', () => { const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); const sessions = (server as unknown as { sessions: Map }).sessions; sessions.set(session.id, session); + const { mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { dirname } = await import('node:path'); + const { SETTINGS_PATH } = await import('../src/web/route-helpers.js'); + const setEnabled = (on: boolean) => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ agentCoordinationEnabled: on })); + }; try { const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + // Opt-in: off, the /api/v1 alias of every route refuses with 403 FORBIDDEN. + setEnabled(false); + for (const [method, verb] of [ + ['GET', 'inspect'], + ['POST', 'stage'], + ['POST', 'submit'], + ] as const) { + const refused = await fetch(prefix + '/' + verb, { + method, + ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}), + }); + expect(refused.status).toBe(403); + const body = await refused.json(); + expect(body.errorCode).toBe('FORBIDDEN'); + expect(body.error).toContain('agentCoordinationEnabled'); + } + setEnabled(true); const inspected = await fetch(prefix + '/inspect'); expect(inspected.status).toBe(200); expect((await inspected.json()).data.source).toBe('pane-snapshot'); @@ -63,6 +87,7 @@ describe('WebServer on an ephemeral port', () => { expect((await response.json()).errorCode).toBe('CONFLICT'); } } finally { + rmSync(SETTINGS_PATH, { force: true }); sessions.delete(session.id); await session.stop(false); } From 2832a037d6884f83767a4cb49d6c86f757403a2d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 25 Oct 2026 09:22:35 -0400 Subject: [PATCH 5/9] fix(agent): verify staged text on a live codex pane and refuse popups Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and issues a receipt only for a frame with the same generation, the input revision its own write moved, and a composer hash equal to the message's. Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed. Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame capture reads the screen and the caret in one tmux invocation, with the old two calls as the fallback. Codex draws its suggestion popups above the composer and keeps the footer, so a staged /stat verified and submit ran /status. Stage now refuses a leading / or ! and a last word starting with @ or $ before typing, and stage and submit refuse a frame with popup rows just above the composer. The pattern and characters are registry data (composerPopupLine, composerCommandPrefixes, composerCompletionSigils), read from the codex 0.162.0 bottom_pane sources. Submit waits until 250 ms after the staged keys: codex turns an Enter within 120 ms of a typed burst into a newline. POST .../input answers 409 with its seq returned while a guarded submit holds the input lease, instead of 200 for bytes it never wrote. Also align the attribution memo comment with the code (a verified file whose incremental scan fails is not re-walked, since the full walk reads a superset), and list stage as a fromUser source in CLAUDE.md. --- .changeset/bounded-agent-coordination.md | 2 + CLAUDE.md | 4 +- docs/api-reference.md | 56 +++++- docs/architecture-invariants.md | 32 ++- docs/cli-registry.md | 23 ++- docs/wiki/Driving-Codeman-From-An-Agent.md | 8 +- .../reference/inspection-and-staging.md | 33 ++- .../codeman/skills/codeman/reference/verbs.md | 4 +- .../reference/inspection-and-staging.md | 33 ++- skills/codeman/reference/verbs.md | 4 +- src/agent-codex-identity.ts | 13 +- src/agent-staging.ts | 169 ++++++++++++++-- src/cli-agent.ts | 2 +- src/config/cli-registry/schema.ts | 19 ++ src/config/cli-registry/stock.ts | 22 ++ src/config/cli-registry/types.ts | 24 +++ src/session.ts | 8 + src/tmux-manager.ts | 44 +++- src/web/routes/agent-session-routes.ts | 11 +- src/web/routes/session-routes.ts | 18 ++ test/agent-codex-identity.test.ts | 16 ++ test/agent-staging-session.test.ts | 73 ++++++- test/agent-staging.test.ts | 189 +++++++++++++++++- test/cli-registry-schema.test.ts | 42 ++++ test/routes/agent-session-routes.test.ts | 11 + test/routes/session-routes.test.ts | 26 +++ test/tmux-capture-full-history.test.ts | 37 +++- 27 files changed, 849 insertions(+), 74 deletions(-) diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 73f9513a..2ff2c0af 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -10,6 +10,8 @@ Codex rollout attribution remembers the attributed file per session and re-verif The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. +Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. While a guarded submit is in flight, `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned instead of a `200` for input that was never written. + Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/CLAUDE.md b/CLAUDE.md index a5e8101f..6cf35451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) @@ -189,7 +189,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` -**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) +**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift. diff --git a/docs/api-reference.md b/docs/api-reference.md index 2d4c6161..d99d4ef2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -426,6 +426,11 @@ worker's prompt but never submitted, and the wait then runs its full timeout on turn that never started. Verified live; this is the most common silent failure on this endpoint. +Any input, waiting or not, answers `409 CONFLICT` with nothing written while an +experimental guarded submit holds the session's input lease (a few milliseconds; +see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +Retry it. + A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as a separate key, and the server re-presses Enter while the prompt is still visibly @@ -630,8 +635,11 @@ final identity verification). The negative filter reads 1 KiB of each rollout first and 4 KiB only when that window holds no complete `originator` field. Once a rollout is attributed, later calls re-verify only that file (same file identity, same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not -older than its own, for a second candidate such as a codex `/new`; a failed check -falls back to one full walk. So the caps bound the first call, not every call. +older than its own, for a second candidate such as a codex `/new`. A file that no +longer verifies falls back to one full walk; a verified file whose incremental scan +finds a second candidate or runs out of budget answers unattributed without one, +since the full walk reads a superset of that scan. So the caps bound the first call, +not every call. An unexpected read error is `422 OPERATION_FAILED` (logged with the session id and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain @@ -670,8 +678,43 @@ actual captured pane width and rendered composer indentation, refusing any message that would reach the right edge or wrap. Every declared Codex effort word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), -never persisted prompt content. A receipt acknowledges typing; a delayed screen -paint can still make submit refuse until exact verification becomes possible. +never persisted prompt content. + +Stage refuses, before typing anything, a message that would change what Enter +does rather than what it sends, from the registry's +`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message +starting with `/` (a slash command; codex runs it, or with its command list open +runs the highlighted one) or `!` (a shell command), checked after any leading +whitespace, and a message whose last word starts with `@` (file and mention +search) or `$` (skills; every `$` word, since whether codex opens that list +depends on its skill catalog). Those popups follow the caret, which stage leaves +at the end of the message, so a `@` or `$` word earlier in the message is +accepted. Send slash and shell commands through `POST .../input` +(`codeman agent send`). Independently, a frame with a suggestion popup in the +block directly above the composer (the registry's `composerPopupLine`) is refused +at stage and submit, since codex draws every popup there and leaves the footer +below the composer unchanged. + +A receipt is issued only for a capture that shows exactly the message with the +caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive, +so after typing stage re-reads the pane every 50 ms for up to one second and +takes the first frame with the same pane generation, the input revision its own +write moved and a composer whose SHA-256 equals the message's. Each capture +reads the screen and the caret in one tmux invocation. A frame showing another +writer's input, a new pane lifecycle or a new geometry ends the wait at once. +Every refusal after the keys went out (typing failed or was ambiguous, the +pane changed, or no frame verified in time) is a `409` that says the message +may already be in the composer: inspect the pane and follow the stranded-draft +recovery (Ctrl+U) in the skill reference before staging again. + +While a guarded submit holds the input lease (the few milliseconds of its +conditional Enter), every other Codeman write path refuses, and +`POST /api/v1/sessions/:id/input` answers `409 CONFLICT` ("A guarded submit is in +progress on this session; nothing was written") instead of a `200` for bytes +that were never written. A tagged `clientId`/`seq` frame keeps its seq, so the +browser's durable queue, which keeps a frame on a `409`, delivers it on the next +retry. WebSocket keystrokes in that window are left unacknowledged and redelivered +the same way. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve @@ -679,7 +722,10 @@ input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, -confirmation hash, expiry, lifecycle/input/geometry revisions and exact composer/caret, +confirmation hash and expiry, then waits until at least 250 ms have passed since +the staged keys went out (codex reads keys typed in one burst as a paste and, for +120 ms after one, turns Enter into a newline instead of a submit), then verifies +lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index edd12634..d0fd2444 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. -**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. **The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. @@ -1290,8 +1290,11 @@ metadata are remembered per session (`attributionMemo`, bounded LRU). A later ca re-verifies only that file's head, then scans just the date directories not older than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is never pruned) for a second candidate, which is how a codex `/new` or a new owned -rollout beside a configured resume is still caught. A failed check drops the memo -and falls back to one full walk. Non-`InspectionConflict` errors are `422 +rollout beside a configured resume is still caught. A head that no longer verifies +drops the memo and falls back to one full walk; a verified head whose incremental +scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it +and answers unattributed without one, because the full walk reads a superset of that +scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422 OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and error code only, never the path. @@ -1301,8 +1304,25 @@ footer are registry data (`capabilities.workDetect.promptGlyph` and declaring `composerFooterLine` takes guarded input (codex today). Codex's footer words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, -including indentation; reaching the last column or wrapping is refused. Stage -cancels older verifier retries and sends no Enter. Receipts contain hashes and +including indentation; reaching the last column or wrapping is refused. A message +starting with a `composerCommandPrefixes` character or whose last word starts with a +`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a +frame with a `composerPopupLine` row in the block just above the composer is refused at +stage and submit: codex draws its popups there and keeps its footer, so the footer +alone read `› /stat` with the command list open as idle, and Enter ran `/status`. +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` +50) for the first frame with the same generation, `revision === before + 1` and +`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys +and the first capture is often from before them; another writer or a lifecycle change +ends the wait at once, and every refusal after typing says the message may be in the +composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call +(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to +the two calls on any doubt; keep the poll anyway. While a guarded submit holds the +lease, `POST .../input` answers `409` (seq returned) instead of a `200` for unwritten +bytes. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and +an Enter within 120 ms of one becomes a newline while submit would report it +delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and bounded ephemeral identities only, expire, and are consumed before awaited submission, including failures. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 955b6f88..a2a029e8 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,10 +47,12 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines?, composerFooterLine? } + // blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?, + // composerCommandPrefixes?, composerCompletionSigils? } // (how this CLI's pane shows work, work it started in the background, a turn // that ended waiting for workers it will resume from, a dialog it is blocked on, - // and the rows under its idle composer that guarded input relies on) + // the rows around its idle composer that guarded input relies on, and what a + // draft can trigger) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -64,7 +66,7 @@ interface CliEntry { ### Regexes that come from config -Seven capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine` and `capabilities.modelDetect.screenLine`. All seven go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -137,6 +139,21 @@ effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the foot of the screen, so a pattern must anchor on chrome only that CLI draws there. +Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup +(its slash-command list, file search, skill and mention lists) ABOVE the composer and +leaves the footer below it alone, so the footer check reads `› /stat` with the command +list open as an idle draft, and Enter would run the highlighted `/status`. +`composerPopupLine` matches a popup row; it is tested against the contiguous block of +non-blank rows directly above the composer (past at most three blank rows, at most twelve +rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists +the characters that, first in a draft, make Enter run a command (codex: `/!`), and +`composerCompletionSigils` the ones that, starting the word under the caret, open a popup +(codex: `@$`); stage refuses such a message before typing, the sigils only on the last +word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's +values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search +popup with results shows bare path rows no pattern can tell from prose, which is why the +trigger refusal is there as well as the frame check. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 18d21b5d..84c57644 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -75,9 +75,11 @@ codeman agent rm "$SID" # any session except t [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by default), and they may change in any release. `inspect` returns bounded evidence with an opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a - local Codex composer, with no Enter, and returns a receipt that expires after two minutes; - `submit` sends one Enter only if the composer still shows exactly that text, and never - replays it. `send --no-enter` alone has no receipt. Details: + local Codex composer, with no Enter, and returns a receipt that expires after two minutes + once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands + and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter + would accept), so send commands with `send`. `submit` sends one Enter only if the + composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details: [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index c53530aa..960a1f13 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -78,15 +78,32 @@ wrapped, remote/container and shell composers are refused. A customized footer o unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + The receipt stores a hash and ephemeral identities, never prompt content. It -expires after two minutes; capacity is 1024 live receipts. Stage delivery can -precede the next screen paint: a receipt acknowledges typing, while submit still -requires the fully painted exact composer. If typing fails or is ambiguous, stage -issues no receipt; inspect the pane before trying again. +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds), `send` and other input to that +session answer `409 CONFLICT` with nothing written; retry the input. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input -boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. @@ -98,10 +115,10 @@ Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. -## Recover after a refused submit +## Recover a stranded draft -A consumed receipt cannot be reused. First observe whether the prompt was -accepted: use transcript evidence/wait signals and a fresh terminal capture +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture (`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; do not clear or resend anything. A transcript prompt row alone is not proof diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 85a7b981..3982b305 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -551,8 +551,8 @@ that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified - draft merely to clear it. For a refused guarded local Codex submit, use the - verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index c53530aa..960a1f13 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -78,15 +78,32 @@ wrapped, remote/container and shell composers are refused. A customized footer o unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. +Stage also refuses, before typing, a message that would change what Enter does: +one starting with `/` (a Codex slash command; with its command list open, Enter +would run the highlighted command instead) or `!` (a shell command), and one whose +**last** word starts with `@` (file/mention search) or `$` (skills). Those popups +follow the caret, which stage leaves at the end, so `look at @src/app.ts please` +is fine. Send slash and shell commands with `codeman agent send`, and end a +message with an ordinary word when it mentions a file. A frame showing a +suggestion popup above the composer is refused at stage and submit too. + The receipt stores a hash and ephemeral identities, never prompt content. It -expires after two minutes; capacity is 1024 live receipts. Stage delivery can -precede the next screen paint: a receipt acknowledges typing, while submit still -requires the fully painted exact composer. If typing fails or is ambiguous, stage -issues no receipt; inspect the pane before trying again. +expires after two minutes; capacity is 1024 live receipts. After typing, stage +re-reads the pane (every 50 ms, up to one second, since Codex repaints a little +after the keys) and issues a receipt only for a frame that shows exactly the +message with the caret after it. Any refusal after the keys went out (typing +failed or was ambiguous, the pane or input changed, or no frame verified in time) +says the message may already be in the composer: no receipt was issued. Do not +stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first. + +While a submit's Enter is in flight (milliseconds), `send` and other input to that +session answer `409 CONFLICT` with nothing written; retry the input. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, geometry, exact composer and caret, then sends one conditional Enter at the session's input -boundary. Competing Codeman writes are refused during dispatch. Browser/API/key +boundary. It first waits until 250 ms have passed since stage typed (Codex turns an +Enter that follows typed text within 120 ms into a newline), so submitting right +after stage is fine. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures remain evidence at the time observed, rather than terminal isolation. @@ -98,10 +115,10 @@ Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. -## Recover after a refused submit +## Recover a stranded draft -A consumed receipt cannot be reused. First observe whether the prompt was -accepted: use transcript evidence/wait signals and a fresh terminal capture +A consumed receipt cannot be reused, and a stage refused after typing issued +none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture (`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; do not clear or resend anything. A transcript prompt row alone is not proof diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 85a7b981..3982b305 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -551,8 +551,8 @@ that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified - draft merely to clear it. For a refused guarded local Codex submit, use the - verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + draft merely to clear it. For a refused guarded local Codex stage or submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft); when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 490f7267..1ff2f280 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -77,8 +77,10 @@ function strictMetadata(bytes: Buffer): Metadata | undefined { * this file's head (same `dev:ino`, same strict metadata) and then scans just the * directories not older than its own for a second candidate (codex `/new`, or a new * owned rollout beside a configured resume), instead of re-reading the whole store, - * which codex never prunes. Any failed check drops the entry and falls back to the - * full walk. Bounded: one entry per live session is the expected size. + * which codex never prunes. A head that no longer verifies drops the entry and falls + * back to the full walk; a failed incremental scan drops it and answers unattributed, + * since the full walk reads a superset of that scan. Bounded: one entry per live + * session is the expected size. */ interface AttributionMemo { id: string; @@ -133,8 +135,11 @@ export async function findExactCodexConversation( } } attributionMemo.delete(key); - // A second candidate is a conflict a full walk would report too; anything - // else (a replaced file, an exhausted budget) gets one full walk now. + // A file that no longer verifies (replaced, rewritten, gone) gets one full walk + // now. A verified file whose incremental scan failed does not: that scan reads a + // subset of what a full walk reads under the same budget, so a second candidate, + // an exhausted budget or an unreadable entry it hit is one the full walk would hit + // too, and walking the whole never-pruned store could only end the same way. if (verified) return undefined; } const candidates = await scanCandidates(sessions, options, budget); diff --git a/src/agent-staging.ts b/src/agent-staging.ts index 84795170..f79e1234 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -10,14 +10,21 @@ import { compileVersionRegex } from './config/cli-registry/patterns.js'; import type { CliEntry } from './config/cli-registry/types.js'; /** - * The screen chrome a guarded composer read relies on, both from the CLI registry - * (`capabilities.workDetect`): the composer glyph and the pattern every row below an - * idle composer must match. Nothing here names a CLI; a CLI takes part in guarded - * input by declaring `composerFooterLine`. + * The screen chrome a guarded composer read relies on, all from the CLI registry + * (`capabilities.workDetect`): the composer glyph, the pattern every row below an + * idle composer must match, and what opens a popup or turns Enter into a command. + * Nothing here names a CLI; a CLI takes part in guarded input by declaring + * `composerFooterLine`. */ export interface GuardedComposerChrome { promptGlyph: string; footerLine: RegExp; + /** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */ + popupLine?: RegExp; + /** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */ + commandPrefixes?: string; + /** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */ + completionSigils?: string; } /** The chrome a registry entry declares, or null (no guarded input for that CLI). */ export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { @@ -25,8 +32,42 @@ export function guardedComposerChrome(entry: CliEntry | undefined): GuardedCompo if (!detect?.promptGlyph || !detect.composerFooterLine) return null; // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. const footerLine = compileVersionRegex(detect.composerFooterLine); - return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null; + if (!footerLine) return null; + // A declared popup pattern the guard refuses is no chrome at all: dropping it would + // silently read a popup frame as an idle composer. + const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined; + if (popupLine === null) return null; + return { + promptGlyph: detect.promptGlyph, + footerLine, + ...(popupLine ? { popupLine } : {}), + ...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}), + ...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}), + }; } +/** + * Why a message cannot be staged into this composer, or null. Typing it would change + * what Enter does rather than what it sends: a draft starting with a command prefix + * (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a + * prompt, and a last word starting with a completion sigil (codex: `@` file and + * mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter + * would accept. Checked before anything is typed. Only the LAST word matters for the + * sigils because the caret is at the end of the message and the popup follows the + * caret; a sigil word earlier in the message opens nothing. + */ +export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null { + const first = message.trimStart().charAt(0); + if (first && chrome.commandPrefixes?.includes(first)) + return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`; + const last = message.split(/\s/).at(-1)?.charAt(0); + if (last && chrome.completionSigils?.includes(last)) + return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``; + return null; +} +/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */ +const POPUP_WINDOW_ROWS = 12; +/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */ +const POPUP_GAP_ROWS = 3; const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const composerRow = (chrome: GuardedComposerChrome) => new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); @@ -41,6 +82,8 @@ export interface GuardedInputSnapshot { } export interface GuardedInputTarget { id: string; + /** The session's composer chrome, read before typing for what a message would trigger. */ + chrome(): GuardedComposerChrome | null; snapshot(): GuardedInputSnapshot | null; write(text: string): Promise<boolean>; submit(expected: GuardedInputSnapshot): Promise<boolean>; @@ -50,9 +93,46 @@ interface Receipt extends GuardedInputSnapshot { sessionId: string; expiresAt: number; sha256: string; + /** When the staged keys went out, for `SUBMIT_QUIET_MS`. */ + typedAt: number; } export class GuardedInputConflict extends Error {} +/** + * How long stage keeps reading the pane after typing for a frame that shows the + * message, and how often. A capture taken right after the keys land is often a + * frame from before them: codex repaints its composer 30 to 60 ms after input + * arrives (measured on a live 0.162.0 pane), so the first read can show the old, + * empty composer, or nothing provable at all. A second of 50 ms reads covers that + * many times over and still answers the caller quickly. + */ +export const STAGE_SETTLE_TIMEOUT_MS = 1000; +export const STAGE_SETTLE_INTERVAL_MS = 50; +/** + * The least time between the staged keys and the conditional Enter. Stage types its + * text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a + * paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather + * than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/ + * paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers + * would land inside it and be told one Enter was delivered while codex only broke + * the line. Submit waits out the rest of this window first; a keystroke in that + * time still invalidates the receipt. + */ +export const SUBMIT_QUIET_MS = 250; +/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */ +export interface StageSettleOptions { + timeoutMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise<void>; +} +/** + * Every refusal after the keys went out says so: stage cannot tell whether the text + * landed, so the caller must look before staging again (the recovery steps clear a + * stranded draft with Ctrl+U). + */ +export const MAY_BE_TYPED = + 'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again'; + /** * Conservatively read a composer: exactly one visible, unwrapped row and a known * footer, both as the registry describes them. Never remove internal spaces, infer @@ -69,6 +149,7 @@ export function readGuardedComposer(screen: string, chrome: GuardedComposerChrom if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; + if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -94,6 +175,23 @@ export function readGuardedComposer(screen: string, chrome: GuardedComposerChrom .trimEnd(); } +/** + * Does a suggestion popup sit directly above the composer row? A popup draws over + * the rows above the composer and leaves the footer below it alone, so the footer + * check alone reads a popup frame as an idle composer. Read the contiguous block of + * rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane: + * the transcript further up is the agent's own and may print anything. A transcript + * row that happens to match costs a refusal, never a receipt. + */ +function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean { + let index = composerIndex - 1; + for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--; + for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) { + if (popupLine.test(lines[index])) return true; + } + return false; +} + /** Require the capture's caret at the exact end of the unwrapped composer. */ export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; @@ -134,11 +232,19 @@ export function readGuardedSnapshotDetails( export class GuardedStageStore { private readonly receipts = new Map<string, Receipt>(); private readonly busy = new Set<string>(); + private readonly settleTimeoutMs: number; + private readonly settleIntervalMs: number; + private readonly sleep: (ms: number) => Promise<void>; constructor( private readonly now = Date.now, private readonly capacity = 1024, - private readonly ttlMs = 120000 - ) {} + private readonly ttlMs = 120000, + settle: StageSettleOptions = {} + ) { + this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS; + this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS; + this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } async stage(target: GuardedInputTarget, owner: string, message: string) { // eslint-disable-next-line no-control-regex const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message); @@ -152,6 +258,9 @@ export class GuardedStageStore { throw new GuardedInputConflict( 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace' ); + const chrome = target.chrome(); + const refusal = chrome && composerTriggerRefusal(message, chrome); + if (refusal) throw new GuardedInputConflict(refusal); const key = target.id; if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress'); this.busy.add(key); @@ -169,24 +278,48 @@ export class GuardedStageStore { if (this.receipts.size + this.busy.size > this.capacity) throw new GuardedInputConflict('Guarded stage receipt capacity reached'); if (!(await target.write(message))) - throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); - const after = target.snapshot(); - if ( - !after || - after.generation !== before.generation || - after.revision !== before.revision + 1 || - after.availableColumns !== before.availableColumns - ) - throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); + throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`); + const typedAt = this.now(); + const after = await this.settle(target, before, promptSha256(message)); const stageId = randomUUID(); const expiresAt = this.now() + this.ttlMs; const sha256 = promptSha256(message); - this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256 }); + this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt }); return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const }; } finally { this.busy.delete(key); } } + /** + * The first frame after the write that shows exactly this message, with the input + * revision the write itself moved and nothing else changed. A null or stale frame + * (the old composer, a half-painted one) is read again until the settle window + * ends; a frame showing another writer's keystroke, a new pane lifecycle or a new + * geometry is refused at once, since waiting cannot undo that. + */ + private async settle( + target: GuardedInputTarget, + before: GuardedInputSnapshot, + expectedSha: string + ): Promise<GuardedInputSnapshot> { + const deadline = this.now() + this.settleTimeoutMs; + for (;;) { + const frame = target.snapshot(); + if ( + frame && + (frame.generation !== before.generation || + frame.revision !== before.revision + 1 || + frame.availableColumns !== before.availableColumns) + ) + throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`); + if (frame?.composerSha === expectedSha) return frame; + if (this.now() >= deadline) + throw new GuardedInputConflict( + `The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}` + ); + await this.sleep(this.settleIntervalMs); + } + } async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) { const receipt = this.receipts.get(stageId); if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id) @@ -198,6 +331,8 @@ export class GuardedStageStore { try { if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha) throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch'); + const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now(); + if (quiet > 0) await this.sleep(quiet); const current = target.snapshot(); if ( !current || diff --git a/src/cli-agent.ts b/src/cli-agent.ts index b8e09d51..082c3ee8 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -1293,7 +1293,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage <id>') .description( - 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)' ) .requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 3876310b..8c6486f5 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -394,6 +394,25 @@ const capabilitiesSchema = z 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' ) .optional(), + // Same guard again: run over the rows just above the composer by the same reads. + composerPopupLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), + // Single ASCII punctuation characters, never letters, digits or whitespace: each one + // refuses every message that starts (or whose last word starts) with it. + composerCommandPrefixes: z + .string() + .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters') + .optional(), + composerCompletionSigils: z + .string() + .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters') + .optional(), }) .strict() // A window with nothing to search is a typo, not a configuration. Refused at LOAD diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 59bf4951..187d7359 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -748,6 +748,28 @@ const CODEX: CliEntry = { // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older // `NN% context left` footer. Indented, so the `›` composer row itself never matches. composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`, + // Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/ + // popup_state.rs, composer_layout.rs and the popups' render code and snapshots): + // every suggestion popup renders ABOVE the composer and keeps the footer below it, + // so the footer alone cannot tell `› /stat` with the command list open from an + // idle draft, and Enter then runs the highlighted `/status`. What sits in the + // block just above the composer (past its blank top padding row): + // command list (`/`) `› /status show current session ...` + // ` /statusline configure which items ...` + // skill and mention lists ` enter insert · esc close` (key-hint row) + // (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter` + // file search (`@`) ` loading...` / ` no matches`, else bare path + // rows, which no pattern can tell from prose + // The last case is why stage also refuses the triggers themselves (below). + composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`, + // Same source. A draft starting with `/` is a slash command (the popup opens while + // the caret is in its name, and Enter runs a command either way), and one starting + // with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups + // open for the word under the caret only: `@` (file search, or the unified mention + // list) and `$` (skills and mentions; whether a given `$word` opens it depends on + // codex's skill catalog, so every `$` word is refused). + composerCommandPrefixes: '/!', + composerCompletionSigils: '@$', }, // The footer under the composer, measured on a live 0.147.0 pane: // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 0f5a97f1..fc873070 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -441,6 +441,30 @@ export interface CliCapabilities { * takes guarded input: stage and submit refuse it. Tested one row at a time. */ composerFooterLine?: string; + /** + * Source of a regex matching a row of a suggestion popup this CLI draws directly + * ABOVE its composer (a slash-command list, a file or mention search, its key-hint + * row). The footer below such a composer is unchanged, so without this guarded + * stage/submit would read the popup frame as an idle composer, and Enter would + * accept the highlighted entry instead of sending the draft. Tested one row at a + * time against the contiguous block just above the composer row, never further + * up the pane: a match there refuses the frame. See `readGuardedComposer()`. + */ + composerPopupLine?: string; + /** + * Characters that, as the first character of a draft, make Enter run a command + * rather than send a prompt (codex: `/` slash commands, `!` shell commands). + * Guarded stage refuses such a message before typing; commands go through the + * ordinary input route. + */ + composerCommandPrefixes?: string; + /** + * Characters that, starting the word under the caret, open a completion popup + * whose highlighted entry Enter accepts (codex: `@` file and mention search, `$` + * skills). Guarded stage refuses a message whose LAST word starts with one, + * before typing: the caret ends there, and the popup follows the caret. + */ + composerCompletionSigils?: string; }; /** * How many columns this CLI indents its transcript body by, so a copy taken from its diff --git a/src/session.ts b/src/session.ts index 3b965eb8..1b1410d1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4749,6 +4749,14 @@ export class Session extends EventEmitter { get guardedInputPendingWrites(): number { return this._guardedPendingWrites; } + /** + * True while a guarded submit holds the input lease. Every write path refuses for + * that span (a few milliseconds), so a route that can report a refusal checks this + * first instead of answering as if the bytes went out. + */ + get guardedSubmitInFlight(): boolean { + return this._guardedSubmitInFlight; + } /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ getCodexHomeOverride(): string | undefined { return this._envOverrides?.CODEX_HOME || undefined; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0c52cf39..0eba7b22 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -984,6 +984,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string { return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`; } +/** + * Split the output of `capture-pane -p \; display-message -p '<cursor format>'` + * into the captured rows and the cursor line, which tmux prints last. Null unless + * that last line has the cursor query's shape (four leading integers), so a query + * that printed nothing can never have the frame's own last row taken for it. + */ +export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null { + const body = output.endsWith('\n') ? output.slice(0, -1) : output; + const split = body.lastIndexOf('\n'); + const cursor = body.slice(split + 1); + if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null; + return { frame: body.slice(0, split + 1), cursor }; +} + export function formatPaneSnapshot( lines: string[], geometry: { cols: number; rows: number; cursorX: number; cursorY: number } @@ -4319,9 +4333,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { execOpts.maxBuffer = (opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES; } + const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`; + const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`; + // The visible frame and its caret come from ONE tmux invocation + // (the capture, `\;`, then the cursor query): tmux runs a command list back to back + // in its command queue, with no pane output parsed between the two, so the + // caret belongs to the frame. Two separate calls raced the CLI's repaint: a + // CLI that redraws its composer a few ms after input arrives (codex) could be + // captured before the redraw and its caret queried after it, and guarded + // stage then refused a frame the keys had already reached. Any doubt about + // the combined output (a failed call, a last line that is not the query's) + // falls back to the two separate calls, which is what this always did. + const combined = fullHistory ? null : this.captureFrameWithCursor(`${captureCommand} \\; ${cursorQuery}`); const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts), + combined?.frame ?? execSync(captureCommand, execOpts), scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path @@ -4329,10 +4355,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // appended, and those two have to agree: trailing blank rows are only safe // to keep when a move follows to put the caret back above them. const geometry = queryPaneCursor(() => - execSync( - `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ) + combined + ? combined.cursor + : execSync(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) ); // Report the size the pane was really drawing at. The visible-frame path // below addresses every row absolutely, so a consumer whose terminal is @@ -4398,6 +4423,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** One combined capture-and-cursor-query call, split; null when it failed or is ambiguous. */ + private captureFrameWithCursor(command: string): { frame: string; cursor: string } | null { + try { + return splitFrameAndCursor(execSync(command, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })); + } catch { + return null; + } + } + /** * Capture the active pane for a tmux session. * diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index 280138ff..01101893 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -14,7 +14,13 @@ import type { SessionPort, InfraPort } from '../ports/index.js'; import { getCli } from '../../config/cli-registry/registry.js'; import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; -import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; +import { + GuardedStageStore, + GuardedInputConflict, + guardedComposerChrome, + MAY_BE_TYPED, + type GuardedInputTarget, +} from '../../agent-staging.js'; import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; @@ -51,6 +57,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor const generation = (session: Session) => session.inspectionGeneration; const target = (session: Session): GuardedInputTarget => ({ id: session.id, + chrome: () => guardedComposerChrome(getCli(session.mode)), snapshot: () => { if (ctx.sessions.get(session.id) !== session) return null; const snapshot = session.guardedInputSnapshot?.(); @@ -153,7 +160,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor ApiErrorCode.CONFLICT, error instanceof GuardedInputConflict ? error.message - : 'Stage delivery failed; inspect the pane before retrying' + : `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}` ) ); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index fbceb684..369ea2dc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2077,6 +2077,24 @@ export function registerSessionRoutes( } } + // A guarded submit (agent-staging.ts) holds the input lease for the few + // milliseconds of its conditional Enter, and every write path refuses while it + // does. Say so instead of answering 200 for bytes that were never written, and + // give the seq back so the browser's durable queue (which keeps a frame on any + // 409) delivers it once the lease is released. Nothing from here to the write + // below awaits, so the lease cannot be taken in between. + if (!duplicate && session.guardedSubmitInFlight === true) { + if (tagged) session.forgetInputSeq(clientId as string, seq as number); + return reply + .code(409) + .send( + createErrorResponse( + ApiErrorCode.CONFLICT, + 'A guarded submit is in progress on this session; nothing was written. Retry the input.' + ) + ); + } + // Only a waiting request pays for the tmux probe: the browser's plain input path // (thousands of calls per session) must stay exec-free. const workerDead = wantsWait && workerIsDead(ctx.mux, session); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index a1292275..a0e57176 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -224,6 +224,22 @@ describe('remembered attribution on a large, never-pruned store', () => { expect(await findExactCodexConversation(f.options)).toBeUndefined(); }); + it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 30 rollouts in the remembered day itself: the incremental scan reads them all, + // so 20 entries cannot cover it, and a full walk (a superset) could not either. + await Promise.all( + Array.from({ length: 30 }, (_, n) => + dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' }) + ) + ); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + // The memo was dropped, so a call with room walks the store and attributes again. + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }); + it('drops the remembered file when it is replaced, and walks again', async () => { const f = await fixture(); const path = await dated(f, '2026/10/24', OWNED); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index 8d97cc23..ff106fdf 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -413,16 +413,18 @@ it('refuses a message that would wrap using the actual captured indent and width }, muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters<typeof Session>[0]); - const { GuardedStageStore } = await import('../src/agent-staging.js'); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); try { const store = new GuardedStageStore(); const target = { id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), snapshot: () => session.guardedInputSnapshot(), write: (text: string) => session.stageGuardedInput(text), submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), }; - await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(); + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/); expect(writes).toEqual([]); } finally { await session.stop(false); @@ -464,3 +466,70 @@ it('records a verifier re-press as the latest submit time', async () => { vi.useRealTimers(); } }); + +it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => { + // Reproduces the live 0.162.0 race: right after the keys land, the screen still + // shows the empty composer while the caret is already past the new text. That + // frame proves nothing; the repaint a few ms later does. + let composer = ''; + let torn = 0; + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + const shown = torn > 0 ? '' : composer; + if (torn > 0) torn--; + return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 2 + composer.length, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + writes.push(text); + if (text !== '\r') { + composer += text; + torn = 2; + } + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js'); + const { getCli } = await import('../src/config/cli-registry/registry.js'); + const sleeps: number[] = []; + const store = new GuardedStageStore(Date.now, 1024, 120000, { + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + const target = { + id: session.id, + chrome: () => guardedComposerChrome(getCli('codex')), + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap), + }; + try { + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(promptSha256('hello')); + expect(sleeps).toHaveLength(2); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({ + submitted: true, + }); + expect(writes).toEqual(['hello', '\r']); + } finally { + await session.stop(false); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 976e2cbd..e6055b75 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -1,9 +1,13 @@ /** @fileoverview Guarded staging one-shot and content-verification safety. */ -import { beforeEach, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; import { GuardedStageStore, + STAGE_SETTLE_INTERVAL_MS, + STAGE_SETTLE_TIMEOUT_MS, + SUBMIT_QUIET_MS, + composerTriggerRefusal, guardedComposerChrome, readGuardedComposer, readGuardedSnapshot, @@ -23,16 +27,24 @@ let composer: string | null; let writes: string[]; let fail: boolean; let target: GuardedInputTarget; +/** Every settle-window wait, on the fake clock: no real timer runs in this file. */ +let sleeps: number[]; +const fakeSleep = async (ms: number) => { + sleeps.push(ms); + time += ms; +}; beforeEach(() => { + sleeps = []; time = 0; generation = 'pane-1'; revision = 0; composer = ''; writes = []; fail = false; - store = new GuardedStageStore(() => time, 2, 1000); + store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep }); target = { id: 's', + chrome: () => CODEX, snapshot: () => composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, submit: async () => target.write('\r'), @@ -144,7 +156,7 @@ it('requires capture cursor geometry, rejecting invisible trailing edits and mov expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello'); }); it('reserves receipt capacity before concurrent stages type', async () => { - store = new GuardedStageStore(() => time, 1, 1000); + store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep }); let release: (() => void) | undefined; const firstTarget = { ...target, @@ -195,3 +207,174 @@ it('takes the composer glyph and footer from the registry, and only codex declar unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; expect(guardedComposerChrome(unsafe)).toBeNull(); }); + +describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => { + /** Serve these post-write frames in order, then the target's own snapshot. */ + function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) { + let written = false; + const own = target.snapshot; + const write = target.write; + target.write = async (text) => { + const ok = await write(text); + written = true; + return ok; + }; + target.snapshot = () => { + if (!written || frames.length === 0) return own(); + const next = frames.shift()!; + return next ? next() : null; + }; + } + const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 }); + + it('names its bounds', () => { + expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000); + expect(STAGE_SETTLE_INTERVAL_MS).toBe(50); + }); + it('issues a receipt once a null first frame is followed by a valid one', async () => { + afterWrite([null]); + const receipt = await store.stage(target, 'alice', 'hello'); + expect(receipt.sha256).toBe(sha('hello')); + expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]); + }); + it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => { + afterWrite([stale, stale]); + await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false }); + expect(sleeps).toHaveLength(2); + }); + it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => { + target.snapshot = () => (writes.length ? null : stale()); + const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message); + expect(refusal).toMatch(/within 1000 ms; no receipt was issued/); + expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/); + expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS); + }); + it('issues no receipt for a frame whose composer text differs from the message', async () => { + const write = target.write; + target.write = async (text) => { + await write(text); + composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key + return true; + }; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/); + // Nothing to submit: no receipt was stored for the frame that did not match. + composer = 'hello'; + await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow( + /Unknown/ + ); + }); + it('refuses at once, without waiting, when another writer moved the input during the window', async () => { + afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]); + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/ + ); + expect(sleeps).toEqual([]); + }); + it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => { + fail = true; + await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow( + /^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/ + ); + }); +}); + +describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => { + const footer = ' gpt-5.6-terra default · ~/case'; + /** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */ + const frame = (popup: string[], draft: string) => + ['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n'); + + it('reads the idle frame with transcript above it', () => { + expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello'); + }); + it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => { + const popup = [ + '› /status show current session configuration and token usage', + ' /statusline configure which items appear in the status line', + ]; + expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull(); + // A wrapped description row at the bottom of the list does not hide it. + expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull(); + }); + it('refuses the skill, mention and file-search popups', () => { + const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close']; + const mention = [ + ' Mentions', + ' All Results Filesystem Only Plugins', + ' sa', + '› Sample Plugin Plugin with skills and an MCP server Plugin', + ' enter/tab insert · esc close · ↑/↓ select · ←/→ filter', + ]; + expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull(); + expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull(); + expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull(); + expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull(); + }); + it('looks only at the block directly above the composer, not the transcript further up', () => { + const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n'); + expect(readGuardedCodexComposer(screen)).toBe('hello'); + }); + it('takes the popup pattern and the trigger characters from the registry', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source); + expect(CODEX.commandPrefixes).toBe('/!'); + expect(CODEX.completionSigils).toBe('@$'); + // A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); + }); + it.each([ + ['/stat', '/'], + ['/status now', '/'], + [' /review', '/'], + ['!ls -la', '!'], + ['review @src/app', '@'], + ['@notes', '@'], + ['run $skill', '$'], + ['costs $5', '$'], + ])('refuses %j before typing (trigger %s)', async (message, trigger) => { + expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`); + await expect(store.stage(target, 'alice', message)).rejects.toThrow( + trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/ + ); + expect(writes).toEqual([]); + }); + it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])( + 'stages %j: no trigger at the start, and the caret word opens nothing', + async (message) => { + expect(composerTriggerRefusal(message, CODEX)).toBeNull(); + await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false }); + } + ); +}); + +describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => { + it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => { + expect(SUBMIT_QUIET_MS).toBeGreaterThan(120); + const receipt = await store.stage(target, 'alice', 'hello'); + time += 40; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]); + expect(writes).toEqual(['hello', '\r']); + }); + it('does not wait once the window has passed', async () => { + const receipt = await store.stage(target, 'alice', 'hello'); + time += SUBMIT_QUIET_MS; + await store.submit(target, 'alice', receipt.stageId, receipt.sha256); + expect(sleeps).toEqual([]); + }); + it('still refuses when a keystroke lands during the wait', async () => { + store = new GuardedStageStore(() => time, 2, 1000, { + sleep: async (ms) => { + await fakeSleep(ms); + revision++; // the user typed while submit waited + }, + }); + const receipt = await store.stage(target, 'alice', 'hello'); + await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow( + /changed; nothing submitted/ + ); + expect(writes).toEqual(['hello']); + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 5c4a468c..5ee368ee 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -205,6 +205,48 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => { + // Run over the rows just above the composer each time guarded input reads the pane. + for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerPopupLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerPopupLine; + if (src) + expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull(); + } + }); + + it('takes only short runs of ASCII punctuation as composer trigger characters', () => { + for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) { + for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) { + expectRejected( + (e) => { + (e.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: value, + }; + }, + `${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set` + ); + } + const entry = baseEntry(); + (entry.capabilities as Record<string, unknown>).workDetect = { + promptGlyph: '>', + workingLine: 'working', + [field]: '/!@$', + }; + expect(CliEntrySchema.safeParse(entry).success, field).toBe(true); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 8bafbb15..e97e401b 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -250,6 +250,17 @@ it('uses real session writes to stage without Enter and refuses intervening API }); expect(stale.statusCode).toBe(409); expect(writes.filter((s) => s === '\r')).toHaveLength(1); + // Codex's command popup opens on `/`; Enter would run the highlighted command. + composer = ''; + writes.length = 0; + const slash = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${session.id}/stage`, + payload: { message: '/stat' }, + }); + expect(slash.statusCode).toBe(409); + expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/); + expect(writes).toEqual([]); } finally { await session.stop(false); } diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b531756d..f770a76c 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1197,6 +1197,32 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual(['prompt', '\r']); }); + it('refuses with 409 and gives the seq back while a guarded submit holds the input lease', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedSubmitInFlight = true; + for (const payload of [ + { input: 'x' }, + { input: 'x', useMux: true }, + { input: 'prompt\r' }, + { input: 'tagged', seq: 7, clientId: 'cid-guard' }, + ]) { + const refused = await post(payload); + expect(refused.statusCode, JSON.stringify(payload)).toBe(409); + expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + } + expect(session.writeBuffer).toEqual([]); + // Once the lease is released the browser's retry of the same seq is applied, not + // rejected as a duplicate. + session.guardedSubmitInFlight = false; + expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['tagged']); + }); + it('always applies untagged input (curl/legacy, no dedup)', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 9ed86ea3..b9b721f6 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -11,7 +11,13 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js'; +import { + formatCursorRestore, + formatPaneSnapshot, + hasVisibleContent, + queryPaneCursor, + splitFrameAndCursor, +} from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); @@ -265,3 +271,32 @@ describe('queryPaneCursor history depth', () => { expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"); }); }); + +describe('the visible frame and its caret come from one tmux invocation', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8'); + const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); + const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart)); + + it('chains the capture and the cursor query with `\;` on the visible path only', () => { + // Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms + // after input): the frame could predate the caret, and guarded stage refused it. + // Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`. + const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}'; + expect(methodBody).toContain('fullHistory ? null : this.captureFrameWithCursor(`' + chained + '`)'); + // Any doubt falls back to the two calls the capture always made. + expect(methodBody).toContain('combined?.frame ?? execSync(captureCommand, execOpts)'); + expect(methodBody).toContain('combined\n ? combined.cursor'); + }); + it('splits the combined output into the rows and the cursor line tmux prints last', () => { + expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({ + frame: '› hello\n footer\n\n', + cursor: '7 0 100 20 0 0', + }); + expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' }); + }); + it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => { + expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull(); + expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull(); + expect(splitFrameAndCursor('')).toBeNull(); + }); +}); From a29192a248a9c7f3b531866466e08e2de079f208 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 09:04:52 -0400 Subject: [PATCH 6/9] fix(agent): do not rerun a pane capture whose combined call failed The combined capture-pane and cursor query swallowed a thrown error and the caller then ran the same capture again, so a hung tmux blocked the event loop for two 5 s timeouts on every visible-frame capture, which serves every GET .../terminal load. A thrown error now reaches the capture's own catch; only output that splitFrameAndCursor cannot split falls back to the two separate calls. The capture runs through a captureRunner() seam, like inputRunner(), so the tests count the tmux calls each path makes. --- src/tmux-manager.ts | 39 +++++++++++------ test/tmux-capture-full-history.test.ts | 60 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0eba7b22..f6a5efaf 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4295,7 +4295,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null { - if (IS_TEST_MODE) return ''; + const exec = this.captureRunner(); + if (!exec) return ''; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) { console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget }); @@ -4341,13 +4342,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // caret belongs to the frame. Two separate calls raced the CLI's repaint: a // CLI that redraws its composer a few ms after input arrives (codex) could be // captured before the redraw and its caret queried after it, and guarded - // stage then refused a frame the keys had already reached. Any doubt about - // the combined output (a failed call, a last line that is not the query's) - // falls back to the two separate calls, which is what this always did. - const combined = fullHistory ? null : this.captureFrameWithCursor(`${captureCommand} \\; ${cursorQuery}`); + // stage then refused a frame the keys had already reached. Only output that + // splitFrameAndCursor() cannot split (a last line that is not the query's) + // falls back to the two separate calls, which is what this always did. A call + // that THREW (a hung tmux timing out) goes to the catch below like any other + // capture failure: retrying it would block the event loop for a second timeout + // on every `GET .../terminal` load. + const combined = fullHistory + ? null + : splitFrameAndCursor( + exec(`${captureCommand} \\; ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) + ); const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - combined?.frame ?? execSync(captureCommand, execOpts), + combined?.frame ?? exec(captureCommand, execOpts), scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path @@ -4357,7 +4365,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const geometry = queryPaneCursor(() => combined ? combined.cursor - : execSync(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) + : exec(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }) ); // Report the size the pane was really drawing at. The visible-frame path // below addresses every row absolutely, so a consumer whose terminal is @@ -4423,13 +4431,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } - /** One combined capture-and-cursor-query call, split; null when it failed or is ambiguous. */ - private captureFrameWithCursor(command: string): { frame: string; cursor: string } | null { - try { - return splitFrameAndCursor(execSync(command, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })); - } catch { - return null; - } + /** + * How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest). + * Split out like {@link inputRunner} so a test subclass can count and script the + * calls a capture makes. + */ + protected captureRunner(): + | ((cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string) + | null { + if (IS_TEST_MODE) return null; + return (cmd, opts) => execSync(cmd, opts); } /** diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index b9b721f6..cc927011 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -10,13 +10,14 @@ */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor, splitFrameAndCursor, + TmuxManager, } from '../src/tmux-manager.js'; describe('tmux full-history pane capture (COD-47)', () => { @@ -282,9 +283,9 @@ describe('the visible frame and its caret come from one tmux invocation', () => // after input): the frame could predate the caret, and guarded stage refused it. // Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`. const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}'; - expect(methodBody).toContain('fullHistory ? null : this.captureFrameWithCursor(`' + chained + '`)'); - // Any doubt falls back to the two calls the capture always made. - expect(methodBody).toContain('combined?.frame ?? execSync(captureCommand, execOpts)'); + expect(methodBody).toContain('exec(`' + chained + '`'); + // Output it cannot split falls back to the two calls the capture always made. + expect(methodBody).toContain('combined?.frame ?? exec(captureCommand, execOpts)'); expect(methodBody).toContain('combined\n ? combined.cursor'); }); it('splits the combined output into the rows and the cursor line tmux prints last', () => { @@ -300,3 +301,54 @@ describe('the visible frame and its caret come from one tmux invocation', () => expect(splitFrameAndCursor('')).toBeNull(); }); }); + +describe('how many tmux calls one visible-frame capture makes', () => { + type Run = (cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string; + class ScriptedTmux extends TmuxManager { + calls: string[] = []; + constructor(private readonly answer: (cmd: string) => string) { + super(); + } + protected override captureRunner(): Run { + return (cmd) => { + this.calls.push(cmd.includes(' \\; ') ? 'combined' : cmd.includes('display-message') ? 'cursor' : 'capture'); + return this.answer(cmd); + }; + } + } + const quiet = () => vi.spyOn(console, 'error').mockImplementation(() => {}); + + it('makes ONE call when the combined output splits', () => { + const tmux = new ScriptedTmux(() => '› hello\n footer\n7 0 100 20 0 0\n'); + expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello'); + expect(tmux.calls).toEqual(['combined']); + }); + + it('does not rerun a combined call that threw: a hung tmux costs one timeout, not two', () => { + const errors = quiet(); + try { + const tmux = new ScriptedTmux(() => { + throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' }); + }); + expect(tmux.capturePaneBuffer('codeman-12345678')).toBeNull(); + expect(tmux.calls).toEqual(['combined']); + expect(errors).toHaveBeenCalledTimes(1); + } finally { + errors.mockRestore(); + } + }); + + it('falls back to the two separate calls only for output it cannot split', () => { + const tmux = new ScriptedTmux((cmd) => + cmd.includes(' \\; ') ? '› hello\n footer\n' : cmd.includes('display-message') ? '7 0 100 20 0 0\n' : '› hello\n' + ); + expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello'); + expect(tmux.calls).toEqual(['combined', 'capture', 'cursor']); + }); + + it('keeps the full-history path on its two calls', () => { + const tmux = new ScriptedTmux((cmd) => (cmd.includes('display-message') ? '0 0 80 24 0 0\n' : 'row\n')); + tmux.capturePaneBuffer('codeman-12345678', undefined, { fullHistory: true }); + expect(tmux.calls).toEqual(['capture', 'cursor']); + }); +}); From ecaddb40ae1266f31d0e6b3a283db1e9f01b931b Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 09:04:53 -0400 Subject: [PATCH 7/9] fix(agent): cap inspection cursors per owner and session Cursors shared one global FIFO of 1,024 and were never retired on use, so one agent polling every second evicted every other caller's cursors after about 17 minutes. Each owner and session now keeps its newest 8 cursors, older ones retired first, under the same global backstop; the cursor just used stays among the newest, so a retry of the last call still works. --- .../reference/inspection-and-staging.md | 4 ++-- .../reference/inspection-and-staging.md | 4 ++-- src/agent-inspection.ts | 21 ++++++++++++++-- test/agent-inspection.test.ts | 24 ++++++++++++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index 960a1f13..630088c2 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -45,8 +45,8 @@ these reads do not provide authoritative current-thread tracking. Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server -restart. At most 1024 are retained; capacity eviction can invalidate an older -cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +restart. Each caller and session keeps its newest 8 (so retrying the last call +works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor or unavailable native source returns `409 CONFLICT`. Inspect again without `--since` to establish a new tail baseline; this may omit older evidence. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index 960a1f13..630088c2 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -45,8 +45,8 @@ these reads do not provide authoritative current-thread tracking. Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server -restart. At most 1024 are retained; capacity eviction can invalidate an older -cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor +restart. Each caller and session keeps its newest 8 (so retrying the last call +works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor or unavailable native source returns `409 CONFLICT`. Inspect again without `--since` to establish a new tail baseline; this may omit older evidence. diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts index c48c90fc..eec6c159 100644 --- a/src/agent-inspection.ts +++ b/src/agent-inspection.ts @@ -18,6 +18,8 @@ export interface InspectionScope { } interface CursorState { scope: string; + /** Owner and session only: the unit the per-scope cap counts. */ + holder: string; path: string; identity: string; offset: number; @@ -47,12 +49,16 @@ async function anchor(handle: FileHandle, offset: number): Promise<string> { return createHash('sha256').update(head).update(tail).digest('hex'); } +/** Cursors kept per owner and session; older ones are retired first. */ +export const INSPECT_CURSORS_PER_SCOPE = 8; + export class InspectionReader { private readonly cursors = new Map<string, CursorState>(); constructor( private readonly now = Date.now, private readonly capacity = 1024, - private readonly ttlMs = 900000 + private readonly ttlMs = 900000, + private readonly perScope = INSPECT_CURSORS_PER_SCOPE ) {} async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) { @@ -160,10 +166,21 @@ export class InspectionReader { const cursor = randomUUID(); // No await between capacity enforcement and insertion: concurrent reads // must not all reserve the same remaining slot while hashing anchors. - for (const [key, value] of this.cursors) if (value.expiresAt <= this.now()) this.cursors.delete(key); + // Each owner and session keeps only its newest cursors, so one agent polling in + // a loop retires its own oldest ones instead of pushing every other caller's out + // of the shared map. The cursor just used stays among the newest, so a retry of + // the last call still works. The global capacity is only a backstop. + const holder = JSON.stringify([scope.owner, scope.sessionId]); + const mine: string[] = []; + for (const [key, value] of this.cursors) { + if (value.expiresAt <= this.now()) this.cursors.delete(key); + else if (value.holder === holder) mine.push(key); + } + while (mine.length >= this.perScope) this.cursors.delete(mine.shift()!); while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!); this.cursors.set(cursor, { scope: scopeKey, + holder, path, identity, offset: offset + consumed, diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts index 989f005b..cadbf53f 100644 --- a/test/agent-inspection.test.ts +++ b/test/agent-inspection.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, expect, it } from 'vitest'; import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { InspectionReader } from '../src/agent-inspection.js'; +import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js'; let dir: string; let file: string; let reader: InspectionReader; @@ -150,6 +150,28 @@ it('keeps cursor capacity bounded across concurrent reads', async () => { const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor))); expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2); }); +it('caps cursors per owner and session, so one polling scope cannot evict another', async () => { + await writeFile(file, '{}\n'); + const other = { ...scope, owner: 'bob', sessionId: 't' }; + const bobs = await reader.read(file, other, 100); + // Alice polls every second for well past the old global FIFO of 1,024. + let last = await reader.read(file, scope, 100); + for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor); + // Bob's cursor survives, and so does a retry of Alice's last call. + await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' }); + await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' }); + // Alice keeps only her newest cursors: the oldest of one more than the cap is retired. + const issued: string[] = []; + for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor); + await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i); + await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined(); + // The cap counts owner and session: a new pane generation of Alice's session shares + // it, Bob's session does not. + const nextPane = { ...scope, generation: 'pane-2' }; + for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100); + await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i); + await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined(); +}); it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => { const row = Buffer.from('{"text":"😀"}\n'); const split = row.indexOf(Buffer.from('😀')) + 2; From 2aad93914e2e36005c7cbf29d3db47ead3bf9315 Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Mon, 26 Oct 2026 09:05:25 -0400 Subject: [PATCH 8/9] fix(agent): hold input during a guarded submit instead of refusing it The input lease is held across the awaited tmux Enter, so WebSocket frames keep arriving while it is held. A tagged frame (seq N) was refused and its seq rolled back, but the browser's WebSocket path does not wait for ACKs: seq N+1 landed after the lease and moved the watermark past N, and the redelivered N was then dropped as a duplicate. The keystroke was lost. Input that arrives during the lease is now held and written in arrival order when the lease is released, whatever the submit's outcome. A WebSocket frame is applied and ACKed only once written; managed writes (writeViaMux) and named keys (send-key) wait for their turn the same way, and a held write that is still being sent holds everything behind it. A keystroke held before the guard runs still moves the input revision, so it still invalidates the Enter. The hold is capped per lease (256 inputs, 1 MiB): past it the WebSocket is closed with 1013 and nothing later from it is applied, so the browser resends every unACKed frame in order, and send-key answers 409 CONFLICT instead of logging a tmux failure as INTERNAL_ERROR. POST .../input keeps its 409 for the lease plus the held input's drain; its browser drainer is serialized. Docs: correct the api-reference paragraph that said these keystrokes are redelivered, and describe the capture fallback and the per-scope cursor cap from the previous two commits in api-reference, architecture-invariants and the changeset. --- .changeset/bounded-agent-coordination.md | 4 +- docs/api-reference.md | 34 +- docs/architecture-invariants.md | 21 +- src/session.ts | 161 ++++++++- src/web/routes/session-routes.ts | 22 +- src/web/routes/ws-routes.ts | 128 +++++--- test/agent-staging-session.test.ts | 29 +- test/guarded-lease-held-input.test.ts | 395 +++++++++++++++++++++++ test/routes/session-routes.test.ts | 33 +- 9 files changed, 727 insertions(+), 100 deletions(-) create mode 100644 test/guarded-lease-held-input.test.ts diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 2ff2c0af..62724678 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -2,7 +2,7 @@ "aicodeman": minor --- -Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. +Add experimental bounded agent inspection with expiring scoped cursors (the newest 8 kept per caller and session) and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. The three new CLI verbs (`codeman agent inspect`, `stage`, `submit`) and their HTTP routes (`/api/v1` and `/api`) are labeled experimental, so the versioning policy's rule for experimental surfaces applies: their shape and behavior may change in minor or patch releases. They are opt-in behind a new synced setting, `agentCoordinationEnabled` (App Settings, Cross-session features, "Experimental agent coordination", default OFF, translated for zh-CN): while it is off all three routes answer `403 FORBIDDEN` with a message naming it, and the CLI verbs print that refusal verbatim. @@ -10,7 +10,7 @@ Codex rollout attribution remembers the attributed file per session and re-verif The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. -Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. While a guarded submit is in flight, `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned instead of a `200` for input that was never written. +Stage waits for the pane to show what it typed: it re-reads the composer every 50 ms for up to one second and issues a receipt only for a frame showing exactly the message (Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed). Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame pane capture reads the screen and the caret in one tmux invocation, falling back to the two separate calls only for output it cannot split; a call that fails is not repeated. Stage refuses messages that would change what Enter does (codex: a leading `/` or `!`, or a last word starting with `@` or `$`, which open a command or completion popup) and frames with a suggestion popup above the composer, from three new registry fields (`workDetect.composerPopupLine`, `composerCommandPrefixes`, `composerCompletionSigils`). Submit waits until 250 ms after the staged keys before sending Enter, since codex turns an Enter within 120 ms of typed text into a newline. Input that arrives while a guarded submit holds its input lease (the few milliseconds of its Enter) is held and written in arrival order once the lease is released, so a browser keystroke, a Shift+Enter or Ctrl+Enter, or an approval answer typed in that window is no longer lost; a WebSocket frame is acknowledged only once it is written. `POST /api/sessions/:id/input` answers `409 CONFLICT` with its seq returned for that span instead of a `200` for input that was never written, and a lease that would hold more than 256 inputs or 1 MiB closes the terminal WebSocket (code 1013) so the browser resends in order, and answers `send-key` with `409 CONFLICT`. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/docs/api-reference.md b/docs/api-reference.md index d99d4ef2..2476f983 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -427,8 +427,9 @@ turn that never started. Verified live; this is the most common silent failure o this endpoint. Any input, waiting or not, answers `409 CONFLICT` with nothing written while an -experimental guarded submit holds the session's input lease (a few milliseconds; -see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +experimental guarded submit holds the session's input lease, or input held behind +it is still being written (a few milliseconds; see +[guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). Retry it. A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is @@ -659,9 +660,10 @@ thread, task completion or authorization. Opaque server-side cursors bind caller, session, pane generation, conversation, opened-file identity, byte offset and bounded content anchors. Terminal resizes -do not change this inspection scope. At most 1024 live -cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh -tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign +do not change this inspection scope. Each owner and session keeps its newest 8 +cursors (a retry of the last call still works; older ones are retired), at most +1024 live cursors are retained in all, each for 15 minutes; restart, eviction or +expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign cursors or loss of the attributed source return `409 CONFLICT`. No transcript filesystem path is returned as cursor/identity metadata; evidence retains the owner's native record content, which can itself contain paths. @@ -708,13 +710,21 @@ may already be in the composer: inspect the pane and follow the stranded-draft recovery (Ctrl+U) in the skill reference before staging again. While a guarded submit holds the input lease (the few milliseconds of its -conditional Enter), every other Codeman write path refuses, and -`POST /api/v1/sessions/:id/input` answers `409 CONFLICT` ("A guarded submit is in -progress on this session; nothing was written") instead of a `200` for bytes -that were never written. A tagged `clientId`/`seq` frame keeps its seq, so the -browser's durable queue, which keeps a frame on a `409`, delivers it on the next -retry. WebSocket keystrokes in that window are left unacknowledged and redelivered -the same way. +conditional Enter), input that reaches the session is held, not refused, and is +written in arrival order as soon as the lease is released, whatever the submit's +outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged +only once it is written, so a frame typed during the lease lands before the one +typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval +answers and Codeman's own managed writes. A held write that is still being sent +holds everything behind it. `POST /api/v1/sessions/:id/input` instead answers +`409 CONFLICT` ("A guarded submit is in progress on this session; nothing was +written") for that span, the lease plus the held input's drain, rather than a +`200` for bytes that were never written; a tagged `clientId`/`seq` frame keeps +its seq, so the browser's durable queue, whose POST fallback sends one frame at a +time and keeps it on a `409`, delivers it on the next retry. A lease holds at +most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and +nothing later from it is applied, so the browser reconnects and resends every +unacknowledged frame in order, and `send-key` answers `409 CONFLICT`. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d0fd2444..2e2495eb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1281,6 +1281,9 @@ candidate-metadata budgets, bounded path probes before canonical reads, and complete UTF8 JSONL records. A full page advances its cursor without claiming a blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane geometry is excluded so changing devices does not invalidate transcript paging. +Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8, +under the global 1,024), so one polling agent retires its own oldest cursors instead +of evicting every other caller's from the shared FIFO. ⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store walk per call. The first call walks (negative-filter prefixes read 1 KiB first, @@ -1317,9 +1320,18 @@ and the first capture is often from before them; another writer or a lifecycle c ends the wait at once, and every refusal after typing says the message may be in the composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call (`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to -the two calls on any doubt; keep the poll anyway. While a guarded submit holds the -lease, `POST .../input` answers `409` (seq returned) instead of a `200` for unwritten -bytes. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +the two calls only for output it cannot split; a combined call that THREW goes to the +capture's catch and is never rerun (a hung tmux would block the event loop for a second +timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives +while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`, +`_inGuardedTurn()`), and written in arrival order when the lease is released: the +browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by +N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written; +a held async write (mux write, named key) holds everything behind it until it settles +(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with +`1013` and applies nothing later from it, so the browser resends in order. `POST +.../input` alone answers `409` (seq returned) for the lease plus the drain, since its +browser drainer is serialized and retries. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and an Enter within 120 ms of one becomes a newline while submit would report it delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and @@ -1330,7 +1342,8 @@ Submission additionally binds input and geometry revisions and rechecks the exact composer/caret at the conditional Enter boundary. The route must retain the Session snapshot's staging generation rather than replacing it with the inspection generation. Managed input and auxiliary named keys participate in the -same pending/lease boundary. Complete focus/mouse reports and empty writes are +same pending/lease boundary: they wait for the lease, and only a full hold queue +refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are not edits. Never retry ambiguous Enter delivery or clear an unverified composer. The ordinary SubmitVerifier uses the shared write boundary, so each re-press updates `lastSubmitAt` just like any other Enter. diff --git a/src/session.ts b/src/session.ts index 1b1410d1..dc43c4ff 100644 --- a/src/session.ts +++ b/src/session.ts @@ -465,6 +465,17 @@ export interface ClaudeMessage { duration_ms?: number; } +/** + * A write that a guarded submit's lease could not hold, because the per-lease hold + * queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it. + */ +export class GuardedInputBusyError extends Error { + constructor() { + super('A guarded submit is in progress on this session; nothing was written. Retry the input.'); + this.name = 'GuardedInputBusyError'; + } +} + /** * Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare * Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own. @@ -4218,7 +4229,7 @@ export class Session extends EventEmitter { prompt: string, options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void } ): Promise<{ result: string; cost: number }> { - if (this._guardedSubmitInFlight) { + if (this.guardedInputHeld) { this._guardedInputRevision++; throw new Error('Guarded submission in progress'); } @@ -4738,6 +4749,19 @@ export class Session extends EventEmitter { private _guardedInputRevision = 0; private _guardedPendingWrites = 0; private _guardedSubmitInFlight = false; + /** + * Input that arrived while a guarded submit held the lease, in arrival order. Each + * entry runs when the lease is released; an entry that returns a promise (a mux + * write, a named key) is waited for before the next runs, so nothing overtakes it. + */ + private _guardedHeld: Array<{ run: () => void | Promise<unknown>; bytes: number }> = []; + private _guardedHeldBytes = 0; + /** True while the drain waits for a held asynchronous write to finish. */ + private _guardedHeldDraining = false; + /** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */ + static readonly MAX_GUARDED_HELD_INPUTS = 256; + /** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */ + static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024; /** Ephemeral identity, never persisted or based on a potentially reused pid alone. */ get inspectionGeneration(): string { return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; @@ -4750,13 +4774,89 @@ export class Session extends EventEmitter { return this._guardedPendingWrites; } /** - * True while a guarded submit holds the input lease. Every write path refuses for - * that span (a few milliseconds), so a route that can report a refusal checks this - * first instead of answering as if the bytes went out. + * True while a guarded submit holds the input lease (a few milliseconds). Input that + * arrives in that span is held and written after it (see {@link guardedInputHeld}). */ get guardedSubmitInFlight(): boolean { return this._guardedSubmitInFlight; } + /** + * True while input is being held behind a guarded submit: the lease itself, or held + * input not yet written after it. A write that is not routed through + * {@link holdForGuardedLease} must refuse in this window, or it overtakes held input. + */ + get guardedInputHeld(): boolean { + return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining; + } + /** + * Hold input behind a guarded submit's lease instead of refusing it. + * + * `free`: nothing is held, so the caller writes inline as it always did. `held`: `run` + * is queued and runs, in arrival order with every other held input, once the lease is + * released (whatever the submit's outcome). `full`: the per-lease cap is reached and + * nothing was queued; the caller must stop delivering from that source until it can + * resend in order. Refusing instead lost input: the browser's WebSocket path does not + * wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then + * rejected as a duplicate. `data`, when given, is observed as input on arrival, so a + * keystroke held during the lease still moves the input revision at once. + */ + holdForGuardedLease(run: () => void | Promise<unknown>, bytes = 0, data?: string): 'free' | 'held' | 'full' { + if (!this.guardedInputHeld) return 'free'; + if (data !== undefined) this._observeGuardedKeyboardInput(data); + if ( + this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS || + this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES + ) + return 'full'; + this._guardedHeld.push({ run, bytes }); + this._guardedHeldBytes += bytes; + return 'held'; + } + /** + * Run an asynchronous write now, or in its turn behind a guarded submit's lease. The + * turn lasts until the write settles, so later held input waits for it. Resolves + * `whenFull` without running anything when the hold queue is full. + */ + private _inGuardedTurn<T>(write: () => Promise<T>, whenFull: () => T): Promise<T> { + if (!this.guardedInputHeld) return write(); + return new Promise<T>((resolve, reject) => { + const held = this.holdForGuardedLease(() => { + const running = write(); + running.then(resolve, reject); + return running; + }); + if (held === 'full') { + try { + resolve(whenFull()); + } catch (err) { + reject(err); + } + } + }); + } + /** Run held input in arrival order until it is all written or a new lease is taken. */ + private _drainGuardedHeld(): void { + while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) { + const entry = this._guardedHeld.shift()!; + this._guardedHeldBytes -= entry.bytes; + let running: void | Promise<unknown>; + try { + running = entry.run(); + } catch (err) { + console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err)); + continue; + } + if (running && typeof running.then === 'function') { + this._guardedHeldDraining = true; + void running + .catch(() => {}) + .then(() => { + this._guardedHeldDraining = false; + this._drainGuardedHeld(); + }); + } + } + } /** Expose only the path selection needed for attributed Codex evidence, never credentials. */ getCodexHomeOverride(): string | undefined { return this._envOverrides?.CODEX_HOME || undefined; @@ -4773,7 +4873,7 @@ export class Session extends EventEmitter { this._closing || this.paneLifecycleInFlight || this._guardedPendingWrites || - (!allowLease && this._guardedSubmitInFlight) || + (!allowLease && this.guardedInputHeld) || this.remote || this.docker || this.paneExit || @@ -4808,7 +4908,7 @@ export class Session extends EventEmitter { /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ async stageGuardedInput(message: string): Promise<boolean> { if (/[\r\n]/.test(message)) return false; - if (this._guardedPendingWrites || this._guardedSubmitInFlight) { + if (this._guardedPendingWrites || this.guardedInputHeld) { this._guardedInputRevision++; return false; } @@ -4839,18 +4939,29 @@ export class Session extends EventEmitter { return sent && dispatched; } finally { this._guardedSubmitInFlight = false; + this._drainGuardedHeld(); } } - /** Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. */ + /** + * Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. + * During a guarded submit the operation waits for the lease like any other write; + * only a full hold queue refuses it, with {@link GuardedInputBusyError}. + */ async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> { this._guardedInputRevision++; - if (this._guardedSubmitInFlight) throw new Error('Guarded submission in progress'); - this._guardedPendingWrites++; - try { - await operation(); - } finally { - this._guardedPendingWrites--; - } + return this._inGuardedTurn( + async () => { + this._guardedPendingWrites++; + try { + await operation(); + } finally { + this._guardedPendingWrites--; + } + }, + () => { + throw new GuardedInputBusyError(); + } + ); } /** * Sends input directly to the PTY process. @@ -5040,7 +5151,17 @@ export class Session extends EventEmitter { */ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> { this._observeGuardedKeyboardInput(data); - if (this._guardedSubmitInFlight) return false; + // A guarded submit's lease lasts for its Enter only: wait for it, in arrival order, + // rather than refusing, so an approval answer or a programmatic prompt is not + // dropped. Without a lease the write starts synchronously, so the pending count + // rises before any await and no lease can be taken while it is in flight. + return this._inGuardedTurn( + () => this._writeViaMuxNow(data, options), + () => false + ); + } + + private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise<boolean> { this._guardedPendingWrites++; try { const submittedPrompt = this._trackSubmit(data, options); @@ -5090,7 +5211,13 @@ export class Session extends EventEmitter { ? null : this._mux.capturePaneText?.(this._muxSession.muxName), sendEnter: () => { - if (this._guardedSubmitInFlight) this._submitVerifier?.cancel(); + // A retry Enter is never held behind a guarded submit: once that submit's + // Enter went out, a re-press would submit whatever is on the composer next. + if (this.guardedInputHeld) { + this._guardedInputRevision++; + this._submitVerifier?.cancel(); + return Promise.resolve(false); + } return this.writeViaMux('\r'); }, // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. @@ -5261,7 +5388,7 @@ export class Session extends EventEmitter { // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise<void> { - if (this._guardedSubmitInFlight) { + if (this.guardedInputHeld) { this._guardedInputRevision++; throw new Error('Guarded submission in progress'); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 369ea2dc..ae4a523f 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -35,6 +35,7 @@ import { type RemoteHost, } from '../../types.js'; import { + GuardedInputBusyError, Session, cliTakesSessionModel, isAltScreenStripMode, @@ -2078,12 +2079,14 @@ export function registerSessionRoutes( } // A guarded submit (agent-staging.ts) holds the input lease for the few - // milliseconds of its conditional Enter, and every write path refuses while it - // does. Say so instead of answering 200 for bytes that were never written, and - // give the seq back so the browser's durable queue (which keeps a frame on any - // 409) delivers it once the lease is released. Nothing from here to the write - // below awaits, so the lease cannot be taken in between. - if (!duplicate && session.guardedSubmitInFlight === true) { + // milliseconds of its conditional Enter, and input that arrives meanwhile is held + // and written in arrival order after it. This route refuses for that span (the + // lease plus the held input's drain) rather than overtake held input. Say so + // instead of answering 200 for bytes that were never written, and give the seq + // back so the browser's durable queue (which keeps a frame on any 409, and whose + // POST drainer sends one frame at a time) delivers it on its next retry. Nothing + // from here to the write below awaits, so the lease cannot be taken in between. + if (!duplicate && session.guardedInputHeld === true) { if (tagged) session.forgetInputSeq(clientId as string, seq as number); return reply .code(409) @@ -2416,7 +2419,7 @@ export function registerSessionRoutes( // own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs // 0x0d (carriage return = submit). - app.post('/api/sessions/:id/send-key', async (req) => { + app.post('/api/sessions/:id/send-key', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Record<string, unknown>; const key = typeof body?.key === 'string' ? body.key : ''; @@ -2461,6 +2464,11 @@ export function registerSessionRoutes( }) ); } catch (err) { + // A guarded submit's lease holds the key until its Enter is out; only a full + // hold queue refuses it, and that is a retryable conflict, not a tmux failure. + if (err instanceof GuardedInputBusyError) { + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, err.message)); + } console.error('[Server] send-key failed:', err); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); } diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index 19c2ddca..99a86691 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -29,6 +29,11 @@ * {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are * optional reliable-delivery tags: the server applies each * (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}. + * A frame that arrives while a guarded submit holds the + * session's input lease is held, then applied and ACKed in + * arrival order once it is released; when that hold queue + * is full the socket is closed with 1013 and nothing later + * from it is applied, so the client resends in order. * {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged) */ @@ -169,12 +174,69 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost const sizingToken = Symbol('ws-desktop-sizing'); let holdsDesktopClaim = false; + // Set once an input frame could not be held behind a guarded submit's lease + // (the hold queue was full). Nothing later from this socket is applied: it is + // closed, and the browser reconnects and resends everything unACKed, in order. + let inputStalled = false; + + // Apply one input frame and ACK it. Runs inline, or later and in arrival order + // when a guarded submit held the input lease as the frame arrived. + const applyInputFrame = (msg: { d: string; seq?: unknown; cid?: unknown }) => { + // Reliable delivery: when the frame carries a clientId + seq, apply it + // exactly once (skip a duplicate redelivery) but ACK it regardless so + // the client can drop it from its durable queue. Frames without seq + // (legacy/other tools) are applied as-is — no behavior change. + const cid = typeof msg.cid === 'string' ? msg.cid : null; + const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null; + const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true; + let delivered = true; + if (apply) { + // Typed input from a claim-holding desktop keeps the claim "hot" + // and re-asserts the desktop layout after a mobile override. + if (holdsDesktopClaim) session.noteDesktopActivity(); + // Browser keystrokes are the user's own, so they may name the tab. + delivered = session.write(msg.d, { fromUser: true }); + // A session whose PTY is gone swallows the write. ACKing anyway told + // the client to drop the frame from its durable queue and left the seq + // burnt, so the retry that reliable delivery exists for was rejected as + // a duplicate: the input was lost for good. + if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq); + } + if (seq !== null && socket.readyState === 1) { + if (apply) { + if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); + } else { + // REJECTED as a duplicate. ACK it — the client must still drop it + // from its durable queue — but say so, and hand back our watermark. + // + // A plain ACK here is indistinguishable from "applied", which is + // what made a client with a rolled-back counter unrecoverable: its + // seqs persist to localStorage on a DEBOUNCED write, so a tab killed + // between a send and that write comes back with a counter BELOW this + // watermark, every later keystroke lands at or under it, and each one + // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and + // a reload restores the same stale counter. `last` is what lets the + // client lift itself out. + // ⚠️ Defensive: the session arrives through a structural port, and an + // implementation without this method must not take the whole input + // path down with it — a throw here aborts the message handler and the + // frame is never ACKed at all, which strands it in the client's queue. + const watermark = + typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' + ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) + : seq; + socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); + } + } + }; + // Attach message handler synchronously BEFORE any async work // (@fastify/websocket requirement to avoid dropped messages). socket.on('message', (raw) => { try { const msg = JSON.parse(String(raw)); if (msg.t === 'i' && typeof msg.d === 'string') { + if (inputStalled) return; if (msg.d.length > MAX_INPUT_LENGTH) { // Refused for good, so say so: a silent return left the frame // unACKed and the client redelivered it every few seconds forever @@ -185,51 +247,27 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost } return; } - // Reliable delivery: when the frame carries a clientId + seq, apply it - // exactly once (skip a duplicate redelivery) but ACK it regardless so - // the client can drop it from its durable queue. Frames without seq - // (legacy/other tools) are applied as-is — no behavior change. - const cid = typeof msg.cid === 'string' ? msg.cid : null; - const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null; - const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true; - let delivered = true; - if (apply) { - // Typed input from a claim-holding desktop keeps the claim "hot" - // and re-asserts the desktop layout after a mobile override. - if (holdsDesktopClaim) session.noteDesktopActivity(); - // Browser keystrokes are the user's own, so they may name the tab. - delivered = session.write(msg.d, { fromUser: true }); - // A session whose PTY is gone swallows the write. ACKing anyway told - // the client to drop the frame from its durable queue and left the seq - // burnt, so the retry that reliable delivery exists for was rejected as - // a duplicate: the input was lost for good. - if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq); - } - if (seq !== null && socket.readyState === 1) { - if (apply) { - if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); - } else { - // REJECTED as a duplicate. ACK it — the client must still drop it - // from its durable queue — but say so, and hand back our watermark. - // - // A plain ACK here is indistinguishable from "applied", which is - // what made a client with a rolled-back counter unrecoverable: its - // seqs persist to localStorage on a DEBOUNCED write, so a tab killed - // between a send and that write comes back with a counter BELOW this - // watermark, every later keystroke lands at or under it, and each one - // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and - // a reload restores the same stale counter. `last` is what lets the - // client lift itself out. - // ⚠️ Defensive: the session arrives through a structural port, and an - // implementation without this method must not take the whole input - // path down with it — a throw here aborts the message handler and the - // frame is never ACKed at all, which strands it in the client's queue. - const watermark = - typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' - ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) - : seq; - socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); - } + // A guarded submit (agent-staging.ts) holds the input lease across its + // awaited Enter, so frames keep arriving while it is held. Refusing one + // lost it: this path does not wait for ACKs, so the next seq landed after + // the lease, moved the watermark past the refused one, and its redelivery + // was then dropped as a duplicate. Hold the frame instead; the session runs + // it, ACK included, in arrival order the moment the lease is released. + // ⚠️ Defensive, like `lastInputSeq` below: the session arrives through a + // structural port, and one without this method must still deliver input + // (a throw here would drop the frame unACKed). + const held = + typeof session.holdForGuardedLease === 'function' + ? session.holdForGuardedLease(() => applyInputFrame(msg), msg.d.length, msg.d) + : 'free'; + if (held === 'free') { + applyInputFrame(msg); + } else if (held === 'full') { + // Not held, not applied, not ACKed, and no later frame from this socket + // is applied either, so nothing can overtake it. + inputStalled = true; + console.warn('[ws] input backlog during a guarded submit; closing for redelivery', { sessionId: id }); + socket.close(1013, 'Input backlog during guarded submit'); } } else if ( msg.t === 'z' && diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index ff106fdf..38d2d852 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -50,7 +50,7 @@ it('fences writes and reused-pid pane lifecycles without persisting prompt conte expect(session.guardedInputSnapshot()).toBeNull(); }); -it('checks conditional Enter at the dispatch boundary and rejects concurrent browser input', async () => { +it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => { let dispatch: (() => void) | undefined; const writes: string[] = []; const mux = { @@ -84,32 +84,41 @@ it('checks conditional Enter at the dispatch boundary and rejects concurrent bro muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters<typeof Session>[0]); try { + const settle = () => new Promise<void>((resolve) => setImmediate(resolve)); const expected = session.guardedInputSnapshot()!; const sending = session.submitGuardedInput(expected); - expect(await session.writeViaMux('other')).toBe(false); + // Held behind the lease, not refused: it still invalidates the pending Enter, and + // it goes out once the lease is released. + const other = session.writeViaMux('other'); dispatch!(); expect(await sending).toBe(false); expect(writes).toEqual([]); + await settle(); + dispatch!(); + expect(await other).toBe(true); + expect(writes).toEqual(['other']); const next = session.submitGuardedInput(session.guardedInputSnapshot()!); dispatch!(); expect(await next).toBe(true); - expect(writes).toEqual(['\r']); + expect(writes).toEqual(['other', '\r']); const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!); await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle( async () => {} ); dispatch!(); expect(await restarting).toBe(false); - expect(writes).toEqual(['\r']); + expect(writes).toEqual(['other', '\r']); const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!); - await expect( - session.performAuxiliaryInput(async () => { - throw new Error('must not execute'); - }) - ).rejects.toThrow('Guarded submission'); + const key = session.performAuxiliaryInput(async () => { + writes.push('aux'); + }); + await settle(); + // The named key waits for the lease instead of failing, and never runs inside it. + expect(writes).toEqual(['other', '\r']); dispatch!(); expect(await auxiliary).toBe(false); - expect(writes).toEqual(['\r']); + await key; + expect(writes).toEqual(['other', '\r', 'aux']); } finally { await session.stop(false); } diff --git a/test/guarded-lease-held-input.test.ts b/test/guarded-lease-held-input.test.ts new file mode 100644 index 00000000..ffeb0a99 --- /dev/null +++ b/test/guarded-lease-held-input.test.ts @@ -0,0 +1,395 @@ +/** + * @fileoverview Input that lands while a guarded submit holds the input lease is held + * and applied in arrival order once the lease is released, never refused and lost. + * + * Shaped like the review repro: a real `Session`, a mux that runs `beforeDispatch` and + * then awaits the Enter the way `TmuxManager.sendInput` does, and the real WebSocket + * route driven through a fake socket. The browser's WebSocket path does not wait for + * ACKs, so the client model below sends every frame at once and redelivers whatever + * is still unACKed afterwards, in seq order, the way `_redeliverSweep` does. + */ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { formatPaneSnapshot } from '../src/tmux-manager.js'; +import { GuardedInputBusyError, Session } from '../src/session.js'; +import { registerWsRoutes } from '../src/web/routes/ws-routes.js'; +import type { SessionPort } from '../src/web/ports/session-port.js'; + +type Frame = { t: 'i'; d: string; seq?: number; cid?: string }; + +class FakeSocket extends EventEmitter { + readyState = 1; + sent: Array<Record<string, unknown>> = []; + closedWith: number | null = null; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code?: number): void { + this.closedWith = code ?? 1000; + this.readyState = 3; + } + ping(): void {} + terminate(): void { + this.readyState = 3; + } + frame(msg: Frame): void { + this.emit('message', Buffer.from(JSON.stringify(msg))); + } + acks(): number[] { + return this.sent.filter((m) => m.t === 'ia').map((m) => m.seq as number); + } +} + +interface Harness { + session: Session; + pty: string[]; + /** Resolve the in-flight Enter (true = tmux accepted it, false = it failed). */ + finishEnter(ok?: boolean | Error): void; + enterDispatched(): boolean; + connect(cid: string): FakeSocket; + stop(): Promise<void>; +} + +const sockets: FakeSocket[] = []; + +function harness(awaitBeforeGuard = false): Harness { + const pty: string[] = []; + let finish: ((ok: boolean | Error) => void) | undefined; + let dispatched = false; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, + // Like TmuxManager.sendInput's Enter-only branch: the guard runs synchronously, + // then the `send-keys Enter` child process is awaited with the lease still held. + sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { + if (options?.beforeDispatch && text !== '\r') return false; + const settle = () => + new Promise<boolean | Error>((resolve) => { + finish = resolve; + }); + // A transport that awaits before its guard leaves a window in which held + // keystrokes must still invalidate the Enter. + const early = awaitBeforeGuard ? await settle() : undefined; + if (options?.beforeDispatch && !options.beforeDispatch()) return false; + dispatched = true; + const outcome = early ?? (await settle()); + if (outcome instanceof Error) throw outcome; + if (outcome) pty.push(text); + return outcome; + }, + }; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters<typeof Session>[0]); + const access = session as unknown as { ptyProcess: { write(data: string): void } | null }; + access.ptyProcess = { write: (data: string) => pty.push(data) }; + + let handler: ((socket: FakeSocket, req: unknown) => void) | undefined; + const app = { + get: (_path: string, _opts: unknown, h: (socket: FakeSocket, req: unknown) => void) => { + handler = h; + }, + } as unknown as FastifyInstance; + const ctx = { sessions: new Map([[session.id, session]]) } as unknown as SessionPort; + registerWsRoutes(app, ctx, () => ({ bindHost: '127.0.0.1', allowedHosts: [] })); + + return { + session, + pty, + finishEnter: (ok = true) => finish!(ok), + enterDispatched: () => dispatched, + connect: (cid: string) => { + const socket = new FakeSocket(); + sockets.push(socket); + handler!(socket, { headers: { host: 'localhost:3000' }, params: { id: session.id }, query: { cid } }); + return socket; + }, + stop: async () => { + access.ptyProcess = null; + await session.stop(false); + }, + }; +} + +/** Redeliver every still-unACKed frame in seq order, as the browser's sweep does. */ +function redeliverUnacked(socket: FakeSocket, frames: Frame[]): void { + const acked = new Set(socket.acks()); + for (const f of frames) if (f.seq !== undefined && !acked.has(f.seq)) socket.frame(f); +} + +afterEach(() => { + for (const s of sockets.splice(0)) s.emit('close', 1000, Buffer.from('')); +}); + +describe('input during a guarded submit lease', () => { + it('delivers a keystroke that landed during the lease before the one typed after it (review repro)', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' }; + const b: Frame = { t: 'i', d: 'b', seq: 2, cid: 'c1' }; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.enterDispatched()).toBe(true); + expect(h.session.guardedSubmitInFlight).toBe(true); + socket.frame(a); + // Held, not refused: no ACK yet and nothing written while the lease is held. + expect(socket.acks()).toEqual([]); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await expect(submit).resolves.toBe(true); + socket.frame(b); + redeliverUnacked(socket, [a, b]); + expect(h.pty).toEqual(['\r', 'a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(socket.sent.filter((m) => m.dup)).toEqual([]); + } finally { + await h.stop(); + } + }); + + it('keeps arrival order for several frames during the lease and one after it', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const frames: Frame[] = ['a', 'b', 'c', 'd'].map((d, i) => ({ t: 'i', d, seq: i + 1, cid: 'c1' })); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + for (const f of frames.slice(0, 3)) socket.frame(f); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await submit; + socket.frame(frames[3]); + redeliverUnacked(socket, frames); + expect(h.pty).toEqual(['\r', 'a', 'b', 'c', 'd']); + expect(socket.acks()).toEqual([1, 2, 3, 4]); + } finally { + await h.stop(); + } + }); + + it('interleaves a second client by arrival, each with its own seq space', async () => { + const h = harness(); + try { + const one = h.connect('c1'); + const two = h.connect('c2'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + one.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + two.frame({ t: 'i', d: 'x', seq: 1, cid: 'c2' }); + one.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + h.finishEnter(true); + await submit; + two.frame({ t: 'i', d: 'y', seq: 2, cid: 'c2' }); + expect(h.pty).toEqual(['\r', 'a', 'x', 'b', 'y']); + expect(one.acks()).toEqual([1, 2]); + expect(two.acks()).toEqual([1, 2]); + } finally { + await h.stop(); + } + }); + + it('flushes held input when the lease ends in failure, refused Enter or thrown', async () => { + for (const outcome of [false, new Error('tmux send-keys failed')]) { + const h = harness(); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(outcome); + if (outcome instanceof Error) await expect(submit).rejects.toThrow('tmux send-keys failed'); + else await expect(submit).resolves.toBe(false); + socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(h.session.guardedSubmitInFlight).toBe(false); + } finally { + await h.stop(); + } + } + }); + + it('holds untagged frames too, which have no redelivery to fall back on', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a' }); + h.finishEnter(true); + await submit; + socket.frame({ t: 'i', d: 'b' }); + expect(h.pty).toEqual(['\r', 'a', 'b']); + } finally { + await h.stop(); + } + }); + + it('a frame held for a socket that closed is still applied once; its redelivery is a duplicate', async () => { + const h = harness(); + try { + const first = h.connect('c1'); + const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' }; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + first.frame(a); + first.close(); + first.emit('close', 1006, Buffer.from('')); + h.finishEnter(true); + await submit; + expect(first.sent).toEqual([]); + const second = h.connect('c1'); + second.frame(a); + second.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['\r', 'a', 'b']); + expect(second.sent.filter((m) => m.t === 'ia')).toEqual([ + { t: 'ia', seq: 1, dup: true, last: 1 }, + { t: 'ia', seq: 2 }, + ]); + } finally { + await h.stop(); + } + }); + + it('closes the socket instead of holding past the cap, and the reconnect resends in order', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + const cap = Session.MAX_GUARDED_HELD_INPUTS; + const frames: Frame[] = Array.from({ length: cap + 3 }, (_, i) => ({ + t: 'i', + d: `k${i}`, + seq: i + 1, + cid: 'c1', + })); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + for (const f of frames) socket.frame(f); + // Frames past the cap are not held, not applied and not ACKed; the socket is + // closed so the browser reconnects and resends everything still unACKed. + expect(socket.closedWith).toBe(1013); + h.finishEnter(true); + await submit; + expect(h.pty.length).toBe(1 + cap); + expect(socket.acks()).toEqual([]); + socket.emit('close', 1013, Buffer.from('')); + const again = h.connect('c1'); + for (const f of frames) again.frame(f); + expect(h.pty).toEqual(['\r', ...frames.map((f) => f.d)]); + expect(again.acks()).toEqual(frames.map((f) => f.seq)); + } finally { + await h.stop(); + } + }); + + it('holds managed writes and named keys behind the lease, and refuses them only when the hold queue is full', async () => { + const h = harness(); + try { + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const order: string[] = []; + const key = h.session.performAuxiliaryInput(async () => { + order.push('key'); + }); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(order).toEqual([]); + for (let i = 1; i < Session.MAX_GUARDED_HELD_INPUTS; i++) { + expect(h.session.holdForGuardedLease(() => order.push('frame'))).toBe('held'); + } + expect(h.session.holdForGuardedLease(() => order.push('lost'))).toBe('full'); + await expect(h.session.performAuxiliaryInput(async () => order.push('lost'))).rejects.toBeInstanceOf( + GuardedInputBusyError + ); + await expect(h.session.writeViaMux('lost')).resolves.toBe(false); + h.finishEnter(true); + await submit; + await key; + expect(order[0]).toBe('key'); + expect(order.filter((o) => o === 'frame')).toHaveLength(Session.MAX_GUARDED_HELD_INPUTS - 1); + expect(order).not.toContain('lost'); + } finally { + await h.stop(); + } + }); + + it('caps held bytes as well as held frames', async () => { + const h = harness(); + try { + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES)).toBe('held'); + expect(h.session.holdForGuardedLease(() => {}, 1)).toBe('full'); + h.finishEnter(true); + await submit; + expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES + 1)).toBe('free'); + } finally { + await h.stop(); + } + }); + + it('a frame arriving while a held named key is still being sent waits for it', async () => { + const h = harness(); + try { + const socket = h.connect('c1'); + let keySent!: () => void; + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const key = h.session.performAuxiliaryInput(async () => { + await new Promise<void>((resolve) => { + keySent = resolve; + }); + h.pty.push('<newline>'); + }); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(true); + await submit; + // The lease is over, but the held key is still in flight: input stays held. + expect(h.session.guardedSubmitInFlight).toBe(false); + expect(h.session.guardedInputHeld).toBe(true); + socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' }); + expect(h.pty).toEqual(['\r']); + keySent(); + await key; + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(h.pty).toEqual(['\r', '<newline>', 'a', 'b']); + expect(socket.acks()).toEqual([1, 2]); + expect(h.session.guardedInputHeld).toBe(false); + } finally { + await h.stop(); + } + }); + + it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => { + const h = harness(true); + try { + const socket = h.connect('c1'); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' }); + h.finishEnter(true); + await expect(submit).resolves.toBe(false); + expect(h.enterDispatched()).toBe(false); + expect(h.pty).toEqual(['a']); + expect(socket.acks()).toEqual([1]); + } finally { + await h.stop(); + } + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index f770a76c..cb9d4891 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -25,7 +25,7 @@ import { tmpdir } from 'node:os'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; -import { Session } from '../../src/session.js'; +import { GuardedInputBusyError, Session } from '../../src/session.js'; import { agentInbox } from '../../src/web/agent-inbox.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). @@ -265,6 +265,33 @@ describe('session-routes', () => { }); }); + it('answers 409 CONFLICT, without an error log, when a guarded submit cannot hold the key', async () => { + execFile.mockReset(); + const session = harness.ctx._session as unknown as { + performAuxiliaryInput: (op: () => Promise<void>) => Promise<void>; + }; + const original = session.performAuxiliaryInput; + session.performAuxiliaryInput = async () => { + throw new GuardedInputBusyError(); + }; + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/send-key', + payload: { key: 'S-Enter' }, + }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(res.json().error).toMatch(/guarded submit is in progress.*nothing was written/); + expect(errors).not.toHaveBeenCalled(); + expect(execFile).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + session.performAuxiliaryInput = original; + } + }); + it('rejects keys outside the hex allowlist without invoking tmux', async () => { execFile.mockReset(); const res = await harness.app.inject({ @@ -1203,7 +1230,7 @@ describe('session-routes', () => { const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; session.writeBuffer.length = 0; const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); - session.guardedSubmitInFlight = true; + session.guardedInputHeld = true; for (const payload of [ { input: 'x' }, { input: 'x', useMux: true }, @@ -1218,7 +1245,7 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual([]); // Once the lease is released the browser's retry of the same seq is applied, not // rejected as a duplicate. - session.guardedSubmitInFlight = false; + session.guardedInputHeld = false; expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); expect(session.writeBuffer).toEqual(['tagged']); }); From 6368128f1e951353511a6b97c4da1cd21f34b997 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Tue, 27 Oct 2026 02:07:39 +0100 Subject: [PATCH 9/9] fix: apply the review items for #694 at merge - src/web/routes/session-routes.ts:2102: the guarded-submit 409 now applies to tagged input (clientId + seq) only; untagged input is held like a WebSocket frame. The raw write (:2228) goes through holdForGuardedLease and answers 200 once held (a send-and-wait reads `delivered` from the held write; a full queue releases the waiter and answers 409); a mux write takes its own turn in writeViaMux. - test/guarded-lease-held-input.test.ts: real-Session route tests for an untagged raw POST during the lease (written after the Enter) and a held send-and-wait (delivered: true); both fail without the fix. - test/routes/session-routes.test.ts + test/mocks/mock-session.ts: the 409 test now covers tagged input only; new tests for untagged raw (held), untagged mux (own turn) and a full hold queue (409); MockSession gains holdForGuardedLease/releaseGuardedLease and a lease-aware writeViaMux. - src/session.ts:4787 (guardedInputHeld JSDoc) + write() + docs/architecture-invariants.md: write() refuses during the lease only, so cron's paste mode can land ahead of input still draining. - docs/api-reference.md: the input route's 409 is for tagged input; untagged input is held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/api-reference.md | 30 ++++++----- docs/architecture-invariants.md | 11 ++++- src/session.ts | 4 ++ src/web/routes/session-routes.ts | 53 +++++++++++++++++--- test/guarded-lease-held-input.test.ts | 57 ++++++++++++++++++++- test/mocks/mock-session.ts | 27 ++++++++-- test/routes/session-routes.test.ts | 71 +++++++++++++++++++++++++-- 7 files changed, 221 insertions(+), 32 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 2476f983..1be7dc6f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -426,11 +426,12 @@ worker's prompt but never submitted, and the wait then runs its full timeout on turn that never started. Verified live; this is the most common silent failure on this endpoint. -Any input, waiting or not, answers `409 CONFLICT` with nothing written while an -experimental guarded submit holds the session's input lease, or input held behind -it is still being written (a few milliseconds; see -[guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). -Retry it. +Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT` +with nothing written while an experimental guarded submit holds the session's +input lease, or input held behind it is still being written (a few milliseconds; +see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). +Retry it. Untagged input is held instead and written in arrival order once the +lease is released. A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as @@ -716,15 +717,18 @@ outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged only once it is written, so a frame typed during the lease lands before the one typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval answers and Codeman's own managed writes. A held write that is still being sent -holds everything behind it. `POST /api/v1/sessions/:id/input` instead answers -`409 CONFLICT` ("A guarded submit is in progress on this session; nothing was -written") for that span, the lease plus the held input's drain, rather than a -`200` for bytes that were never written; a tagged `clientId`/`seq` frame keeps -its seq, so the browser's durable queue, whose POST fallback sends one frame at a -time and keeps it on a `409`, delivers it on the next retry. A lease holds at -most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and +holds everything behind it. A tagged `POST /api/v1/sessions/:id/input` (one +carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit +is in progress on this session; nothing was written") for that span, the lease +plus the held input's drain, rather than a `200` for bytes that were never +written; it keeps its seq, so the browser's durable queue, whose POST fallback +sends one frame at a time and keeps it on a `409`, delivers it on the next retry. +Untagged input to that route is held like a WebSocket frame, because its senders +(the phone accessory keys, the Stop button) never read the answer. A lease holds +at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and nothing later from it is applied, so the browser reconnects and resends every -unacknowledged frame in order, and `send-key` answers `409 CONFLICT`. +unacknowledged frame in order, and `send-key` and an untagged raw input answer +`409 CONFLICT`. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2e2495eb..34016ed2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1330,8 +1330,15 @@ N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once wri a held async write (mux write, named key) holds everything behind it until it settles (`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with `1013` and applies nothing later from it, so the browser resends in order. `POST -.../input` alone answers `409` (seq returned) for the lease plus the drain, since its -browser drainer is serialized and retries. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its +.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain, +since its browser drainer is serialized and retries; untagged input there (the phone +accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the +answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write +by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue +it again behind later input and wait on itself). `write()` refuses during the lease +only, since the drain writes held input through it, so a direct `write()` caller such +as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining. +⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and an Enter within 120 ms of one becomes a newline while submit would report it delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and diff --git a/src/session.ts b/src/session.ts index dc43c4ff..08dd83d3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4784,6 +4784,9 @@ export class Session extends EventEmitter { * True while input is being held behind a guarded submit: the lease itself, or held * input not yet written after it. A write that is not routed through * {@link holdForGuardedLease} must refuse in this window, or it overtakes held input. + * `write()` is the known exception: it refuses during the lease only, since the drain + * writes held input through it, so a direct `write()` caller (cron's `paste` mode in + * `cron/cron-service.ts`) can still land ahead of input that is draining. */ get guardedInputHeld(): boolean { return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining; @@ -4983,6 +4986,7 @@ export class Session extends EventEmitter { */ write(data: string, options: SessionWriteOptions = {}): boolean { this._observeGuardedKeyboardInput(data); + // The lease only, not the drain, which writes held input through here (see guardedInputHeld). if (this._guardedSubmitInFlight) return false; const submittedPrompt = this._trackSubmit(data, options); if (!this.ptyProcess) return false; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index ae4a523f..1d72f986 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2080,15 +2080,18 @@ export function registerSessionRoutes( // A guarded submit (agent-staging.ts) holds the input lease for the few // milliseconds of its conditional Enter, and input that arrives meanwhile is held - // and written in arrival order after it. This route refuses for that span (the - // lease plus the held input's drain) rather than overtake held input. Say so + // and written in arrival order after it. A TAGGED request is refused for that span + // (the lease plus the held input's drain) rather than overtake held input: say so // instead of answering 200 for bytes that were never written, and give the seq // back so the browser's durable queue (which keeps a frame on any 409, and whose - // POST drainer sends one frame at a time) delivers it on its next retry. Nothing - // from here to the write below awaits, so the lease cannot be taken in between. - if (!duplicate && session.guardedInputHeld === true) { - if (tagged) session.forgetInputSeq(clientId as string, seq as number); - return reply + // POST drainer sends one frame at a time) delivers it on its next retry. An + // UNTAGGED request has no retry behind it (the phone accessory keys and the Stop + // button's Ctrl+C are fire-and-forget POSTs that never read the answer), so it is + // held instead, like a WebSocket frame: `writeViaMux` takes its own turn behind + // the lease, and the raw write below is held explicitly. Nothing from here to the + // write below awaits, so the lease cannot be taken in between. + const refuseGuarded = () => + reply .code(409) .send( createErrorResponse( @@ -2096,6 +2099,9 @@ export function registerSessionRoutes( 'A guarded submit is in progress on this session; nothing was written. Retry the input.' ) ); + if (!duplicate && tagged && session.guardedInputHeld === true) { + session.forgetInputSeq(clientId as string, seq as number); + return refuseGuarded(); } // Only a waiting request pays for the tmux probe: the browser's plain input path @@ -2208,7 +2214,38 @@ export function registerSessionRoutes( // Same rollback. NOT an error response, deliberately: a session can // legitimately have no PTY yet (created but not started), and callers have // always been able to write to one without a 4xx. - delivered = session.write(inputStr, { fromUser: true }); + // + // During a guarded submit (untagged only: a tagged one was refused above) the + // write is held and runs in arrival order once the lease is released. The answer + // is a 200 once held; a send-and-wait reads `delivered` from the held write. Only + // a full hold queue refuses, after releasing the waiter registered above. + let heldWrite: Promise<boolean> | null = null; + if (session.guardedInputHeld === true) { + let settle!: (written: boolean) => void; + const written = new Promise<boolean>((resolve) => { + settle = resolve; + }); + const held = session.holdForGuardedLease( + () => { + let ok = false; + try { + ok = session.write(inputStr, { fromUser: true }); + } finally { + settle(ok); + } + }, + inputStr.length, + inputStr + ); + if (held === 'full') { + abort.abort(); + stopDeathWatch(); + return refuseGuarded(); + } + if (held === 'held') heldWrite = written; + } + if (heldWrite && !waitPromise) return {}; + delivered = heldWrite ? await heldWrite : session.write(inputStr, { fromUser: true }); if (!delivered && tagged) { session.forgetInputSeq(clientId as string, seq as number); } diff --git a/test/guarded-lease-held-input.test.ts b/test/guarded-lease-held-input.test.ts index ffeb0a99..31191fcb 100644 --- a/test/guarded-lease-held-input.test.ts +++ b/test/guarded-lease-held-input.test.ts @@ -9,12 +9,14 @@ * is still unACKed afterwards, in seq order, the way `_redeliverSweep` does. */ import { EventEmitter } from 'node:events'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { formatPaneSnapshot } from '../src/tmux-manager.js'; import { GuardedInputBusyError, Session } from '../src/session.js'; import { registerWsRoutes } from '../src/web/routes/ws-routes.js'; +import { registerSessionRoutes } from '../src/web/routes/session-routes.js'; import type { SessionPort } from '../src/web/ports/session-port.js'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; type Frame = { t: 'i'; d: string; seq?: number; cid?: string }; @@ -376,6 +378,59 @@ describe('input during a guarded submit lease', () => { } }); + it('holds an untagged raw POST .../input during the lease and writes it after the Enter', async () => { + const h = harness(); + const route = await createRouteTestHarness(registerSessionRoutes); + try { + (route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + expect(h.session.guardedSubmitInFlight).toBe(true); + // The Stop button's Ctrl+C: an untagged POST whose answer nobody reads. + const res = await route.app.inject({ + method: 'POST', + url: `/api/sessions/${h.session.id}/input`, + payload: { input: '\x03' }, + }); + expect(res.statusCode).toBe(200); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await expect(submit).resolves.toBe(true); + expect(h.pty).toEqual(['\r', '\x03']); + } finally { + await route.app.close(); + await h.stop(); + } + }); + + it('a held untagged raw send-and-wait reports the held write as delivered', async () => { + const h = harness(); + const route = await createRouteTestHarness(registerSessionRoutes); + try { + (route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session); + const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!); + await Promise.resolve(); + const answer = route.app.inject({ + method: 'POST', + url: `/api/sessions/${h.session.id}/input`, + payload: { input: '\x03', useMux: false, wait: 'exit', waitTimeout: 1000 }, + }); + // Private, but the only proof the route reached the hold before the lease ends. + const queue = (h.session as unknown as { _guardedHeld: unknown[] })._guardedHeld; + await vi.waitFor(() => expect(queue).toHaveLength(1)); + expect(h.pty).toEqual([]); + h.finishEnter(true); + await submit; + const res = await answer; + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ delivered: true, duplicate: false }); + expect(h.pty).toEqual(['\r', '\x03']); + } finally { + await route.app.close(); + await h.stop(); + } + }); + it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => { const h = harness(true); try { diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index fa2b8c5e..d5df6104 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -111,6 +111,22 @@ export class MockSession extends EventEmitter { await operation(); } + /** Mirrors Session.guardedInputHeld; a route test sets it to stand in for a guarded submit's lease. */ + guardedInputHeld = false; + /** Input held behind that lease, in arrival order (see releaseGuardedLease). */ + heldGuardedInput: Array<() => void | Promise<unknown>> = []; + /** Mirrors Session.holdForGuardedLease, without the cap (a test replaces it to model a full queue). */ + holdForGuardedLease(run: () => void | Promise<unknown>, _bytes = 0, _data?: string): 'free' | 'held' | 'full' { + if (!this.guardedInputHeld) return 'free'; + this.heldGuardedInput.push(run); + return 'held'; + } + /** Ends the stand-in lease and runs the held input in arrival order, as Session does. */ + releaseGuardedLease(): void { + this.guardedInputHeld = false; + for (const run of this.heldGuardedInput.splice(0)) void run(); + } + private _muxName: string | null = null; constructor(id: string = 'mock-session-id') { @@ -134,9 +150,14 @@ export class MockSession extends EventEmitter { /** Write via mux (used by respawn controller) */ async writeViaMux(data: string): Promise<boolean> { - if (this.failWrites) return false; - this.writeBuffer.push(data); - return true; + const write = (): boolean => { + if (this.failWrites) return false; + this.writeBuffer.push(data); + return true; + }; + // Like Session.writeViaMux, it waits its turn behind a stand-in guarded lease. + if (!this.guardedInputHeld) return write(); + return new Promise<boolean>((resolve) => this.heldGuardedInput.push(() => resolve(write()))); } /** diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index cb9d4891..39940a96 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1224,7 +1224,7 @@ describe('session-routes', () => { expect(session.writeBuffer).toEqual(['prompt', '\r']); }); - it('refuses with 409 and gives the seq back while a guarded submit holds the input lease', async () => { + it('refuses tagged input with 409 and gives the seq back while a guarded submit holds the input lease', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; @@ -1232,10 +1232,8 @@ describe('session-routes', () => { const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); session.guardedInputHeld = true; for (const payload of [ - { input: 'x' }, - { input: 'x', useMux: true }, - { input: 'prompt\r' }, { input: 'tagged', seq: 7, clientId: 'cid-guard' }, + { input: 'prompt\r', useMux: true, seq: 1, clientId: 'cid-guard-mux' }, ]) { const refused = await post(payload); expect(refused.statusCode, JSON.stringify(payload)).toBe(409); @@ -1243,13 +1241,76 @@ describe('session-routes', () => { expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/); } expect(session.writeBuffer).toEqual([]); + expect(session.heldGuardedInput).toEqual([]); // Once the lease is released the browser's retry of the same seq is applied, not // rejected as a duplicate. - session.guardedInputHeld = false; + session.releaseGuardedLease(); expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200); expect(session.writeBuffer).toEqual(['tagged']); }); + it('holds an untagged raw POST during a guarded submit and writes it once the lease is released', async () => { + // The phone accessory keys and the Stop button's Ctrl+C are untagged, fire-and-forget + // POSTs that never read the answer, so a 409 here silently lost the tap. + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + session.guardedInputHeld = true; + try { + for (const input of ['\x03', '\x1b[A']) { + const res = await harness.app.inject({ method: 'POST', url, payload: { input } }); + expect(res.statusCode, JSON.stringify(input)).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + } + expect(session.writeBuffer).toEqual([]); + session.releaseGuardedLease(); + expect(session.writeBuffer).toEqual(['\x03', '\x1b[A']); + } finally { + session.releaseGuardedLease(); + } + }); + + it('lets untagged mux input wait its own turn behind the lease instead of refusing it', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload }); + session.guardedInputHeld = true; + try { + // Fire-and-forget `useMux`: answered at once, written in its turn. + expect((await post({ input: 'x', useMux: true })).statusCode).toBe(200); + // A plain prompt's mux write is awaited, so it answers once written. + const prompt = post({ input: 'prompt\r' }); + await vi.waitFor(() => expect(session.heldGuardedInput).toHaveLength(2)); + expect(session.writeBuffer).toEqual([]); + session.releaseGuardedLease(); + expect((await prompt).statusCode).toBe(200); + expect(session.writeBuffer).toEqual(['x', 'prompt\r']); + } finally { + session.releaseGuardedLease(); + } + }); + + it('refuses an untagged raw POST with 409 only when the hold queue is full', async () => { + const url = `/api/sessions/${harness.ctx._sessionId}/input`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any; + session.writeBuffer.length = 0; + session.guardedInputHeld = true; + session.holdForGuardedLease = () => 'full'; + try { + const res = await harness.app.inject({ method: 'POST', url, payload: { input: '\x03' } }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(session.writeBuffer).toEqual([]); + } finally { + delete session.holdForGuardedLease; + session.releaseGuardedLease(); + } + }); + it('always applies untagged input (curl/legacy, no dedup)', async () => { const url = `/api/sessions/${harness.ctx._sessionId}/input`; // eslint-disable-next-line @typescript-eslint/no-explicit-any