From a24b0ec7bbf9c832f7522c2e9096fa7f97943131 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 14:15:14 -0400 Subject: [PATCH] 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);