mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-28 03:49:33 +01:00
feat(agent): make coordination routes opt-in and address review
Gate inspect, stage and submit behind the synced agentCoordinationEnabled setting (default OFF, App Settings > Cross-session features, zh-CN strings). While off, all three routes answer 403 FORBIDDEN naming the setting, checked before session lookup; the CLI verbs print it verbatim. Remember the attributed Codex rollout per session and re-verify only its head on later calls, rescanning only date directories not older than it for a /new conflict. The first walk reads 1 KiB prefixes first, so a store of 2,100+ unrelated rollouts still attributes. Answer unexpected inspection errors with 422 OPERATION_FAILED and log the session id and error code only. Read the guarded composer glyph and footer from the CLI registry: codex workDetect.promptGlyph and a new workDetect.composerFooterLine capability, compiled through compileVersionRegex. Codex footer effort words are one shared list with modelDetect.screenLine. Fix the wiki API reference link, frame the versioning policy entry as a case of the experimental rule, join the CLAUDE.md Agent CLI paragraph, and update the changeset, skill reference and architecture notes.
This commit is contained in:
1 parent
1a2c3be0ea
commit
c78635a3d3
32 files changed
+848
-124
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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 `<Mode>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 `<Mode>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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-3
@@ -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
|
||||
|
||||
@@ -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 `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression.
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
+16
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+152
-36
@@ -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<string, AttributionMemo>({ 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<boolean> {
|
||||
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<Array<{ id: string; path: string; meta: Metadata }> | 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;
|
||||
}
|
||||
+50
-20
@@ -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]) };
|
||||
}
|
||||
|
||||
+14
-8
@@ -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 <id>')
|
||||
.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 <n>', 'UTF8 evidence budget (1–65536)', '8192')
|
||||
.option('--since <cursor>', '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 <id>')
|
||||
.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 <path>', '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 <id>')
|
||||
.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 <receipt>', 'One-shot stage receipt')
|
||||
.requiredOption('--confirm-sha <hash>', 'Exact SHA-256 returned by stage')
|
||||
.option('--json', 'Machine-readable output')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-3
@@ -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
|
||||
: {
|
||||
|
||||
@@ -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 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
|
||||
|
||||
@@ -2178,6 +2178,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentInboxPersist"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="experimental agent coordination inspect stage submit codeman agent guarded input">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Experimental agent coordination</span>
|
||||
<span class="set-row-desc">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.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentCoordination"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(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<ApiResponse<never> | 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<ts>-<uuid>.jsonl */
|
||||
async function dated(f: Awaited<ReturnType<typeof fixture>>, 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<ReturnType<typeof fixture>>, 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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>): 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown>).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.
|
||||
|
||||
@@ -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<string, Session>).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 () => {
|
||||
|
||||
@@ -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<string, typeof session> }).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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user