diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index b88bcef9..73f9513a 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -4,7 +4,11 @@ Add experimental bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. -Explicitly exclude the three new CLI verbs and matching HTTP routes (including `/api/v1` and `/api` aliases) from the stable contract while experimental; their shape and behavior may change in minor or patch releases. +The three new CLI verbs (`codeman agent inspect`, `stage`, `submit`) and their HTTP routes (`/api/v1` and `/api`) are labeled experimental, so the versioning policy's rule for experimental surfaces applies: their shape and behavior may change in minor or patch releases. They are opt-in behind a new synced setting, `agentCoordinationEnabled` (App Settings, Cross-session features, "Experimental agent coordination", default OFF, translated for zh-CN): while it is off all three routes answer `403 FORBIDDEN` with a message naming it, and the CLI verbs print that refusal verbatim. + +Codex rollout attribution remembers the attributed file per session and re-verifies only that file's head on later calls, rescanning only the date directories not older than it for a codex `/new`, so a large never-pruned `~/.codex/sessions` store no longer falls back to a pane snapshot or costs a full walk per call. An unexpected inspection read error answers `422 OPERATION_FAILED` and is logged with the session id and error code only, instead of a retryable `409`. + +The guarded composer read takes its glyph and footer from the CLI registry: codex's `workDetect.promptGlyph` and a new `workDetect.composerFooterLine` capability (config-supplied regex, compiled through the ReDoS guard). Only a CLI declaring it takes guarded input. Codex's footer effort words are now one shared list for this and `modelDetect.screenLine`. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. diff --git a/CLAUDE.md b/CLAUDE.md index 683084f9..a5e8101f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` -**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked -`agent-session-routes.ts` module; the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. @@ -176,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) diff --git a/README.md b/README.md index 36f0bf74..19d13314 100644 --- a/README.md +++ b/README.md @@ -977,7 +977,7 @@ codeman tui 3 # attach to session 3 of that list ### `codeman agent` — session-to-session verbs in every CLI mode -The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The new `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental**: commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases. Pin a compatible version when automating them; see the [stability exception](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. +The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. ```bash codeman agent ls # sessions; * marks this one diff --git a/docs/api-reference.md b/docs/api-reference.md index 2bcbebb6..2d4c6161 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -593,11 +593,20 @@ request returns. The capture runs synchronous tmux calls on the server; the **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental, so the versioning policy's rule for experimental +surfaces applies (`docs/versioning-policy.md`, "Experimental agent +coordination"): they are not part of the stable contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced +`agentCoordinationEnabled` setting is off (the default; App Settings, +Cross-session features, **Experimental agent coordination**), with a message +naming it. The check runs before session lookup and body validation, and reads +the saved setting on every request, so turning it on needs no restart. +`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1). + These authenticated routes use the standard envelope and session ownership checks. `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before any transcript, pane or receipt lookup. @@ -617,7 +626,14 @@ keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution separately caps at 8192 directory entries, eight directory levels, 8 MiB of negative-filter prefix reads and 1 MiB of candidate metadata reads (including -final identity verification). Unreachable workspace/home/metadata directories +final identity verification). The negative filter reads 1 KiB of each rollout +first and 4 KiB only when that window holds no complete `originator` field. Once +a rollout is attributed, later calls re-verify only that file (same file identity, +same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not +older than its own, for a second candidate such as a codex `/new`; a failed check +falls back to one full walk. So the caps bound the first call, not every call. +An unexpected read error is `422 OPERATION_FAILED` (logged with the session id +and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain unattributed. A full incremental page can be continued with its cursor; only a first pending record too large for the budget reports an output-budget block. @@ -643,7 +659,10 @@ filesystem path is returned as cursor/identity metadata; evidence retains the owner's native record content, which can itself contain paths. Guarded input initially requires a local characterized Codex composer, one -unwrapped visible line, a recognized footer and a cursor at its exact end. +unwrapped visible line, a recognized footer and a cursor at its exact end. The +composer glyph and footer are the CLI registry's +`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that +declares no `composerFooterLine` is refused. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing whitespace (the existing mux transport trims it). Before typing, it checks the diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3feb4f96..edd12634 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. -⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. +⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. @@ -1264,8 +1264,15 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ## Agent inspection and guarded submission These three operations are experimental across both CLI and HTTP, including the -`/api/v1` paths and unversioned aliases. See `docs/versioning-policy.md` for the -explicit stability exception; do not infer stability from the URL prefix. +`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule +in `docs/versioning-policy.md`, so do not infer stability from the URL prefix. + +⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming +`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session +features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The +gate runs first, before session lookup or body validation, and reads +`settings.json` fresh on every request, so a toggle applies without a restart. +`codeman agent inspect|stage|submit` pass the 403 through verbatim. `agent-session-routes.ts` is registered by `WebServer` with the other route modules. Ownership is checked before transcript/pane/receipt lookup. Inspection @@ -1275,8 +1282,24 @@ complete UTF8 JSONL records. A full page advances its cursor without claiming a blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane geometry is excluded so changing devices does not invalidate transcript paging. -Staging requires a fresh, known local Codex composer/caret capture. The footer -uses the shared `CODEX_REASONING_EFFORTS` vocabulary. Before any write, available +⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store +walk per call. The first call walks (negative-filter prefixes read 1 KiB first, +4 KiB only when that window holds no complete `originator` field; non-UUID file +names are skipped unread), then the attributed `{path, dev:ino}` and its strict +metadata are remembered per session (`attributionMemo`, bounded LRU). A later call +re-verifies only that file's head, then scans just the date directories not older +than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is +never pruned) for a second candidate, which is how a codex `/new` or a new owned +rollout beside a configured resume is still caught. A failed check drops the memo +and falls back to one full walk. Non-`InspectionConflict` errors are `422 +OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and +error code only, never the path. + +Staging requires a fresh, known composer/caret capture. The composer glyph and +footer are registry data (`capabilities.workDetect.promptGlyph` and +`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI +declaring `composerFooterLine` takes guarded input (codex today). Codex's footer +words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, including indentation; reaching the last column or wrapping is refused. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 72c28a2d..955b6f88 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -47,9 +47,10 @@ interface CliEntry { env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, - // blockedLine?, blockedLines? } + // blockedLine?, blockedLines?, composerFooterLine? } // (how this CLI's pane shows work, work it started in the background, a turn - // that ended waiting for workers it will resume from, and a dialog it is blocked on) + // that ended waiting for workers it will resume from, a dialog it is blocked on, + // and the rows under its idle composer that guarded input relies on) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -63,7 +64,7 @@ interface CliEntry { ### Regexes that come from config -Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Seven capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine` and `capabilities.modelDetect.screenLine`. All seven go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -124,6 +125,18 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a pattern, make sure its row is one the agent cannot write. +### The idle composer guarded input reads (`composerFooterLine`) + +The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through +`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture, +that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the +exact end of its text. `composerFooterLine` is the second half of that proof: every +non-blank row below the composer (at most three) must match it, one row at a time. A CLI +that declares none never takes guarded input; only codex declares one, built from the same +effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a +codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the +foot of the screen, so a pattern must anchor on chrome only that CLI draws there. + ### Dialogs a CLI blocks on (`blockedLine`) `blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index bf42c095..8f8581a5 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -64,17 +64,22 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: ### Experimental agent coordination -`codeman agent inspect`, `codeman agent stage` and `codeman agent submit` are -experimental. The matching `GET /api/v1/sessions/:id/inspect`, -`POST /api/v1/sessions/:id/stage` and `POST /api/v1/sessions/:id/submit` routes, -including their unversioned `/api` aliases, share this designation. This is an -explicit exception to both the CLI and HTTP stability promises above: these -commands, flags, endpoint paths, payloads and behavior may change or be removed -in a MINOR or PATCH release without a stable-surface deprecation period. Pin a -compatible Codeman version when automating them. Existing authentication, -ownership and shared response-envelope rules still apply. Other agent commands -and API routes retain their existing stability guarantees. Promotion to stable -requires an explicit documented decision. +`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and +the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage` +and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since +every `/api` route is also served under `/api/v1`), are labeled experimental, so +item 3 above applies to them: their commands, flags, endpoint paths, payloads and +behavior may change or be removed in a MINOR or PATCH release. They are also +opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced +`agentCoordinationEnabled` setting (default OFF) until it is turned on, so +automation cannot come to depend on them without choosing to. Pin a compatible +Codeman version when automating them. Existing authentication, ownership and +shared response-envelope rules still apply. The session behavior underneath them +(input revision tracking in the session's write paths, the SubmitVerifier +re-press going through the shared write boundary) runs for every session and is +covered like any other stable behavior. Other agent commands and API routes keep +their existing guarantees. Promotion to stable requires an explicit documented +decision. ## Deprecation policy diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 776eddf1..18d21b5d 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -44,9 +44,9 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 codeman agent read "$SID" # last answer (as the server reads it for that mode) codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode) -codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled) -codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only -codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter +codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor +codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only +codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves … codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form codeman agent interrupt "$SID" # a bare ESC, conversation intact @@ -70,6 +70,15 @@ codeman agent rm "$SID" # any session except t `ls` shows each session's mail in an INBOX column. Mail lives in memory unless **Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)), and goes when its session is deleted. +- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN` + naming the setting until **Experimental agent coordination** is on (see + [Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by + default), and they may change in any release. `inspect` returns bounded evidence with an + opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a + local Codex composer, with no Enter, and returns a receipt that expires after two minutes; + `submit` sends one Enter only if the composer still shows exactly that text, and never + replays it. `send --no-enter` alone has no receipt. Details: + [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input). - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. @@ -267,5 +276,3 @@ failing the spawn, because a cosmetic field must never break a worker. - [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way. - [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI. - [`skills/codeman/SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) - the skill itself. - -Bounded inspection and guarded stage/submit details: [API reference](../api-reference.md#bounded-agent-inspection-and-guarded-input). Stage accepts one printable UTF8 line without trailing whitespace; receipts expire after two minutes. Submit refuses changed/unprovable composers and never automatically replays Enter. `send --no-enter` alone has no receipt. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676b..43099736 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -106,6 +106,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha memory. Messages expire 12 hours after they were posted either way. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). +**Experimental agent coordination** (under **Cross-session features**, synced, off by +default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded, +paged reads of another session's output, and typing a prompt into a local Codex composer +that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP +routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are +experimental and may change in any release. See +[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands). + ### Appearance | Setting | Notes | diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index d624f5d7..c53530aa 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -2,11 +2,16 @@ **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct tmux access is required. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 54a8899c..85a7b981 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -816,10 +816,11 @@ Rules that matter: ## Experimental bounded inspection and guarded submission -These three commands and their HTTP routes may change or be removed in minor or -patch releases; they are excluded from the stable CLI/HTTP contract. - -Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed `codeman agent submit --stage-id --confirm-sha` when the exact local Codex composer and caret can be verified. Other targets provide labeled snapshots or diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index d624f5d7..c53530aa 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -2,11 +2,16 @@ **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), -are excluded from the stable CLI/HTTP contract. Their commands, flags, paths, +are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. +They are also opt-in: until the user turns on **Experimental agent coordination** +in App Settings (`agentCoordinationEnabled`, off by default), every one of them +fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something +to retry or work around: tell the user, or use `send`/`read` instead. + Use these commands inside a Codeman session. They use its authenticated HTTP API. They supplement `send`, `wait` and `read` over the same HTTP transport; no direct tmux access is required. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 54a8899c..85a7b981 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -816,10 +816,11 @@ Rules that matter: ## Experimental bounded inspection and guarded submission -These three commands and their HTTP routes may change or be removed in minor or -patch releases; they are excluded from the stable CLI/HTTP contract. - -Use `codeman agent inspect` for bounded evidence and incremental local Codex cursors. +These three commands and their HTTP routes are labeled experimental, so they may +change or be removed in minor or patch releases. They are also opt-in +(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns +it on). Use `codeman agent inspect` for bounded evidence and incremental local +Codex cursors. Use `codeman agent stage --message-file` followed by one hash-confirmed `codeman agent submit --stage-id --confirm-sha` when the exact local Codex composer and caret can be verified. Other targets provide labeled snapshots or diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 97cfe50c..490f7267 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -1,11 +1,16 @@ /** * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. + * The first call walks the store under explicit budgets; the attributed file is + * then remembered per session and only re-verified (plus a scan of the date + * directories not older than it) on later calls, since codex never prunes + * `~/.codex/sessions`. */ import { open, opendir, realpath } from 'node:fs/promises'; import { probePathKind } from './utils/bounded-path-probe.js'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { LRUMap } from './utils/lru-map.js'; const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const HEAD_BYTES = 131072; @@ -67,6 +72,42 @@ function strictMetadata(bytes: Buffer): Metadata | undefined { } return found; } +/** + * The rollout a previous call attributed, per session. A later call re-verifies only + * this file's head (same `dev:ino`, same strict metadata) and then scans just the + * directories not older than its own for a second candidate (codex `/new`, or a new + * owned rollout beside a configured resume), instead of re-reading the whole store, + * which codex never prunes. Any failed check drops the entry and falls back to the + * full walk. Bounded: one entry per live session is the expected size. + */ +interface AttributionMemo { + id: string; + path: string; + dev: number; + ino: number; + meta: Metadata; + /** Directory components of `path` below `sessions/`, used to prune older siblings. */ + dirParts: string[]; +} +const attributionMemo = new LRUMap({ maxSize: 1024 }); +/** Test-only: forget every remembered attribution. */ +export function __clearCodexAttributionMemo(): void { + attributionMemo.clear(); +} +/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */ +const PREFIX_FIRST_BYTES = 1024; +/** Fallback read when the first window holds no complete `originator` field. */ +const PREFIX_MAX_BYTES = 4096; +const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/; +/** + * Is a date-shaped directory name older than the remembered one at the same depth? + * Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else + * is never pruned, so an unexpected layout costs a wider scan, not a missed rollout. + */ +function olderDatePart(name: string, memo: string | undefined): boolean { + if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false; + return Number(name) < Number(memo); +} /** * A configured resume and a newly owned rollout conflict rather than pinning an * old launch forever after /new. TUI resumes of foreign-originator threads cannot @@ -81,9 +122,92 @@ export async function findExactCodexConversation( if (!workspace || !home) return undefined; const sessions = join(home, 'sessions'); if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; - const directories = [{ path: sessions, depth: 0 }]; + const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0'); + const remembered = attributionMemo.get(key); + if (remembered) { + const verified = await verifyRemembered(remembered, workspace, budget); + if (verified) { + const others = await scanCandidates(sessions, options, budget, remembered.dirParts); + if (others && others.length === 1 && others[0].path === remembered.path) { + return { id: remembered.id, path: remembered.path }; + } + } + attributionMemo.delete(key); + // A second candidate is a conflict a full walk would report too; anything + // else (a replaced file, an exhausted budget) gets one full walk now. + if (verified) return undefined; + } + const candidates = await scanCandidates(sessions, options, budget); + if (!candidates || candidates.length !== 1) return undefined; + const candidate = candidates[0]; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; + const handle = await open(candidate.path, 'r').catch(() => undefined); + if (!handle) return undefined; + try { + const info = await handle.stat(); + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + if ( + !meta || + meta.id !== candidate.id || + meta.cwd !== candidate.meta.cwd || + meta.originator !== candidate.meta.originator + ) + return undefined; + attributionMemo.set(key, { + id: candidate.id, + path: candidate.path, + dev: info.dev, + ino: info.ino, + meta, + dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean), + }); + return { id: candidate.id, path: candidate.path }; + } finally { + await handle.close(); + } +} +/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */ +async function verifyRemembered( + memo: AttributionMemo, + workspace: string, + budget: CodexAttributionBudget +): Promise { + if (HEAD_BYTES > budget.maxBytes) return false; + if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false; + const handle = await open(memo.path, 'r').catch(() => undefined); + if (!handle) return false; + try { + const info = await handle.stat(); + if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false; + const bytes = Buffer.alloc(HEAD_BYTES); + const read = await handle.read(bytes, 0, bytes.length, 0); + const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); + return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator; + } catch { + return false; + } finally { + await handle.close(); + } +} +/** + * Every rollout under `sessions` whose strict metadata names this session (or the + * configured resume id), or undefined when the walk hit a bound or an unreadable + * entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped + * directories older than those components are skipped. + */ +async function scanCandidates( + sessions: string, + options: CodexAttributionOptions, + budget: CodexAttributionBudget, + notOlderThan?: string[] +): Promise | undefined> { + // `onBoundary`: every ancestor so far equals the remembered path, so pruning applies. + const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }]; const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; const originator = `codeman_${options.sessionId}`; + const originatorJson = JSON.stringify(originator); const explicit = options.resumeSessionId; let entries = 0; let prefixes = 0; @@ -98,32 +222,43 @@ export async function findExactCodexConversation( const path = join(dir.path, entry.name); if (entry.isDirectory()) { if (dir.depth >= 8) return undefined; - directories.push({ path, depth: dir.depth + 1 }); + const part = notOlderThan?.[dir.depth]; + if (dir.onBoundary && olderDatePart(entry.name, part)) continue; + directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part }); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + const id = entry.name.match(UUID_AT_END)?.[1]; + if (!id) continue; // no rollout id in the name: never a candidate, read nothing const file = await open(path, 'r').catch(() => undefined); if (!file) return undefined; try { const info = await file.stat(); if (!info.isFile()) return undefined; - const id = entry.name.match(UUID_AT_END)?.[1]; - const prefixBytes = Math.min(info.size, 4096); - if (prefixes + prefixBytes > prefixLimit) return undefined; - prefixes += prefixBytes; - const prefix = Buffer.alloc(prefixBytes); - const first = await file.read(prefix, 0, prefixBytes, 0); - // Cheap negative filter only; a match still needs full parsed metadata. - if ( - id !== explicit && - !prefix.subarray(0, first.bytesRead).toString('utf8').includes(JSON.stringify(originator)) - ) - continue; + if (id !== explicit) { + // Cheap negative filter only; a match still needs full parsed metadata. + // A short first window decides when it already holds the whole + // `originator` field; only otherwise is the wider window read. + let window = Math.min(info.size, PREFIX_FIRST_BYTES); + let prefix = ''; + for (;;) { + if (prefixes + window > prefixLimit) return undefined; + prefixes += window; + const buffer = Buffer.alloc(window); + const first = await file.read(buffer, 0, window, 0); + prefix = buffer.subarray(0, first.bytesRead).toString('utf8'); + if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break; + const wider = Math.min(info.size, PREFIX_MAX_BYTES); + if (wider <= window) break; + window = wider; + } + if (!prefix.includes(originatorJson)) continue; + } const size = Math.min(info.size, HEAD_BYTES); if (scanned + size > budget.maxBytes) return undefined; scanned += size; const bytes = Buffer.alloc(size); const read = await file.read(bytes, 0, size, 0); const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); - if (!meta || !id || meta.id !== id) continue; + if (!meta || meta.id !== id) continue; if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta }); } finally { await file.close(); @@ -131,24 +266,5 @@ export async function findExactCodexConversation( } } } - if (candidates.length !== 1) return undefined; - const candidate = candidates[0]; - if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; - const handle = await open(candidate.path, 'r').catch(() => undefined); - if (!handle) return undefined; - try { - const bytes = Buffer.alloc(HEAD_BYTES); - const read = await handle.read(bytes, 0, bytes.length, 0); - const meta = strictMetadata(bytes.subarray(0, read.bytesRead)); - if ( - !meta || - meta.id !== candidate.id || - meta.cwd !== candidate.meta.cwd || - meta.originator !== candidate.meta.originator - ) - return undefined; - return { id: candidate.id, path: candidate.path }; - } finally { - await handle.close(); - } + return candidates; } diff --git a/src/agent-staging.ts b/src/agent-staging.ts index 2dbd2382..84795170 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -6,10 +6,30 @@ import { createHash, randomUUID } from 'node:crypto'; import { stripAnsi } from './utils/regex-patterns.js'; import { visibleWidth } from './tui/tui-ansi.js'; -import { CODEX_REASONING_EFFORTS } from './types/session.js'; -const FOOTER_LINE = new RegExp( - String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )` -); +import { compileVersionRegex } from './config/cli-registry/patterns.js'; +import type { CliEntry } from './config/cli-registry/types.js'; + +/** + * The screen chrome a guarded composer read relies on, both from the CLI registry + * (`capabilities.workDetect`): the composer glyph and the pattern every row below an + * idle composer must match. Nothing here names a CLI; a CLI takes part in guarded + * input by declaring `composerFooterLine`. + */ +export interface GuardedComposerChrome { + promptGlyph: string; + footerLine: RegExp; +} +/** The chrome a registry entry declares, or null (no guarded input for that CLI). */ +export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null { + const detect = entry?.capabilities.workDetect; + if (!detect?.promptGlyph || !detect.composerFooterLine) return null; + // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal. + const footerLine = compileVersionRegex(detect.composerFooterLine); + return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null; +} +const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const composerRow = (chrome: GuardedComposerChrome) => + new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`); export const STAGE_MAX_BYTES = 8192; export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); export interface GuardedInputSnapshot { @@ -34,19 +54,21 @@ interface Receipt extends GuardedInputSnapshot { export class GuardedInputConflict extends Error {} /** - * Conservatively read a Codex composer: exactly one visible, unwrapped row and a - * known footer. Never remove internal spaces, infer an unknown shell prompt, or - * interpret dialog options as authority. An unsupported frame yields no proof. + * Conservatively read a composer: exactly one visible, unwrapped row and a known + * footer, both as the registry describes them. Never remove internal spaces, infer + * an unknown shell prompt, or interpret dialog options as authority. An unsupported + * frame yields no proof. */ -export function readGuardedCodexComposer(screen: string): string | null { +export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null { + const isComposer = composerRow(chrome); const lines = stripAnsi(screen) .split('\n') .map((line) => line.trimEnd()); - const rows = lines.map((line, index) => ({ line, index })).filter((row) => /^\s*›(?:\s|$)/.test(row.line)); + const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line)); const last = rows.at(-1); if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); - if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null; + if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -65,15 +87,22 @@ export function readGuardedCodexComposer(screen: string): string | null { } } else if (!dim) visible += token; } - return stripAnsi(visible).trimStart().slice(2).trimEnd(); + // The glyph and the one space after it are the prefix, not input. + return stripAnsi(visible) + .trimStart() + .slice(chrome.promptGlyph.length + 1) + .trimEnd(); } /** Require the capture's caret at the exact end of the unwrapped composer. */ -export function readGuardedCodexSnapshot(snapshot: string): string | null { - return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null; +export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null { + return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null; } /** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ -export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null { +export function readGuardedSnapshotDetails( + snapshot: string, + chrome: GuardedComposerChrome +): { composer: string; prefixWidth: number } | null { // formatPaneSnapshot emits one absolute row address per line, then the caret. // Plain captures lack caret evidence and cannot prove invisible trailing edits. // eslint-disable-next-line no-control-regex @@ -87,16 +116,17 @@ export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: s if (Number(position[1]) !== i + 1 || position[2] !== '1') return null; rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index)); } - const composer = readGuardedCodexComposer(rows.join('\n')); + const composer = readGuardedComposer(rows.join('\n'), chrome); if (composer === null) return null; - let composerRow = -1; + const isComposer = composerRow(chrome); + let row = -1; for (let i = rows.length - 1; i >= 0; i--) - if (/^\s*›(?:\s|$)/.test(stripAnsi(rows[i]))) { - composerRow = i; + if (isComposer.test(stripAnsi(rows[i]))) { + row = i; break; } - const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); - if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) + const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`)); + if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) return null; return { composer, prefixWidth: visibleWidth(prefix[1]) }; } diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 8db5ad3e..b8e09d51 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -2,11 +2,13 @@ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running * inside a Codeman session, in every CLI mode. * - * A thin HTTP client over endpoints that already exist (`quick-start`, `input`, - * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the - * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It - * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so - * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is + * A thin HTTP client over the server's endpoints (`quick-start`, `input`, `wait`, + * `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, the agent inbox: + * `inbox`, `inbox/ack`, `agent-inbox/summary`, and the experimental, opt-in + * `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403 + * until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no + * transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership + * and the per-session waiter cap apply unchanged. The behaviour is * the packaged agent skill's (`skills/codeman`), ported from shell prose into code * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only * preamble — has the same verbs from one line in its AGENTS.md. @@ -1272,7 +1274,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('inspect ') - .description('Experimental: bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors' + ) .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192') .option('--since ', 'Resume a cursor from the same owner, pane and transcript') .option('--json', 'Machine-readable output') @@ -1289,7 +1293,7 @@ export function registerAgentCommands(program: Command): Command { agent .command('stage ') .description( - 'Experimental: type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' + 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer' ) .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline') .option('--json', 'Machine-readable output') @@ -1299,7 +1303,9 @@ export function registerAgentCommands(program: Command): Command { agent .command('submit ') - .description('Experimental: send Enter once only when the staged composer, pane and hash still match') + .description( + 'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match' + ) .requiredOption('--stage-id ', 'One-shot stage receipt') .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage') .option('--json', 'Machine-readable output') diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index de4a5542..3876310b 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -384,6 +384,16 @@ const capabilitiesSchema = z // marker but never the footer. Still bounded, since it is also how much text the // pattern runs over. blockedLines: z.number().int().min(1).max(24).optional(), + // Same guard again: run over the rows under the composer each time guarded + // input reads the pane, and ~/.codeman/clis.json can set it. + composerFooterLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null, + 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers' + ) + .optional(), }) .strict() // A window with nothing to search is a typo, not a configuration. Refused at LOAD diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1b0e00ea..59bf4951 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -13,6 +13,14 @@ import type { CliEntry } from './types.js'; import { CODEX_REASONING_EFFORTS } from '../../types/session.js'; +/** + * Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what + * codex prints when no effort is configured), as one regex alternation. Shared by the two + * patterns that read that footer (`modelDetect.screenLine` and + * `workDetect.composerFooterLine`), so a new level is a one-place edit. + */ +const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|'); + const HOME_DIRS = { local: '~/.local/bin', usrLocal: '/usr/local/bin', @@ -734,6 +742,12 @@ const CODEX: CliEntry = { watchingLines: 4, blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`, blockedLines: 20, + // The rows codex draws under an idle composer, tested one at a time by guarded + // stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default · + // ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the + // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older + // `NN% context left` footer. Indented, so the `›` composer row itself never matches. + composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`, }, // The footer under the composer, measured on a live 0.147.0 pane: // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, @@ -757,7 +771,7 @@ const CODEX: CliEntry = { // and the `›` composer, and a forged footer-shaped transcript line is not followed by // an indented row, so it is not read. modelDetect: { - screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`, + screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`, screenLines: 2, }, // App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 81fd48b4..0f5a97f1 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -432,6 +432,15 @@ export interface CliCapabilities { * this is sized for the narrowest pane the CLI was measured at. Default 1. */ blockedLines?: number; + /** + * Source of a regex every non-blank row BELOW an idle composer must match (at most + * three rows): the footer and hint rows this CLI draws there and nowhere else. It is + * what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove + * that the `promptGlyph` row they read is this CLI's own composer, at rest, and not + * a dialog option or a transcript line the agent printed. A CLI that omits it never + * takes guarded input: stage and submit refuse it. Tested one row at a time. + */ + composerFooterLine?: string; }; /** * How many columns this CLI indents its transcript body by, so a copy taken from its diff --git a/src/session.ts b/src/session.ts index 8d3a5791..3b965eb8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,7 +129,12 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; -import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js'; +import { + guardedComposerChrome, + promptSha256, + readGuardedSnapshotDetails, + type GuardedInputSnapshot, +} from './agent-staging.js'; import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; @@ -4764,10 +4769,12 @@ export class Session extends EventEmitter { this.remote || this.docker || this.paneExit || - getCli(this.mode)?.capabilities.transcript !== 'codex-rollout' || !this._muxSession ) return null; + // Only a CLI whose registry entry describes its composer chrome takes guarded input. + const chrome = guardedComposerChrome(getCli(this.mode)); + if (!chrome) return null; const capture: import('./mux-interface.js').PaneCaptureOptions = {}; const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); if (!screen) return null; @@ -4778,7 +4785,7 @@ export class Session extends EventEmitter { const working = this._workingLinePattern(); working.lastIndex = 0; if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; - const composer = readGuardedCodexSnapshotDetails(screen); + const composer = readGuardedSnapshotDetails(screen, chrome); return composer === null ? null : { diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0f17c78d..1e585379 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -697,6 +697,9 @@ 'New sessions for this case land in this group': '此案例的新会话将放入此分组', 'Could not save the default session group.': '无法保存默认会话分组。', 'Remote auto-reconnect': '远程自动重连', + 'Experimental agent coordination': '实验性智能体协作', + "Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.": + '允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。', 'Pin tab names on the CLI': '在 CLI 上固定标签名', "Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.": '以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。', diff --git a/src/web/public/index.html b/src/web/public/index.html index b52a2956..49ce6bf4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2178,6 +2178,13 @@ +
+
+ Experimental agent coordination + Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request. +
+ +
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f45..162fabb7 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -541,6 +541,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Agent inbox snapshot on disk: synced, default OFF. document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true; + // Experimental agent coordination (inspect/stage/submit routes): synced, default OFF. + document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. @@ -2929,6 +2931,7 @@ Object.assign(CodemanApp.prototype, { configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), ...this._configBackupRetentionPayload(_prev), agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked, + agentCoordinationEnabled: document.getElementById('appSettingsAgentCoordination').checked, readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index 662cf41f..280138ff 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -2,6 +2,11 @@ * @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints. * Ownership is checked before any disk or pane read. Native evidence comes only * from strict conversation attribution; all other modes get labeled snapshots. + * + * Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the + * setting, until the synced `agentCoordinationEnabled` is on (default OFF), the + * same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first, + * before session lookup, so a disabled install reveals nothing about its sessions. */ import type { FastifyInstance } from 'fastify'; import type { Session } from '../../session.js'; @@ -10,10 +15,32 @@ import { getCli } from '../../config/cli-registry/registry.js'; import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js'; import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js'; import { GuardedStageStore, GuardedInputConflict, type GuardedInputTarget } from '../../agent-staging.js'; -import { ApiErrorCode, createErrorResponse } from '../../types.js'; -import { findSessionOrFail, getAuthUser, parseBody } from '../route-helpers.js'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js'; +/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */ +export async function readAgentCoordinationEnabled(): Promise { + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + return settings.agentCoordinationEnabled === true; +} + +/** The refusal every route sends while the setting is off. Exported for the tests. */ +export const AGENT_COORDINATION_DISABLED_MESSAGE = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + +async function gate(): Promise | null> { + if (await readAgentCoordinationEnabled()) return null; + return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE); +} + +/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */ +export function errorCode(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code; + return error instanceof Error ? error.constructor.name : 'unknown'; +} + export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { const reader = new InspectionReader(); const stages = new GuardedStageStore(); @@ -33,6 +60,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor submit: (expected) => session.submitGuardedInput(expected), }); app.get('/api/sessions/:id/inspect', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id, req); const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query); @@ -99,19 +128,24 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor } catch (error) { if (error instanceof InspectionConflict) return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message)); - // Native paths and arbitrary IO errors must never escape into the response. - return reply - .code(409) - .send(createErrorResponse(ApiErrorCode.CONFLICT, 'Inspection source unavailable; retry without a cursor')); + // Anything else (an unreadable rollout, an IO error) is not a retryable cursor + // conflict. Log the session and the error code only: the message can carry the + // native transcript path, which must never reach the log or the response. + console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`); + return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable')); } }); app.post('/api/sessions/:id/stage', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); const { message } = parseBody(AgentStageSchema, req.body); try { const receipt = await stages.stage(target(session), getAuthUser(req).username, message); return { success: true, data: receipt }; } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`); return reply .code(409) .send( @@ -125,6 +159,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor } }); app.post('/api/sessions/:id/submit', async (req, reply) => { + const refused = await gate(); + if (refused) return reply.code(403).send(refused); const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req); const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body); try { @@ -133,6 +169,8 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha), }; } catch (error) { + if (!(error instanceof GuardedInputConflict)) + console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`); return reply .code(409) .send( diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 295441d2..0cb55d1b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1544,6 +1544,13 @@ export const SettingsUpdateSchema = z * inboxes stay). On, pending messages between agents survive a server restart. */ agentInboxPersistEnabled: z.boolean().optional(), + /** + * Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded + * inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`. + * SYNCED, default OFF: the routes are labeled experimental and may change in a minor or + * patch release, so automation has to opt in. While OFF, all three routes answer 403. + */ + agentCoordinationEnabled: z.boolean().optional(), /** * Read My Mind predictor model override. Empty/absent = the AI-checker * default (opus: prediction quality is the product and it runs only on an diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index d3bc9cad..a1292275 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -1,9 +1,13 @@ /** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */ -import { afterEach, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { findExactCodexConversation, resolveEffectiveCodexHome } from '../src/agent-codex-identity.js'; +import { + __clearCodexAttributionMemo, + findExactCodexConversation, + resolveEffectiveCodexHome, +} from '../src/agent-codex-identity.js'; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); @@ -154,3 +158,80 @@ it.each(['workspace', 'metadata'] as const)( } } ); + +describe('remembered attribution on a large, never-pruned store', () => { + beforeEach(() => __clearCodexAttributionMemo()); + const OWNED = '33333333-3333-4333-8333-333333333333'; + const NEWER = '44444444-4444-4444-8444-444444444444'; + /** codex's own layout: sessions/YYYY/MM/DD/rollout--.jsonl */ + async function dated(f: Awaited>, day: string, id: string, payload: object = {}) { + const dir = join(f.home, 'sessions', ...day.split('/')); + await mkdir(dir, { recursive: true }); + const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ + type: 'session_meta', + payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload }, + }) + '\n' + ); + return path; + } + /** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */ + async function unrelated(f: Awaited>, count: number) { + const instructions = 'x'.repeat(19 * 1024); + for (let start = 0; start < count; start += 200) { + await Promise.all( + Array.from({ length: Math.min(200, count - start) }, (_, k) => { + const n = start + k; + const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`; + return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, { + originator: 'codex_cli', + instructions, + }); + }) + ); + } + } + const small = { maxEntries: 20, maxBytes: 1048576 }; + + it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => { + const f = await fixture(); + await unrelated(f, 2100); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + }, 60_000); + + it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => { + const f = await fixture(); + await unrelated(f, 300); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + // 20 entries cannot cover 300 rollouts in 9 months of day directories: only the + // remembered head plus the directories not older than it fit. + expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path }); + __clearCodexAttributionMemo(); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + }); + + it('still catches a newer owned rollout (codex /new) after remembering one', async () => { + const f = await fixture(); + await unrelated(f, 50); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await dated(f, '2026/10/25', NEWER); + expect(await findExactCodexConversation(f.options, small)).toBeUndefined(); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + }); + + it('drops the remembered file when it is replaced, and walks again', async () => { + const f = await fixture(); + const path = await dated(f, '2026/10/24', OWNED); + expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path }); + await rm(path); + await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' }); + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + const moved = await dated(f, '2026/10/23', NEWER); + expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved }); + }); +}); diff --git a/test/agent-coordination-setting.test.ts b/test/agent-coordination-setting.test.ts new file mode 100644 index 00000000..57ac54b1 --- /dev/null +++ b/test/agent-coordination-setting.test.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the + * experimental inspect/stage/submit routes: the strict settings schema admits it, App + * Settings loads and saves it under the same control id, and its row reads in Chinese + * (zh-CN) with no English left beyond the command and product names. The route gate itself + * is pinned in test/routes/agent-session-routes.test.ts. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); +const ID = 'appSettingsAgentCoordination'; + +type Api = { configure(o: Record): void; start(): void }; + +/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */ +const leftover = (text: string) => + text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById(ID)!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('agentCoordinationEnabled', () => { + it('is a boolean the strict settings schema accepts, and nothing else', () => { + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false); + }); + + it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => { + expect(INDEX.split(`id="${ID}"`)).toHaveLength(2); + expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox'); + expect(SETTINGS_UI).toContain( + `document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;` + ); + expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`); + }); + + it('sits in the synced Cross-session features group', () => { + const group = source.window.document.getElementById(ID)!.closest('.set-group')!; + expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features'); + expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced'); + }); + + it('finds the row (the translation check is not vacuous)', () => { + expect(english.label).toBe('Experimental agent coordination'); + expect(english.description).toMatch(/^Lets agents read another session's output/); + }); + + it('translates the label and the description, with no English left', () => { + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 6a9c1ad3..976e2cbd 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -2,7 +2,18 @@ import { beforeEach, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; -import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; +import { + GuardedStageStore, + guardedComposerChrome, + readGuardedComposer, + readGuardedSnapshot, + type GuardedInputTarget, +} from '../src/agent-staging.js'; +import { getCli, listClis } from '../src/config/cli-registry/registry.js'; +/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */ +const CODEX = guardedComposerChrome(getCli('codex'))!; +const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX); +const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX); const sha = (s: string) => createHash('sha256').update(s).digest('hex'); let store: GuardedStageStore; let time: number; @@ -110,7 +121,6 @@ it('does not issue a receipt on failed staging or intervening input during its w }); it('reads exact composer spaces and refuses wrapped or modal frames', async () => { - const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world'); expect( readGuardedCodexComposer( @@ -122,7 +132,6 @@ it('reads exact composer spaces and refuses wrapped or modal frames', async () = expect(readGuardedCodexComposer('› hello\n')).toBeNull(); }); it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => { - const { readGuardedCodexSnapshot } = await import('../src/agent-staging.js'); const { formatPaneSnapshot } = await import('../src/tmux-manager.js'); const frame = (text: string, x: number, y = 0) => formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y }); @@ -157,7 +166,6 @@ it('reserves receipt capacity before concurrent stages type', async () => { it.each([...CODEX_REASONING_EFFORTS, 'default'])( 'accepts the shared Codex effort word %s in the footer', async (word) => { - const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); } ); @@ -168,3 +176,22 @@ it('refuses messages that reach the available pane columns before typing', async expect(writes).toEqual([]); await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); }); + +it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => { + const codex = getCli('codex')!.capabilities.workDetect!; + expect(CODEX.promptGlyph).toBe(codex.promptGlyph); + expect(CODEX.footerLine.source).toBe(codex.composerFooterLine); + // Another glyph drives the same reader: nothing in it is codex-specific. + const other = { promptGlyph: '>>', footerLine: CODEX.footerLine }; + expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello'); + expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull(); + const declaring = listClis() + .filter((entry) => entry.capabilities.workDetect?.composerFooterLine) + .map((entry) => entry.id); + expect(declaring).toEqual(['codex']); + expect(guardedComposerChrome(getCli('claude'))).toBeNull(); + // A pattern the ReDoS guard refuses yields no chrome, so no guarded input. + const unsafe = structuredClone(getCli('codex')!); + unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b'; + expect(guardedComposerChrome(unsafe)).toBeNull(); +}); diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts index a0b023a7..d1858a3a 100644 --- a/test/cli-agent-inspection.test.ts +++ b/test/cli-agent-inspection.test.ts @@ -92,3 +92,36 @@ it('registers required file and confirmation options and bounded inspect flags', ?.mandatory ).toBe(true); }); +it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-')); + try { + const path = join(dir, 'message'); + await writeFile(path, 'hello'); + const message = + 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.'; + for (const json of [false, true]) { + const lines: string[] = []; + const refusing: AgentDeps = { + ctx: { apiUrl: 'http://localhost:1', selfId: SELF }, + json, + now: () => 1, + io: { out: (line) => lines.push(line), err: (line) => lines.push(line) }, + request: async () => ({ + status: 403, + text: '', + json: { success: false, error: message, errorCode: 'FORBIDDEN' }, + }), + }; + expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1); + expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1); + expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1); + expect(lines).toHaveLength(3); + for (const line of lines) { + const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line; + expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 9fa1c6ee..5c4a468c 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -184,6 +184,27 @@ describe('workDetect.workingLine is guarded like every other config regex', () = } }); + it('holds the optional composerFooterLine to the same guard', () => { + // Run over the rows under the composer each time guarded input reads the pane. + for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) { + expectRejected((e) => { + (e.capabilities as Record).workDetect = { + promptGlyph: '>', + workingLine: 'working', + composerFooterLine, + }; + }, 'guarded input tests it against pane rows a clis.json override can supply'); + } + }); + + it('accepts every shipped composerFooterLine', () => { + for (const entry of STOCK_CLIS) { + const src = entry.capabilities.workDetect?.composerFooterLine; + if (!src) continue; + expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull(); + } + }); + it('holds the optional blockedLine to the same guard', () => { // Run over the foot of a capture at every settle and on a timer while a dialog is // up, and a clis.json override can set it. diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 27fbd415..8bafbb15 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -1,11 +1,118 @@ /** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ -import { afterEach, describe, expect, it } from 'vitest'; -import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AGENT_COORDINATION_DISABLED_MESSAGE, + registerAgentSessionRoutes, +} from '../../src/web/routes/agent-session-routes.js'; +import { InspectionReader } from '../../src/agent-inspection.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; let harness: RouteTestHarness; +/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */ +function setEnabled(on: boolean | undefined): void { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on })); +} +beforeEach(() => setEnabled(true)); afterEach(async () => { await harness?.app.close(); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('the agentCoordinationEnabled gate', () => { + const requests = (id: string) => [ + { method: 'GET' as const, url: `/api/sessions/${id}/inspect` }, + { method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` }, + { method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } }, + { + method: 'POST' as const, + url: `/api/sessions/${id}/submit`, + payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }, + }, + ]; + it.each([ + ['absent', undefined], + ['false', false], + ] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => { + setEnabled(value); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + // A session id that does not exist is refused the same way: the gate runs before lookup. + for (const request of [...requests(session.id), ...requests('no-such-session')]) { + const result = await harness.app.inject(request); + expect(result.statusCode).toBe(403); + expect(result.json()).toEqual({ + success: false, + error: AGENT_COORDINATION_DISABLED_MESSAGE, + errorCode: 'FORBIDDEN', + }); + } + expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled'); + expect(session.writeBuffer).toEqual([]); + }); + it('answers on every route once it is on, with no restart', async () => { + setEnabled(false); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + const session = [...harness.ctx.sessions.values()][0]; + expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403); + setEnabled(true); + const [inspect, invalid, stage, submit] = await Promise.all( + requests(session.id).map((request) => harness.app.inject(request)) + ); + expect(inspect.statusCode).toBe(200); + expect(inspect.json().data.source).toBe('pane-snapshot'); + expect(invalid.statusCode).toBe(400); + // The mock session has no characterized composer, so the guards refuse: the route answered. + expect(stage.statusCode).toBe(409); + expect(submit.statusCode).toBe(409); + }); +}); + +it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { Session } = await import('../../src/session.js'); + const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-')); + const home = join(root, 'codex'); + await mkdir(join(home, 'sessions'), { recursive: true }); + const session = new Session({ + id: 'inspect-fail', + workingDir: root, + mode: 'codex', + envOverrides: { CODEX_HOME: home }, + }); + const id = '11111111-1111-4111-8111-111111111111'; + const path = join(home, 'sessions', `rollout-${id}.jsonl`); + await writeFile( + path, + JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n' + ); + harness = await createRouteTestHarness(registerAgentSessionRoutes); + (harness.ctx.sessions as unknown as Map).set(session.id, session); + const read = vi + .spyOn(InspectionReader.prototype, 'read') + .mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` }); + expect(read).toHaveBeenCalled(); + expect(result.statusCode).toBe(422); + expect(result.json().errorCode).toBe('OPERATION_FAILED'); + expect(result.body).not.toContain(path); + const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(logged).toContain(session.id); + expect(logged).toContain('EACCES'); + expect(logged).not.toContain(home); + } finally { + read.mockRestore(); + warn.mockRestore(); + await session.stop(false); + await rm(root, { recursive: true, force: true }); + } }); describe('agent session routes', () => { it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index 6bce845a..020b83a4 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -45,8 +45,32 @@ describe('WebServer on an ephemeral port', () => { const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); const sessions = (server as unknown as { sessions: Map }).sessions; sessions.set(session.id, session); + const { mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { dirname } = await import('node:path'); + const { SETTINGS_PATH } = await import('../src/web/route-helpers.js'); + const setEnabled = (on: boolean) => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ agentCoordinationEnabled: on })); + }; try { const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + // Opt-in: off, the /api/v1 alias of every route refuses with 403 FORBIDDEN. + setEnabled(false); + for (const [method, verb] of [ + ['GET', 'inspect'], + ['POST', 'stage'], + ['POST', 'submit'], + ] as const) { + const refused = await fetch(prefix + '/' + verb, { + method, + ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}), + }); + expect(refused.status).toBe(403); + const body = await refused.json(); + expect(body.errorCode).toBe('FORBIDDEN'); + expect(body.error).toContain('agentCoordinationEnabled'); + } + setEnabled(true); const inspected = await fetch(prefix + '/inspect'); expect(inspected.status).toBe(200); expect((await inspected.json()).data.source).toBe('pane-snapshot'); @@ -63,6 +87,7 @@ describe('WebServer on an ephemeral port', () => { expect((await response.json()).errorCode).toBe('CONFLICT'); } } finally { + rmSync(SETTINGS_PATH, { force: true }); sessions.delete(session.id); await session.stop(false); }