diff --git a/docs/README.md b/docs/README.md index 37a3bdf..59983eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -169,7 +169,7 @@ The client environment is not persisted. Each client's environment and working d ### Captured IDs in shell commands -To resume an agent conversation, `fleetcom` inserts its captured ID into a command run through `$SHELL -c`. Because the ID becomes shell input, validation accepts only lowercase hexadecimal in the `8-4-4-4-12` UUID shape. The same check applies to capture payloads, live session records, and final command construction. `fleetcom` does not read session IDs from terminal output and instruments only a bare program word or its canonical resume form. See [Agent session resume](agent-resume.md#validation-boundary) for both boundaries. +To resume an agent conversation, insert its captured ID into a command run through `$SHELL -c`. Before shell insertion, accept only lowercase hexadecimal in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, live session records, and the final command construction. Never read session IDs from terminal output; instrument only a bare program word or its canonical resume form. See [Agent session resume](agent-resume.md#validation-boundary) for both boundaries. ### Copying text through the terminal diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 989001f..b872a39 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -44,6 +44,10 @@ The random nonce isolates concurrent supervisors and prevents PID reuse from sel ## Evidence sources +Each agent process is supervised by the `fleetcom` daemon. A conversation handed off to the agent CLI's own background service is outside that task. For accepted commands, keep the conversation in the task's process and accept captured IDs only from that process. + +All validation on 2026-10-04 was performed on macOS; Linux was not tested. + ### `claude` Claude accepts an ID at launch. For a bare command, the harness therefore generates a v4 UUID and adds it with the settings overlay: @@ -52,30 +56,54 @@ Claude accepts an ID at launch. For a bare command, the harness therefore genera --session-id '' --settings '/claude-settings.json' ``` -Since a canonical resume command already specifies the conversation ID, the harness adds only `--settings`. The overlay installs a `SessionStart` hook that copies its JSON payload into `FLEETCOM_CAPTURE_FILE`. The harness then reads `session_id` from that payload. +For a canonical resume command, the conversation ID is already specified, so only `--settings` is added. Two keys are layered over your settings for the launched process alone; your settings files are not modified. + +With the first key, `"disableAgentView": true`, agent view is disabled: `claude agents`, `--bg`, `/background`, and Claude's on-demand daemon. With agent view enabled, a conversation can be handed off to that daemon, outside the task's process. In an instrumented task, you therefore cannot background a conversation: `/background` is unavailable, you cannot park the conversation by pressing Left arrow on an empty prompt, and with `/fork` you start a sub-agent inside the task's process instead of a background session. -Claude session records are also available at `/sessions/.json`, one per session. The direct path for the task leader's PID is read. When the shell is retained as task leader under `$SHELL -c` instead of being replaced with Claude, no matching record is available and no ID is read from the registry. +A `SessionStart` hook is registered through the second key: + +```text +{ printf '%s\n' "$PPID"; cat; } > "$FLEETCOM_CAPTURE_FILE" +``` + +The first line of the capture file is the parent Claude process's PID; the rest is the hook's JSON payload. Read `session_id` from that payload only when the PID is the task leader's. Reject captures written by any other process. + +Claude session records are also available at `/sessions/.json`, one per session. The direct path for the task leader's PID is read. A record is accepted only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` match the task. The PID must match the filename, the working directories must be identical or resolve to the same path, and the process start must fall within 30 seconds of the task spawn. Missing, malformed, or mismatched records are ignored. A matching record's `waiting` status is also mapped to the top tier of the [preview cascade](commands.md#peek); the preview is unchanged for other statuses. +For both hook validation and registry lookup, use the task leader's PID. Claude must therefore be the task leader after an exec from `$SHELL -c`. With the shell retained as task leader, Claude's PID is stamped in the capture, and no registry record is available for the leader. The capture is rejected, no ID is read from the registry, and the spawn-time ID is used. A conversation selected after launch, including through `/clear`, is then not followed. + +Live validation on 2026-10-04 was performed with Claude Code 2.1.289. With agent view enabled, after using `/fork`, `/background`, or Left arrow on an empty prompt, the capture file was written by a process other than the task. When rerunning with such an ID, the exit status was 1 with "… is running in the background. Run `claude attach ` …". After pressing Left arrow, the original ID was still in the registry; on rerun with that ID, a snapshot was reopened without the later turns. With `disableAgentView` in the overlay, no handoffs were observed. The sub-agent started through `/fork ` was run in-process, and the same ID was present in the capture file, registry, and spawn-time metadata. The task's PID was stamped in the capture at startup and again after `/clear`. + ### `codex` -You cannot choose a Codex ID at launch. A notify override is injected into both accepted forms: +You cannot choose a Codex ID at launch. Two overrides are injected into both accepted forms: ```text --c 'notify=["/codex-notify.sh"]' +-c 'notify=["/codex-notify.sh"]' -c 'features.daemon_auto_start=false' ``` -After each turn, the notifier writes the `agent-turn-complete` JSON argument to `FLEETCOM_CAPTURE_FILE`, and the harness reads `thread-id` from it. This captures an in-TUI session change after a turn completes in the resumed conversation. +Since Codex 0.157.0, running plain `codex` attaches to a shared background server. With the notify override alone, embedded mode is used and a startup warning is displayed. Set `features.daemon_auto_start=false` to explicitly use embedded mode without the warning. + +With embedded mode, the conversation is kept in the task's process. For an instrumented task, the shared server is never used: you cannot find the task in `codex agents` or reach it through `codex queue`. + +After each turn, the `agent-turn-complete` JSON argument is written to `FLEETCOM_CAPTURE_FILE`. Notifications are emitted for several threads in one Codex process: the conversation on screen, each spawned sub-agent, and a hidden thread used to title a new session. Only the first is the task's conversation, so do not use the notified `thread-id` directly. Find that thread's rollout under `/sessions///
/` and read its first line to resolve the root of its session tree. Use the thread's own ID for a root, or the root ID recorded in a sub-agent's header. After a completed turn in the resumed conversation, an in-TUI session change is captured this way. + +Accept a thread only when exactly one rollout is named for it, with a header in either the root format or the sub-agent format with a root ID. No rollout is saved for the title thread, so always reject it. If the capture is rejected, use the spawn-time ID or preserve the authored command. + +Look up only the thread ID from the task's own notification. List the dated directories to find its rollout, then read only the first line. Do not infer ownership from other sessions in the store. To preserve a configured notifier, read bare top-level keys in `$CODEX_HOME/config.toml` up to the first table header. For a one-line `notify` array of non-empty basic strings, invoke the notifier after writing the capture file. Store its argv in `FLEETCOM_NOTIFY_CHAIN`, joined by newlines, and append the notification payload before invocation. With an absent setting or empty array, run only the capture hook. -Do not inject the capture hook for empty arguments, newlines, or NUL: these cannot be represented in the chain encoding. Also skip injection when `config.toml` exists but cannot be read, or when a line above the first table header is not a bare key assigned a recognized single-line value. No ID is captured in these cases. +Do not inject the capture hook for empty arguments, newlines, or NUL: these cannot be represented in the chain encoding. Also skip injection when `config.toml` exists but cannot be read, or when a line above the first table header is not a bare key assigned a recognized single-line value. Neither override is added in these cases: no ID is captured, and the mode is selected by Codex. -Live validation on 2026-09-28 used the official macOS arm64 Codex 0.157.1 and 0.158.0 executables with isolated homes and a scripted loopback Responses server. In both releases, the real capture script preserved the notification JSON and passed it to the configured notifier. Saving and loading through foreground fleetcom preserved the thread ID and reopened its history. These runs used `--no-daemon`; separate default-launch probes also captured and saved the ID, but their fresh homes had no managed Codex daemon installed or running. Shared-daemon behavior and real-account behavior remain unverified. +Live validation on 2026-09-28 was performed with the official macOS arm64 Codex 0.157.1 and 0.158.0 executables, isolated homes, and a scripted loopback Responses server. In both releases, the notification JSON was preserved in the capture file and passed to the configured notifier by the real capture script. After saving and loading through foreground fleetcom, the same thread ID was present and its history was reopened. Both releases were tested with `--no-daemon`. The ID was also captured and saved in separate default-launch probes, but no managed Codex daemon was installed or running in those fresh homes. Real-account behavior was not verified. The [recorded terminal fixtures](../tests/corpus/codex_0158_terminal/README.md) preserve resumed-history and clipboard output from those runs. Offline replay checks the recorded display and OSC 52 forwarding; it does not run the notifier or execute save/load. The separate `tests/daemon_resume.rs` integration tests exercise session resume with stub agents. +Live validation on 2026-10-04 was performed with codex-cli 0.160.0. Three thread IDs were reported from one process: the unsaved title thread, a sub-agent, and the root. When resuming the title thread, "No saved session found" was reported; for the sub-agent, "cannot resume an unloaded multi-agent v2 sub-agent through its parent" was reported. With the notify override alone, `⚠ 1 warning` was displayed in the footer; with both overrides, none was displayed. With an unknown `features.*` key, startup completed with an unrecognized-setting warning. No Codex version predating `daemon_auto_start` was tested. On the same date, 493 local rollout headers from Codex 0.135.0 through 0.160.0 were examined: `id` and `session_id` were present in each. In Codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as its `session_id`; that format is rejected. + ### `grok` You can specify a Grok ID at launch, but cannot inject a live-capture channel. For a bare command, `--session-id ''` is added; no instrumentation is required for a canonical resume command. @@ -88,7 +116,22 @@ You cannot pin an omp ID at launch: no `--session-id` flag is available, and an -e '/omp-capture.js' ``` -The `-e` flag loads the JavaScript module into the agent process, appending it to the user's extensions. Its `session_start` and `session_switch` handlers write `sessionId` as JSON to `FLEETCOM_CAPTURE_FILE`, including after in-TUI `/resume` changes. Capture writes are best-effort: the handlers return immediately for an empty capture path and ignore write errors. +Load the JavaScript module into the agent process through `-e`, appending it to your extensions. On `session_start`, `session_switch`, `session_branch`, and `agent_end`, write `sessionId` as JSON to `FLEETCOM_CAPTURE_FILE`, including after in-TUI `/resume` changes. Report only IDs usable with `omp --resume`: + +- The session is the top-level one: `ctx.agent.kind` is `"main"`. The same handlers are registered for sub-agent sessions, but you cannot resume a sub-agent by ID. +- The session file exists on disk. It is created after the first assistant message. + +At `agent_end`, after every turn, report two changes without dedicated events: session file creation and, since omp 18.5.0, assignment of a new ID when the session lease is held by another live process. + +As a result: + +- For a bare `omp` task, no ID is captured until the end of the first turn. Save before then to retain the authored `omp`. +- Before omp 18.3.2, `ctx.agent` is unavailable, so no session ID is captured: a bare `omp` is always saved as authored. +- After switching or branching to a session without a file on disk, continue using the previous ID until the end of the next turn. + +Write each report to a temporary file beside the capture file, then rename it over the destination to avoid reads of partial payloads. Capture is best-effort: return immediately for an empty capture path and ignore write errors. + +Live validation on 2026-10-04 was performed with omp 18.6.1 and a local model. Nothing was written before the first prompt. At the first `agent_end`, the top-level session's ID was written, with its session file present on disk. The handlers were also invoked for a sub-agent at `session_start` and `agent_end`, with the sub-agent's own ID; the top-level ID was preserved in the capture. After `/fork`, the new ID was reported through `session_switch`. After a rewind through `/branch`, the session ID was unchanged and no event was emitted. When resuming a session already leased by another live process, a new ID was assigned at the first write and reported at the next `agent_end`. No `session_branch` event was observed; its handling was determined from the oh-my-pi source at `v18.6.1`. The aliases `-r`, `--session`, and `-c` remain opaque because only the exactly detected canonical form is rewritten. @@ -96,11 +139,11 @@ The aliases `-r`, `--session`, and `-c` remain opaque because only the exactly d Different conversation IDs may be available from different channels during one task. The first available ID is selected in this order: -1. The current capture-file payload. +1. The current capture-file payload, if accepted by the harness. 2. The live session registry, implemented by `claude`. 3. The ID pinned or targeted at spawn. -The same precedence is used for named saves, recovery snapshots, and reruns. Session stores are not scanned to infer conversation ownership: you cannot determine the owning task from a nearby transcript or rollout. +If a capture is rejected, try the next source. Use the same precedence for named saves, recovery snapshots, and reruns. Do not infer conversation ownership from session stores: you cannot determine the owning task from a nearby transcript or rollout. Session IDs are never read from terminal output: another conversation's valid UUID may be present in examples, quoted commands, or tool output. An ID available only in an exit hint is not recovered. Without a capture, registry, or launch ID, the authored command remains unchanged. @@ -119,7 +162,7 @@ The program word is preserved as typed. If no valid ID is available, the origina ## Validation boundary -Every captured value eventually enters a shell command, so validation accepts only lowercase hexadecimal characters in the `8-4-4-4-12` UUID shape. The same check applies to capture payloads, registry records, and final command construction. Malformed values are ignored rather than interpolated. A valid UUID alone does not establish conversation ownership. +Before inserting a captured ID into a shell command, accept only lowercase hexadecimal characters in the `8-4-4-4-12` UUID format. Validate capture payloads, Codex rollout headers, registry records, and the final command construction. Ignore malformed values rather than interpolating them. Conversation ownership cannot be determined from UUID validity alone. ## Extending capture @@ -127,12 +170,12 @@ Implement the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs) f - `shape`: supply the program word and resume selector. Accepted and canonical forms are derived from that pair by the default `detect` and `resume_command` implementations. - `instrument`: return spawn-time arguments, environment entries, and an optional pinned ID. -- `parse_capture`: read an ID from hook, notify, or extension JSON. +- `parse_capture`: read an ID from the capture file's contents. The task leader's PID and the launch-time home are supplied; when other processes can reach the capture channel, use them to refuse a payload the task's own process did not write. - `live_session_id`: read the ID published on disk for a live session. Return `None` by default when no registry is available. - `live_blocked_status`: read that registry for blocked-on-user status. Return preview text, never an ID; return `None` by default. -- `resolve_home`: resolve configuration needed by instrumentation or the live registry. Return `None` by default. +- `resolve_home`: resolve configuration needed by instrumentation, capture parsing, or the live registry. Return `None` by default. -The launch environment is passed to `resolve_home` by the supervisor. For Claude and Codex, the explicit override is read first, then `$HOME` plus the tool's dot directory. With neither supplied, the supervisor's platform home is used. The resolved path is stored with the task and used for Claude registry reads after reconnect, preserving the launch-time home. For Grok and omp, no home resolution is required; the environment is passed to the child unchanged. +The launch environment is passed to `resolve_home` by the supervisor. For Claude and Codex, the explicit override is read first, then `$HOME` plus the tool's dot directory. With neither supplied, the supervisor's platform home is used. The resolved path is stored with the task and used for Claude registry reads and Codex rollout lookups after reconnect, preserving the launch-time home. For Grok and omp, no home resolution is required; the environment is passed to the child unchanged. ## Environment variables @@ -142,4 +185,4 @@ The launch environment is passed to `resolve_home` by the supervisor. For Claude | `FLEETCOM_CAPTURE_FILE` | Per-run capture file used by the injected hook, notifier, or extension module. | | `FLEETCOM_NOTIFY_CHAIN` | Newline-joined argv for the configured Codex notifier; empty when none is active. | | `CLAUDE_CONFIG_DIR` | Claude home holding the `sessions/.json` registry; defaults to `$HOME/.claude`. | -| `CODEX_HOME` | Codex home used for notify routing; defaults to `$HOME/.codex`. | +| `CODEX_HOME` | Codex home holding `config.toml` for notify routing and the `sessions/` rollouts; defaults to `$HOME/.codex`. | diff --git a/src/harness/assets.rs b/src/harness/assets.rs index ebc8d66..07bc8d5 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -8,11 +8,14 @@ //! `install` reaps sibling namespaces whose owner no longer exists. //! //! Asset contracts: -//! - `claude`: `--settings ` layers a `SessionStart` hook -//! (`cat > "$FLEETCOM_CAPTURE_FILE"`) over the user's settings. The hook -//! copies each JSON payload from stdin into the path named by -//! [`CAPTURE_ENV`](super::CAPTURE_ENV), which `fleetcom` sets in the task's -//! environment. +//! - `claude`: layer two keys over the user's settings for this process alone +//! through `--settings `. With `disableAgentView`, +//! disable `claude agents`, `--bg`, `/background`, and the on-demand daemon +//! to keep the conversation in the task's process. In a `SessionStart` hook +//! (`{ printf '%s\n' "$PPID"; cat; } > "$FLEETCOM_CAPTURE_FILE"`), overwrite +//! the file at [`CAPTURE_ENV`](super::CAPTURE_ENV), set in the task's launch +//! environment. Write the parent Claude process's PID on the first line, +//! followed by the JSON payload from stdin, unmodified. //! - `codex`: `-c notify=[""]` names an executable that //! `codex` invokes with notification JSON. The script writes its first //! argument verbatim (no trailing newline) over `$FLEETCOM_CAPTURE_FILE`, @@ -21,11 +24,16 @@ //! newline-joined argv with the payload appended, so the displaced notifier //! receives the same final argument `codex` would have passed; otherwise //! exit 0. -//! - `omp`: `-e ` loads an extension module inside the agent's -//! own process, appending to the user's extensions rather than replacing -//! them. Its `session_start` and `session_switch` handlers write the session -//! id as JSON over `$FLEETCOM_CAPTURE_FILE`, skip the write when that -//! variable is unset or empty, and swallow every error. +//! - `omp`: load an extension module inside the agent's own process through +//! `-e `, appending to the user's extensions. On +//! `session_start`, `session_switch`, `session_branch`, and `agent_end`, +//! replace `$FLEETCOM_CAPTURE_FILE` with `{reason, sessionId, sessionFile, +//! cwd}` JSON. Write `..tmp` beside the capture file and +//! rename it over the destination to avoid reads of partial payloads. +//! Report only the top-level session (`ctx.agent.kind` is `"main"`) and +//! only when `sessionFile` exists on disk. On omp older than 18.3.2, +//! `ctx.agent` is absent, so write nothing. Skip unset or empty capture +//! paths and ignore all errors. use std::{ fs, io, @@ -58,24 +66,56 @@ set -- $FLEETCOM_NOTIFY_CHAIN "$1" exec "$@" "#; -/// Extension module loaded by `omp -e`. Its default export registers handlers -/// for initial sessions and in-TUI session changes. omp imports the module, so -/// the asset needs no executable bit. +/// Extension module imported through `omp -e`; no executable bit is needed. +/// Register one reporter in the default export for four events: +/// +/// - `session_start`, `session_switch`, `session_branch`: the launch, an +/// in-TUI switch or `/fork`, and a branch into a new session. A new ID may +/// be assigned during each operation. During a rewind, the ID is unchanged +/// and none of these events is emitted. +/// - `agent_end`: the end of every turn. Report two changes without dedicated +/// events: session file creation after the first assistant message and, +/// since 18.5.0, assignment of a new ID on the first write when the session +/// lease is held by another live process. +/// +/// Report only an ID usable with `omp --resume`: +/// +/// - Only the top-level session's ID. The same handlers are registered for +/// sub-agent sessions, with the child's ID in their session manager. +/// - None on an omp older than 18.3.2. `ctx.agent` is absent there, so the +/// top-level session cannot be told from a sub-agent. +/// - Only after the session file exists on disk. For a fresh session, first +/// report at `agent_end`. +/// +/// Include the PID and a `.tmp` suffix in the temporary filename to avoid +/// collisions with `task--.json` and leave at most one file per +/// process. Use the capture file's directory to rename within one filesystem. +/// Remove any remaining temporary file with the namespace during `Drop`. const OMP_CAPTURE_MODULE: &str = r#"import * as fs from "node:fs"; function write(ctx, reason) { - const path = process.env.FLEETCOM_CAPTURE_FILE; - if (!path) return; try { + const path = process.env.FLEETCOM_CAPTURE_FILE; + if (!path) return; + // These handlers are also registered for sub-agent sessions, which cannot + // be resumed by ID. Without `ctx.agent` (before 18.3.2), report nothing. + if (ctx.agent?.kind !== "main") return; + // The session file is created after the first assistant message. Until + // then, the ID cannot be found through `omp --resume`. + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile || !fs.existsSync(sessionFile)) return; + // Rename over the capture file to expose only complete payloads to readers. + const partial = `${path}.${process.pid}.tmp`; fs.writeFileSync( - path, + partial, JSON.stringify({ reason, sessionId: ctx.sessionManager.getSessionId(), - sessionFile: ctx.sessionManager.getSessionFile(), + sessionFile, cwd: ctx.cwd, }), ); + fs.renameSync(partial, path); } catch { // Best effort: a capture failure must never take the session down. } @@ -84,19 +124,39 @@ function write(ctx, reason) { export default function (pi) { pi.on("session_start", (_e, ctx) => write(ctx, "session_start")); pi.on("session_switch", (_e, ctx) => write(ctx, "session_switch")); + pi.on("session_branch", (_e, ctx) => write(ctx, "session_branch")); + // No dedicated event is emitted for a lease move or session file creation: + // report again after every turn. + pi.on("agent_end", (_e, ctx) => write(ctx, "agent_end")); } "#; -/// Build the `claude` settings overlay containing the `SessionStart` hook. +/// Build the `claude` settings overlay: `disableAgentView` and the +/// `SessionStart` capture hook. +/// +/// With agent view enabled, a conversation can be parked or forked into +/// Claude's daemon. In those processes, the inherited +/// [`CAPTURE_ENV`](super::CAPTURE_ENV) and overlay are used to report other +/// sessions to the same capture file. Disable agent view for the launched +/// process only. With `CLAUDE_CODE_DISABLE_AGENT_VIEW=1`, agent view would +/// also be disabled in the agent's child processes. +/// +/// The hook's shell is a child of the Claude process, so prefix the payload +/// with `$PPID`. Compare that stamp with the task leader's PID in +/// `Claude::parse_capture`. fn claude_settings_json() -> String { jzon::object! { + "disableAgentView": true, "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", - "command": format!("cat > \"${}\"", super::CAPTURE_ENV), + "command": format!( + r#"{{ printf '%s\n' "$PPID"; cat; }} > "${}""#, + super::CAPTURE_ENV + ), }, ], }, @@ -244,10 +304,52 @@ mod tests { use super::*; use crate::{ - harness::{CAPTURE_ENV, NOTIFY_CHAIN_ENV, fixtures::ID}, + harness::{ + CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, + fixtures::{ID, OTHER}, + }, testutil::{dead_pid, install_fake_notifier, temp, write_executable}, }; + /// Events registered in the `omp` module, in registration order. + const OMP_EVENTS: [&str; 4] = [ + "session_start", + "session_switch", + "session_branch", + "agent_end", + ]; + + /// Runtimes usable with the `omp` module, in preference order, with + /// arguments to place before the script path. omp is run on Bun. + const JS_RUNTIMES: &[(&str, &[&str])] = &[ + ("bun", &[]), + ("node", &[]), + ("deno", &["run", "--allow-all"]), + ]; + + /// Stand-in for omp's extension host. Load the module, collect its + /// registrations, and fire one event with a context built from the + /// arguments ` `. An empty + /// kind means no `ctx.agent`, as before omp 18.3.2; an empty file means + /// `undefined` from `getSessionFile()`. Print the registered event names + /// in registration order. + const OMP_HOST_DRIVER: &str = r#"import register from "./omp-capture.mjs"; + +const handlers = {}; +register({ on: (event, handler) => (handlers[event] = handler) }); +const [event, kind, sessionId, sessionFile] = process.argv.slice(2); +const ctx = { + cwd: "/work/proj", + sessionManager: { + getSessionId: () => sessionId, + getSessionFile: () => sessionFile || undefined, + }, +}; +if (kind) ctx.agent = { kind }; +handlers[event]({ type: event }, ctx); +console.log(Object.keys(handlers).join(" ")); +"#; + fn mode(p: &Path) -> u32 { fs::metadata(p).unwrap().permissions().mode() & 0o777 } @@ -582,13 +684,31 @@ mod tests { assert_eq!(fs::read_to_string(&record).unwrap(), "payload\n"); } - /// The hook command serialized into the settings file copies stdin into - /// the configured capture file. + /// Require valid JSON, disabled agent view, and the exact stamped hook + /// command in the overlay. + #[test] + fn claude_settings_disable_agent_view_and_stamp_the_hook() { + let parsed = jzon::parse(&claude_settings_json()).expect("the overlay must be valid JSON"); + assert_eq!(parsed["disableAgentView"].as_bool(), Some(true)); + assert_eq!( + parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(), + Some(r#"{ printf '%s\n' "$PPID"; cat; } > "$FLEETCOM_CAPTURE_FILE""#) + ); + assert_eq!( + parsed["hooks"]["SessionStart"][0]["hooks"][0]["type"].as_str(), + Some("command") + ); + } + + /// Run the hook command from the settings file. Require the parent PID + /// followed by stdin in the capture file; accept only that parent's stamp. #[test] - fn hook_command_from_settings_copies_stdin_to_the_capture_file() { + fn hook_command_from_settings_stamps_its_parent_and_copies_stdin() { let root = temp("assets_hook"); let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); let cap = assets.paths_for(2, 0).capture_file; + // Verify full replacement of an earlier, longer capture. + fs::write(&cap, "9".repeat(4096)).unwrap(); let text = fs::read_to_string(&assets.claude_settings).unwrap(); let parsed = jzon::parse(&text).unwrap(); @@ -596,9 +716,12 @@ mod tests { .as_str() .expect("settings must carry the hook command"); + // Include the trailing newline supplied by Claude. let payload = format!( r#"{{"session_id":"{ID}","hook_event_name":"SessionStart","source":"startup"}}"# - ); + ) + "\n"; + // Spawn the hook's shell directly, as in Claude. + let parent = std::process::id(); let mut child = Command::new("sh") .arg("-c") .arg(command) @@ -614,7 +737,197 @@ mod tests { .write_all(payload.as_bytes()) .unwrap(); assert!(child.wait().unwrap().success()); - assert_eq!(fs::read_to_string(&cap).unwrap(), payload); + let written = fs::read_to_string(&cap).unwrap(); + assert_eq!(written, format!("{parent}\n{payload}")); + assert_eq!( + Claude + .parse_capture(&written, Some(parent), None) + .as_deref(), + Some(ID) + ); + assert_eq!(Claude.parse_capture(&written, Some(parent + 1), None), None); + } + + /// Check the `omp` module without a JavaScript runtime: require both + /// validation checks before the sole write, then rename the file. + #[test] + fn omp_module_text_gates_then_writes_then_renames() { + let module = OMP_CAPTURE_MODULE; + let imports: Vec<&str> = module + .lines() + .filter(|line| line.starts_with("import ")) + .collect(); + assert_eq!(imports, [r#"import * as fs from "node:fs";"#]); + + let mut at = 0; + for step in [ + "try {", + "const path = process.env.FLEETCOM_CAPTURE_FILE;", + "if (!path) return;", + r#"if (ctx.agent?.kind !== "main") return;"#, + "const sessionFile = ctx.sessionManager.getSessionFile();", + "if (!sessionFile || !fs.existsSync(sessionFile)) return;", + "const partial = `${path}.${process.pid}.tmp`;", + "fs.writeFileSync(\n partial,", + "reason,", + "sessionId: ctx.sessionManager.getSessionId(),", + "sessionFile,", + "cwd: ctx.cwd,", + "fs.renameSync(partial, path);", + "} catch {", + ] { + let found = module[at..] + .find(step) + .unwrap_or_else(|| panic!("missing or out of order: {step:?}")); + at += found + step.len(); + } + // Write only to the temporary file. + assert_eq!(module.matches("fs.writeFileSync(").count(), 1); + + // Register only these four events. With `tool_call`, `tool_result`, + // or `tool_approval_*` handlers, omp's speculation is disabled. + assert_eq!(module.matches("pi.on(").count(), OMP_EVENTS.len()); + for event in OMP_EVENTS { + let registration = format!(r#"pi.on("{event}", (_e, ctx) => write(ctx, "{event}"));"#); + assert!(module.contains(®istration), "{event}"); + } + } + + /// Fire the installed module's handlers under a JavaScript runtime. A + /// report is written only for the top-level session and only once its + /// session file exists. Verify replacement of the entire capture file. + #[test] + fn omp_module_reports_only_a_materialized_main_session() { + let Some((program, prefix)) = JS_RUNTIMES.iter().find(|(program, _)| { + Command::new(program) + .arg("--version") + .output() + .is_ok_and(|out| out.status.success()) + }) else { + // Write directly to stderr to report the skip even on success; + // `eprintln!` output is captured by the test harness. + writeln!( + io::stderr(), + "skipped omp_module_reports_only_a_materialized_main_session: \ + no bun, node, or deno on PATH" + ) + .unwrap(); + return; + }; + + let root = temp("assets_omp_module"); + let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); + let ns = namespace(&assets, &root); + let cap = assets.paths_for(5, 0).capture_file; + // In Node, a `.js` file's module system depends on the nearest + // `package.json`; use `.mjs` for ES modules under every runtime. + let host = root.join("host"); + fs::create_dir(&host).unwrap(); + fs::copy(&assets.omp_capture, host.join("omp-capture.mjs")).unwrap(); + let driver = host.join("driver.mjs"); + fs::write(&driver, OMP_HOST_DRIVER).unwrap(); + + let session_file = |id: &str| { + let file = host.join(format!("2026-08-15T22-13-39-854Z_{id}.jsonl")); + fs::write(&file, "").unwrap(); + file.to_str().unwrap().to_string() + }; + let session = session_file(ID); + let unwritten = host.join("unwritten.jsonl"); + let unwritten = unwritten.to_str().unwrap(); + + let fire = |event: &str, kind: &str, id: &str, file: &str, capture: Option<&Path>| { + let mut cmd = Command::new(program); + cmd.args(*prefix).arg(&driver).args([event, kind, id, file]); + match capture { + Some(path) => cmd.env(CAPTURE_ENV, path), + None => cmd.env_remove(CAPTURE_ENV), + }; + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "{event} {kind:?} {file:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + OMP_EVENTS.join(" "), + "the module must register exactly the four events" + ); + }; + let captured = || { + let written = fs::read_to_string(&cap).unwrap(); + Omp.parse_capture(&written, None, None) + }; + + // Require a normal return for absent and empty capture paths, and + // for a write error from a missing parent directory. + let orphan = root.join("missing").join("task-5-0.json"); + for capture in [None, Some(Path::new("")), Some(orphan.as_path())] { + fire("session_start", "main", ID, &session, capture); + } + assert!(!orphan.parent().unwrap().exists()); + + // Use an existing file for the sub-agent and absent-`ctx.agent` cases + // to test rejection by agent kind alone. For the top-level cases, + // omit the file on disk. + for (kind, file) in [ + ("sub", session.as_str()), + ("", session.as_str()), + ("main", unwritten), + ("main", ""), + ] { + for event in ["session_start", "agent_end"] { + fire(event, kind, ID, file, Some(&cap)); + assert!(!cap.exists(), "{event} {kind:?} {file:?} must not report"); + } + } + + for event in OMP_EVENTS { + // Verify full replacement of an earlier, longer capture. + fs::write(&cap, "9".repeat(4096)).unwrap(); + fire(event, "main", ID, &session, Some(&cap)); + let payload = jzon::parse(&fs::read_to_string(&cap).unwrap()).unwrap(); + let fields: Vec<(&str, Option<&str>)> = payload + .entries() + .map(|(key, value)| (key, value.as_str())) + .collect(); + assert_eq!( + fields, + [ + ("reason", Some(event)), + ("sessionId", Some(ID)), + ("sessionFile", Some(session.as_str())), + ("cwd", Some("/work/proj")), + ], + "{event}" + ); + assert_eq!(captured().as_deref(), Some(ID), "{event}"); + } + + // Preserve the top-level capture after a sub-agent's turn. + fire("agent_end", "sub", OTHER, &session, Some(&cap)); + assert_eq!(captured().as_deref(), Some(ID)); + + // No event is emitted for a lease move; report the new ID after the turn. + fire("agent_end", "main", OTHER, &session_file(OTHER), Some(&cap)); + assert_eq!(captured().as_deref(), Some(OTHER)); + + // Every temporary file was renamed into place. + let mut names: Vec = fs::read_dir(&ns) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + assert_eq!( + names, + [ + "claude-settings.json", + "codex-notify.sh", + "omp-capture.js", + "task-5-0.json" + ] + ); } /// Drop removes only the owned namespace and its contents. diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 67c4c1b..46b8ed8 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -2,6 +2,13 @@ //! hook, and the live session registry. Bare launches pin a v4 UUID; accepted //! launches install the hook through `--settings`. //! Live lookup reads `/sessions/.json`. +//! +//! With agent view enabled, a conversation can be moved into Claude's own +//! background daemon. In that daemon's processes, the inherited capture +//! environment and `--settings` flag are used to run the same hook with IDs +//! from other conversations. Disable agent view in the overlay to prevent +//! those handoffs. Stamp each payload with the parent Claude process's PID +//! and accept only captures from the task's own process. use std::{ fs, @@ -44,8 +51,26 @@ impl Harness for Claude { plan } - fn parse_capture(&self, payload: &str) -> Option { - capture_id(&jzon::parse(payload).ok()?, "session_id") + /// Accept a hook payload only from the task's own process. The first line + /// is the PID of the Claude process that ran the hook and must be exactly + /// the decimal form of `pid`; the remainder is the hook's JSON. Ownership + /// cannot be determined from the JSON: `source: "fork"` is reported for + /// both `/branch` in the task's process and a background fork. + /// + /// The task leader is the Claude process only after an exec from + /// `$SHELL -c`, as required for [`record_for_pid`]. With a shell retained + /// as task leader, the stamped PID differs: reject the capture and fall + /// back to the registry, then the spawn-time ID. Without a task PID, + /// ownership cannot be checked. + fn parse_capture( + &self, + payload: &str, + pid: Option, + // Ownership is decided by the stamp alone. + _home: Option<&Path>, + ) -> Option { + let json = payload.strip_prefix(&format!("{}\n", pid?))?; + capture_id(&jzon::parse(json).ok()?, "session_id") } fn live_session_id( @@ -242,20 +267,97 @@ mod tests { assert_eq!(plan.env.len(), 1, "env still names the capture file"); } + /// Task leader PID used by the capture-gate cases. + const OWNER: u32 = 4242; + + /// `SessionStart` JSON supplied to the hook: one object and a trailing + /// newline. + fn hook_json(id: &str, source: &str) -> String { + format!( + r#"{{"session_id":"{id}","transcript_path":"/t/x.jsonl","cwd":"/w","hook_event_name":"SessionStart","source":"{source}"}}"# + ) + "\n" + } + + /// Parse `payload` as the capture file of a task led by [`OWNER`]. + fn parse_owned(payload: &str) -> Option { + Claude.parse_capture(payload, Some(OWNER), None) + } + + /// Accept the leader's stamp for every `source`, including `fork` after + /// `/branch` in the task's own process. #[test] - fn parse_capture_returns_only_strict_ids() { - let payload = format!( - r#"{{"session_id":"{ID}","transcript_path":"/t/x.jsonl","cwd":"/w","hook_event_name":"SessionStart","source":"startup"}}"# - ); - assert_eq!(Claude.parse_capture(&payload).as_deref(), Some(ID)); + fn parse_capture_accepts_the_task_leaders_stamp() { + for source in ["startup", "resume", "clear", "fork"] { + let payload = format!("{OWNER}\n{}", hook_json(ID, source)); + assert_eq!(parse_owned(&payload).as_deref(), Some(ID), "{source}"); + } + } - assert_eq!(Claude.parse_capture(r#"{"session_id":"NOT-VALID"}"#), None); - assert_eq!( - Claude.parse_capture(r#"{"session_id":"x'; rm -rf ~'"}"#), - None - ); - assert_eq!(Claude.parse_capture("not json"), None); - assert_eq!(Claude.parse_capture("{}"), None); + /// Reject another process's PID, even with a shared decimal prefix. + /// Also reject captures when the task has no PID. + #[test] + fn parse_capture_refuses_a_foreign_stamp() { + let json = hook_json(ID, "startup"); + for foreign in [1, 424, 4243, 42420, 14242] { + assert_eq!( + parse_owned(&format!("{foreign}\n{json}")), + None, + "{foreign}" + ); + } + let owned = format!("{OWNER}\n{json}"); + assert_eq!(Claude.parse_capture(&owned, Some(424), None), None); + assert_eq!(Claude.parse_capture(&owned, Some(42420), None), None); + assert_eq!(Claude.parse_capture(&owned, None, None), None); + } + + /// Require the leader's exact decimal PID on the first line. Reject the + /// older, unstamped format, with JSON on the first line. + #[test] + fn parse_capture_refuses_a_missing_or_malformed_stamp() { + let json = hook_json(ID, "startup"); + assert_eq!(parse_owned(&json), None, "bare JSON"); + assert_eq!(parse_owned(json.trim_end()), None, "bare JSON, no newline"); + for stamp in [ + "", "pid", "+4242", "-4242", "04242", " 4242", "4242 ", "4242\r", "0x1092", "4242.0", + ] { + assert_eq!(parse_owned(&format!("{stamp}\n{json}")), None, "{stamp:?}"); + } + } + + /// Truncate the file, write the stamp, then copy the JSON. A read between + /// writes can return any prefix of the complete capture. + #[test] + fn parse_capture_refuses_empty_and_torn_payloads() { + let complete = format!("{OWNER}\n{}", hook_json(ID, "startup")); + assert_eq!(parse_owned(&complete).as_deref(), Some(ID)); + // The object is complete without its trailing newline. For every + // shorter prefix, the stamp, its newline, or the closing brace is missing. + let whole = complete.trim_end().len(); + for cut in 0..whole { + assert_eq!( + parse_owned(&complete[..cut]), + None, + "{:?}", + &complete[..cut] + ); + } + assert_eq!(parse_owned(&complete[..whole]).as_deref(), Some(ID)); + } + + /// Require a strict ID even with a valid stamp before shell insertion. + #[test] + fn parse_capture_returns_only_strict_ids_under_a_valid_stamp() { + for json in [ + r#"{"session_id":"NOT-VALID"}"#, + r#"{"session_id":"x'; rm -rf ~'"}"#, + r#"{"session_id":"C8C4A5CC-0B32-4BA0-A6B4-6ED08C218E0D"}"#, + r#"{"session_id":7}"#, + "not json", + "{}", + ] { + assert_eq!(parse_owned(&format!("{OWNER}\n{json}\n")), None, "{json}"); + } } /// A complete matching record exposes its validated session ID. diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 00d77cf..d58a195 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,9 +1,18 @@ -//! Codex does not let the caller select an ID at launch. This harness instead -//! injects a `notify` override and chains compatible configured notifiers. +//! A Codex ID cannot be selected at launch. Inject a `notify` override, +//! chain compatible configured notifiers, and explicitly enable embedded mode +//! with a second override. +//! +//! Notifications are emitted for every thread in one Codex process: the +//! conversation on screen, each spawned sub-agent, and the hidden thread used +//! to title a new session. Only the first is the task's conversation. Resuming +//! a sub-agent with an unloaded parent or the unsaved title thread exits 1. +//! In `parse_capture`, use the notified thread's rollout header to resolve its +//! session tree's root. Reject threads that cannot be classified. use std::{ fmt::Write as _, fs, + io::{BufRead, BufReader, Read}, path::{Path, PathBuf}, }; @@ -12,6 +21,26 @@ use super::{ home_root, resolve_home, shell_quote, }; +/// Config override for explicitly launching in embedded mode. +/// Since codex 0.157.0, running plain `codex` attaches to a shared background +/// server. With a `-c` override outside a short allowlist, embedded mode is +/// used instead. `notify` is not on that list, so with only the notify override, +/// "Running without the shared background server" is displayed at startup. +/// The warning is displayed only with this feature enabled; its key is on +/// the allowlist. Use embedded mode to keep the conversation in the task's +/// process, under `fleetcom` supervision. +/// +/// With `--no-daemon`, the same mode is selected, but versions predating the +/// flag fail to start. With an unknown `features.*` key, only an +/// unrecognized-setting warning is displayed. +const EMBEDDED_OVERRIDE: &str = "features.daemon_auto_start=false"; + +/// Upper bound on a rollout's first line, terminator included. The session's +/// base instructions are included in this line: about 22 KB in rollouts from +/// codex 0.135.0 through 0.160.0. Use 1 MiB to allow over 45 times that size +/// while bounding the read even when no newline is present. +const HEADER_MAX: u64 = 1024 * 1024; + pub struct Codex; impl Harness for Codex { @@ -46,7 +75,11 @@ impl Harness for Codex { toml_escape(&capture.codex_notify.to_string_lossy()) ); SpawnPlan { - args_suffix: format!(" -c {}", shell_quote(&toml)), + args_suffix: format!( + " -c {} -c {}", + shell_quote(&toml), + shell_quote(EMBEDDED_OVERRIDE) + ), env: vec![ ( CAPTURE_ENV.into(), @@ -58,13 +91,103 @@ impl Harness for Codex { } } - fn parse_capture(&self, payload: &str) -> Option { + /// Accept an `agent-turn-complete` notification and return the root + /// thread of the notified thread's session tree: the conversation the + /// task's TUI is on. For a sub-agent, return its root. If the thread cannot + /// be classified in [`root_thread`], return `None` to try the next ID source. + /// + /// Look up only the thread ID from the task's own notification and + /// classify it from its rollout header. Do not infer conversation + /// ownership from other sessions in the store. + fn parse_capture( + &self, + payload: &str, + // Notifications for every thread originate in the task's own process. + _pid: Option, + home: Option<&Path>, + ) -> Option { let v = jzon::parse(payload).ok()?; if v["type"].as_str() != Some("agent-turn-complete") { return None; } - capture_id(&v, "thread-id") + let thread = capture_id(&v, "thread-id")?; + root_thread(&home_root(home, ".codex")?, &thread) + } +} + +/// Resolve `thread` to the root thread of its session tree from the header of +/// its rollout under the Codex `home`. `thread` must already satisfy +/// [`is_uuid`](super::is_uuid): it is matched against file names. +/// +/// Each thread is saved as +/// `sessions///
/rollout--.jsonl`. The first +/// line is `{"type":"session_meta","payload":{…}}`: `payload.id` is the thread +/// and `payload.session_id` is the root thread of its tree. For a root thread, +/// both IDs are its own, with no `subagent` member in `source`. For a +/// sub-agent, `session_id` is the root's ID, with a `subagent` member in the +/// `source` object. +/// +/// Return `None` unless exactly one rollout is named for `thread` and its +/// header is in one of those two formats. No rollout is saved for the title +/// thread. Examine only directory listings and the first line. +fn root_thread(home: &Path, thread: &str) -> Option { + let mut rollouts = Vec::new(); + collect_rollouts( + &home.join("sessions"), + // Year, month, and day directories. + 3, + &format!("-{thread}.jsonl"), + &mut rollouts, + )?; + let [rollout] = rollouts.as_slice() else { + return None; + }; + let v = jzon::parse(&read_header(rollout)?).ok()?; + let meta = &v["payload"]; + if v["type"].as_str() != Some("session_meta") || meta["id"].as_str() != Some(thread) { + return None; + } + let root = capture_id(meta, "session_id")?; + // Require exactly one condition. In codex 0.140.0 and 0.141.0, a + // sub-agent's own ID was stored as `session_id`, so its root is unknown. + ((root == thread) != meta["source"].has_key("subagent")).then_some(root) +} + +/// Push every entry with a name ending in `suffix`, exactly `depth` directory +/// levels below `dir`. Skip non-directory entries above that level. Return +/// `None` when a directory cannot be listed: a second match may be inside. +fn collect_rollouts(dir: &Path, depth: u8, suffix: &str, found: &mut Vec) -> Option<()> { + for entry in fs::read_dir(dir).ok()? { + let entry = entry.ok()?; + if depth == 0 { + if entry + .file_name() + .to_str() + .is_some_and(|name| name.ends_with(suffix)) + { + found.push(entry.path()); + } + } else if entry.path().is_dir() { + collect_rollouts(&entry.path(), depth - 1, suffix, found)?; + } + } + Some(()) +} + +/// Read the first line of `path` without its terminator. Return `None` when +/// no newline is present in the first [`HEADER_MAX`] bytes: the file is empty, +/// the write is incomplete, or the line is too long. Also return `None` +/// for an unreadable file and for a line that is not UTF-8. +fn read_header(path: &Path) -> Option { + let mut line = Vec::new(); + BufReader::new(fs::File::open(path).ok()?) + .take(HEADER_MAX) + .read_until(b'\n', &mut line) + .ok()?; + if line.pop() != Some(b'\n') { + return None; } + String::from_utf8(line).ok() } /// Whether Codex notification capture can preserve the configured route. @@ -240,12 +363,24 @@ mod tests { use super::*; use crate::{ harness::fixtures::{assert_all_opaque, paths}, - testutil::{Scratch, temp}, + testutil::{Scratch, codex_session_meta, install_codex_rollout, install_codex_root, temp}, }; /// Codex's own launch and resume commands carry v7 IDs; the shared v4 /// fixture stays valid for detection, which is version-agnostic. const ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; + /// A sub-agent thread and a second-level sub-agent thread in the session + /// rooted at [`ID`]. + const CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; + const GRANDCHILD: &str = "019f5454-3d70-7e02-b1c8-2a4b6c8d0e1f"; + /// Hidden title thread ID, reported through notify but never saved in a rollout. + const TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; + + /// Suffix of every instrumented launch that uses [`paths`]. + const SUFFIX: &str = concat!( + r#" -c 'notify=["/tmp/Application Support/notify.sh"]'"#, + " -c 'features.daemon_auto_start=false'", + ); /// Codex-specific opaque shapes: subcommands (including `exec` and /// single-letter aliases), flags, `-c` overrides, `--resume` (the wrong @@ -283,17 +418,14 @@ mod tests { temp("codex_no_config_home") } - /// Both accepted shapes receive the same notify override because Codex - /// cannot pin an ID at launch. + /// Apply the same two overrides to both accepted forms: the notifier, + /// then explicit embedded mode. No ID can be pinned at launch. #[test] - fn instrument_installs_the_notify_override() { + fn instrument_installs_the_notify_and_embedded_overrides() { for cmd in ["codex".to_string(), format!("codex resume {ID}")] { let inv = Codex.detect(&cmd).unwrap(); let plan = Codex.instrument(&inv, &paths(), Some(&no_config_home())); - assert_eq!( - plan.args_suffix, r#" -c 'notify=["/tmp/Application Support/notify.sh"]'"#, - "{cmd}" - ); + assert_eq!(plan.args_suffix, SUFFIX, "{cmd}"); assert_eq!(plan.injected_id, None, "{cmd}"); assert_eq!( plan.env, @@ -368,10 +500,7 @@ mod tests { .unwrap(); let inv = Codex.detect("codex").unwrap(); let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert_eq!( - plan.args_suffix, - r#" -c 'notify=["/tmp/Application Support/notify.sh"]'"# - ); + assert_eq!(plan.args_suffix, SUFFIX); assert!(plan.env.contains(&( NOTIFY_CHAIN_ENV.into(), "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient\nturn-ended" @@ -379,7 +508,7 @@ mod tests { ))); } - /// Disable capture injection for an unrepresentable route. + /// For an unrepresentable route, inject neither override nor environment. #[test] fn instrument_skips_an_unrepresentable_config_notify() { let home = temp("codex_opaque_notify"); @@ -431,7 +560,10 @@ mod tests { .expect("sh must run"); assert_eq!( String::from_utf8(out.stdout).unwrap(), - format!("-c\n{}\n", r#"notify=["/Odd Path/it's \"here\"\\now"]"#) + format!( + "-c\n{}\n-c\nfeatures.daemon_auto_start=false\n", + r#"notify=["/Odd Path/it's \"here\"\\now"]"# + ) ); } @@ -541,20 +673,358 @@ mod tests { } } + /// Notification JSON for a completed turn of `thread`. + fn turn_complete(thread: &str) -> String { + format!( + r#"{{"type":"agent-turn-complete","thread-id":"{thread}","turn-id":"t","cwd":"/w"}}"# + ) + } + + /// Resolve a completed-turn notification for `thread` against `home`. + fn resolve(home: &Path, thread: &str) -> Option { + Codex.parse_capture(&turn_complete(thread), None, Some(home)) + } + + /// `session_meta` payload with raw JSON for `source`. + fn meta(id: &str, session: &str, source: &str) -> String { + format!(r#"{{"id":"{id}","session_id":"{session}","source":{source}}}"#) + } + + /// `session_meta` payload of a sub-agent of `parent` in the session + /// rooted at `root`, with raw JSON for `source`. + fn child_meta(id: &str, parent: &str, root: &str, source: &str) -> String { + format!( + r#"{{"id":"{id}","session_id":"{root}","parent_thread_id":"{parent}","source":{source}}}"# + ) + } + + /// `source` of a thread started through the model's `spawn_agent` tool. + fn thread_spawn(parent: &str, depth: u8) -> String { + format!( + r#"{{"subagent":{{"thread_spawn":{{"parent_thread_id":"{parent}","depth":{depth},"agent_path":"/root/pong","agent_nickname":"Pong"}}}}}}"# + ) + } + + /// Validate the payload before looking up its rollout. Even with a root + /// rollout present, accept only a turn-complete payload with a strict ID. #[test] fn parse_capture_accepts_only_turn_complete_payloads() { - let payload = format!( - r#"{{"type":"agent-turn-complete","thread-id":"{ID}","turn-id":"t","cwd":"/w"}}"# - ); - assert_eq!(Codex.parse_capture(&payload).as_deref(), Some(ID)); + let home = temp("codex_capture_payload"); + install_codex_root(&home, ID); + let parse = |payload: &str| Codex.parse_capture(payload, None, Some(&home)); + assert_eq!(parse(&turn_complete(ID)).as_deref(), Some(ID)); let wrong_type = format!(r#"{{"type":"other","thread-id":"{ID}"}}"#); - assert_eq!(Codex.parse_capture(&wrong_type), None); - assert_eq!( - Codex.parse_capture(r#"{"type":"agent-turn-complete","thread-id":"my session"}"#), - None + assert_eq!(parse(&wrong_type), None); + for thread in ["my session", "../../../config", "*", ""] { + let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{thread}"}}"#); + assert_eq!(parse(&payload), None, "{thread:?}"); + } + assert_eq!(parse(r#"{"type":"agent-turn-complete"}"#), None); + assert_eq!(parse("not json"), None); + assert_eq!(parse(""), None); + } + + /// For a root thread, `session_id` is its own ID under every observed + /// string `source`. Accept the capture regardless of the task's PID. + #[test] + fn parse_capture_accepts_a_root_thread() { + for source in ["cli", "vscode", "exec"] { + let home = temp("codex_capture_root"); + install_codex_rollout( + &home, + ID, + codex_session_meta(&meta(ID, ID, &format!("\"{source}\""))), + ); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID), "{source}"); + assert_eq!( + Codex + .parse_capture(&turn_complete(ID), Some(4242), Some(&home)) + .as_deref(), + Some(ID), + "{source}" + ); + } + } + + /// Resolve a spawned sub-agent to `session_id`, not its parent: for the + /// second-level thread, the parent is itself a sub-agent. Omit the root's + /// rollout to verify that no lookup is needed for it. + #[test] + fn parse_capture_maps_a_spawned_sub_agent_to_its_root() { + let home = temp("codex_capture_spawned"); + install_codex_rollout( + &home, + CHILD, + codex_session_meta(&child_meta(CHILD, ID, ID, &thread_spawn(ID, 1))), + ); + install_codex_rollout( + &home, + GRANDCHILD, + codex_session_meta(&child_meta(GRANDCHILD, CHILD, ID, &thread_spawn(CHILD, 2))), + ); + assert_eq!(resolve(&home, CHILD).as_deref(), Some(ID)); + assert_eq!(resolve(&home, GRANDCHILD).as_deref(), Some(ID)); + assert_eq!(resolve(&home, ID), None, "the root has no rollout here"); + } + + /// No parent is specified in a guardian sub-agent's `source`; classify + /// it by the `subagent` member alone. + #[test] + fn parse_capture_maps_a_guardian_sub_agent_to_its_root() { + let home = temp("codex_capture_guardian"); + install_codex_rollout( + &home, + CHILD, + codex_session_meta(&child_meta( + CHILD, + ID, + ID, + r#"{"subagent":{"other":"guardian"}}"#, + )), ); - assert_eq!(Codex.parse_capture("not json"), None); + assert_eq!(resolve(&home, CHILD).as_deref(), Some(ID)); + } + + /// In codex 0.140.0 and 0.141.0, a sub-agent's own ID was stored as + /// `session_id`. Reject `id == session_id` with a `subagent` source: + /// the root is unknown, and the sub-agent's ID is not resumable. + #[test] + fn parse_capture_refuses_a_sub_agent_that_names_itself_as_root() { + let home = temp("codex_capture_self_rooted"); + for source in [ + thread_spawn(ID, 1), + r#"{"subagent":{"other":"guardian"}}"#.to_string(), + r#"{"subagent":"review"}"#.to_string(), + ] { + install_codex_rollout( + &home, + CHILD, + codex_session_meta(&child_meta(CHILD, ID, CHILD, &source)), + ); + assert_eq!(resolve(&home, CHILD), None, "{source}"); + } + } + + /// No rollout is saved for the title thread. Reject its notification + /// with an absent store, an empty store, or rollouts for other threads. + #[test] + fn parse_capture_refuses_a_thread_without_a_rollout() { + let home = temp("codex_capture_title"); + assert_eq!(resolve(&home, TITLE), None, "no sessions directory"); + fs::create_dir_all(home.join("sessions/2026/10/04")).unwrap(); + assert_eq!(resolve(&home, TITLE), None, "empty day directory"); + install_codex_root(&home, ID); + assert_eq!(resolve(&home, TITLE), None, "another thread's rollout"); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + } + + /// Look for rollouts exactly three directories below `sessions`. + /// Ignore matching names at other depths and files among the directories. + #[test] + fn parse_capture_finds_rollouts_only_in_day_directories() { + let home = temp("codex_capture_depth"); + let name = format!("rollout-2026-10-04T13-49-56-{ID}.jsonl"); + let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); + for dir in [ + "sessions", + "sessions/2026", + "sessions/2026/10", + "sessions/2026/10/04/extra", + ] { + let dir = home.join(dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(&name), &header).unwrap(); + } + assert_eq!(resolve(&home, ID), None); + + fs::write(home.join("sessions/2026/10/04").join(&name), &header).unwrap(); + fs::write(home.join("sessions/.DS_Store"), "").unwrap(); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + } + + /// Reject duplicate rollouts for one thread, within or across day + /// directories. Accept the capture after removing the extra rollout. + #[test] + fn parse_capture_refuses_two_rollouts_for_one_thread() { + let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); + for day in ["2026/10/04", "2026/10/05", "2027/01/01"] { + let home = temp("codex_capture_twins"); + install_codex_root(&home, ID); + let dir = home.join("sessions").join(day); + fs::create_dir_all(&dir).unwrap(); + let twin = dir.join(format!("rollout-2026-10-05T09-00-00-{ID}.jsonl")); + fs::write(&twin, &header).unwrap(); + assert_eq!(resolve(&home, ID), None, "{day}"); + fs::remove_file(&twin).unwrap(); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID), "{day}"); + } + } + + /// Reject the capture if a day directory cannot be listed: a second + /// rollout may be present even when only one is visible. + #[test] + fn parse_capture_refuses_a_store_it_cannot_list() { + use std::os::unix::fs::PermissionsExt; + let home = temp("codex_capture_unlistable"); + install_codex_root(&home, ID); + let locked = home.join("sessions/2026/10/05"); + fs::create_dir_all(&locked).unwrap(); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + let refused = resolve(&home, ID); + // Restore access before asserting so the scratch tree can be removed. + fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(refused, None); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + } + + /// Require the notified thread's ID and its session tree in the header. + /// Reject every other format as unclassified. + #[test] + fn parse_capture_refuses_an_unclassified_header() { + let spawned = thread_spawn(ID, 1); + for (what, header) in [ + ( + "the header's id is another thread", + codex_session_meta(&meta(CHILD, CHILD, r#""cli""#)), + ), + ( + "a sub-agent's header that names the notified thread as its root", + codex_session_meta(&child_meta(CHILD, ID, ID, &spawned)), + ), + ( + "no session_id", + codex_session_meta(&format!(r#"{{"id":"{ID}","source":"cli"}}"#)), + ), + ( + "session_id is not a string", + codex_session_meta(&format!(r#"{{"id":"{ID}","session_id":7,"source":"cli"}}"#)), + ), + ( + "session_id is not a strict ID", + codex_session_meta(&meta(ID, "x'; rm -rf ~'", &spawned)), + ), + ( + "session_id is uppercase", + codex_session_meta(&meta(ID, &CHILD.to_uppercase(), &spawned)), + ), + ( + "a foreign session_id under a string source", + codex_session_meta(&meta(ID, CHILD, r#""cli""#)), + ), + ( + "a foreign session_id without a source", + codex_session_meta(&format!(r#"{{"id":"{ID}","session_id":"{CHILD}"}}"#)), + ), + ( + "a foreign session_id under an object source that names no sub-agent", + codex_session_meta(&meta(ID, CHILD, r#"{"custom":"x"}"#)), + ), + ( + "a foreign session_id under a string source spelled like the member", + codex_session_meta(&meta(ID, CHILD, r#""subagent""#)), + ), + ( + "the wrong record type", + format!( + r#"{{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"turn_context","payload":{}}}"#, + meta(ID, ID, r#""cli""#) + ) + "\n", + ), + ( + "no record type", + format!(r#"{{"payload":{}}}"#, meta(ID, ID, r#""cli""#)) + "\n", + ), + ("no payload", "{\"type\":\"session_meta\"}\n".to_string()), + ("a JSON array", "[]\n".to_string()), + ("not JSON", "not json\n".to_string()), + ( + "a blank first line", + format!("\n{}", codex_session_meta(&meta(ID, ID, r#""cli""#))), + ), + ] { + let home = temp("codex_capture_unclassified"); + install_codex_rollout(&home, ID, header); + assert_eq!(resolve(&home, ID), None, "{what}"); + } + } + + /// A read during the header write can return any prefix of it. Accept + /// only a terminated line; reject an empty file or a complete object + /// without its newline. + #[test] + fn parse_capture_refuses_empty_and_torn_headers() { + let home = temp("codex_capture_torn"); + let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); + for cut in 0..header.len() { + install_codex_rollout(&home, ID, &header[..cut]); + assert_eq!(resolve(&home, ID), None, "{:?}", &header[..cut]); + } + install_codex_rollout(&home, ID, &header); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + } + + /// Require a newline within the first [`HEADER_MAX`] bytes. Bound the + /// line length, not the file size. + #[test] + fn parse_capture_bounds_the_header_line() { + let home = temp("codex_capture_bound"); + let header = |pad: usize| { + codex_session_meta(&format!( + r#"{{"id":"{ID}","session_id":"{ID}","source":"cli","base_instructions":{{"text":"{}"}}}}"#, + "x".repeat(pad) + )) + }; + let max = usize::try_from(HEADER_MAX).unwrap(); + let fill = max - header(0).len(); + + let at_bound = header(fill); + assert_eq!(at_bound.len(), max); + install_codex_rollout(&home, ID, at_bound.clone() + &at_bound); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + + let over = header(fill + 1); + assert_eq!(over.len(), max + 1); + install_codex_rollout(&home, ID, over); + assert_eq!(resolve(&home, ID), None); + } + + /// Accept a header alone or followed by bytes that are neither JSON nor + /// UTF-8: nothing past the first line is examined. + #[test] + fn parse_capture_reads_nothing_after_the_header() { + let home = temp("codex_capture_body"); + let header = codex_session_meta(&meta(ID, ID, r#""cli""#)); + install_codex_rollout(&home, ID, &header); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + + let mut with_body = header.into_bytes(); + with_body.extend_from_slice(b"\xff\xfe not json\n{\"type\":\"session_meta\""); + install_codex_rollout(&home, ID, with_body); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + } + + /// Reject unreadable rollouts and non-UTF-8 first lines. Place the invalid + /// byte in a field unused for classification to test strict decoding. + #[test] + fn parse_capture_refuses_an_unreadable_header() { + let home = temp("codex_capture_unreadable"); + let path = install_codex_root(&home, ID); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert_eq!(resolve(&home, ID), None, "a directory"); + + fs::remove_dir(&path).unwrap(); + let header = codex_session_meta(&format!( + r#"{{"id":"{ID}","session_id":"{ID}","source":"cli","cwd":"/w"}}"# + )); + install_codex_rollout(&home, ID, &header); + assert_eq!(resolve(&home, ID).as_deref(), Some(ID)); + let mut bytes = header.into_bytes(); + let cwd = bytes.windows(2).position(|w| w == b"/w").unwrap(); + bytes.insert(cwd, 0xff); + install_codex_rollout(&home, ID, bytes); + assert_eq!(resolve(&home, ID), None, "invalid UTF-8"); } /// Notification chaining reads `config.toml` and ignores sibling files. diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 4357d72..63e5762 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -78,7 +78,7 @@ mod tests { fn parse_capture_is_unconditionally_none() { // No injected channel exists, so no payload is ever trusted. let payload = format!(r#"{{"session_id":"{ID}"}}"#); - assert_eq!(Grok.parse_capture(&payload), None); - assert_eq!(Grok.parse_capture(""), None); + assert_eq!(Grok.parse_capture(&payload, None, None), None); + assert_eq!(Grok.parse_capture("", None, None), None); } } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 7bcdcfe..13caf55 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -44,8 +44,9 @@ pub const NOTIFY_CHAIN_ENV: &str = "FLEETCOM_NOTIFY_CHAIN"; /// Detection, capture, and resume behavior for one agent CLI. pub trait Harness: Sync { - /// Resolve configuration needed by instrumentation or the live registry - /// from the launch environment. Tools that need neither return `None`. + /// Resolve configuration needed by instrumentation, capture parsing, or + /// the live registry from the launch environment. Return `None` when no + /// configuration is needed. fn resolve_home(&self, _env: &dyn Fn(&str) -> Option) -> Option { None } @@ -71,9 +72,17 @@ pub trait Harness: Sync { home: Option<&Path>, ) -> SpawnPlan; - /// Extract a session ID from hook or notify JSON. Defaults to `None` for - /// tools without an injected capture channel. - fn parse_capture(&self, _payload: &str) -> Option { + /// Extract a session ID from the capture file's contents. `pid` is the + /// task's session leader and `home` the launch-time harness home. When + /// other processes can write to the capture channel, use these arguments + /// to reject payloads written outside the task's own process. + /// Return `None` by default for tools without an injected capture channel. + fn parse_capture( + &self, + _payload: &str, + _pid: Option, + _home: Option<&Path>, + ) -> Option { None } @@ -280,8 +289,8 @@ pub fn is_uuid(s: &str) -> bool { }) } -/// Validated session ID at `key` in hook or notify JSON; [`is_uuid`] is the -/// shell-insertion boundary. +/// Validated session ID at `key` in a capture payload or a Codex rollout +/// header; [`is_uuid`] is the shell-insertion boundary. fn capture_id(v: &jzon::JsonValue, key: &str) -> Option { let id = v[key].as_str()?; is_uuid(id).then(|| id.to_string()) diff --git a/src/harness/omp.rs b/src/harness/omp.rs index 36bbf98..083a606 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -1,7 +1,14 @@ -//! omp cannot pin a session ID at launch: it has no `--session-id` flag, and -//! `--resume` requires an existing session. Live capture therefore loads an -//! extension whose `session_start` and `session_switch` handlers write the -//! current ID. +//! An omp session ID cannot be pinned at launch: no `--session-id` flag is +//! available, and an existing session is required for `--resume`. For live +//! capture, load an extension to report the top-level ID on `session_start`, +//! `session_switch`, `session_branch`, and `agent_end`. +//! +//! Report only IDs usable with `omp --resume`. Skip sub-agent sessions: +//! the same handlers are registered for them, but their IDs are not resumable. +//! Wait until the session file is created after the first assistant message. +//! For a bare `omp` task, no ID is captured until the end of the first turn. +//! Capture is unavailable before omp 18.3.2: without the agent kind in the +//! extension context, top-level sessions cannot be distinguished from sub-agents. //! //! omp's IDs are UUIDv7. [`is_uuid`](super::is_uuid) validates the 8-4-4-4-12 //! lowercase-hex shape and not the version field, so they pass unchanged. @@ -40,8 +47,15 @@ impl Harness for Omp { } } - /// Return `sessionId` from a valid extension payload. - fn parse_capture(&self, payload: &str) -> Option { + /// Return `sessionId` from a valid extension payload. Check the agent + /// kind in the extension: without that field in the payload, the check + /// cannot be repeated here. + fn parse_capture( + &self, + payload: &str, + _pid: Option, + _home: Option<&Path>, + ) -> Option { capture_id(&jzon::parse(payload).ok()?, "sessionId") } } @@ -100,22 +114,28 @@ mod tests { /// Extract only a validated ID from the extension payload. #[test] fn parse_capture_returns_only_strict_ids() { - for reason in ["session_start", "session_switch"] { + let parse = |payload: &str| Omp.parse_capture(payload, None, None); + for reason in [ + "session_start", + "session_switch", + "session_branch", + "agent_end", + ] { let payload = format!( r#"{{"reason":"{reason}","sessionId":"{CAPTURED}","sessionFile":"/s/2026-08-15T22-13-39-854Z_{CAPTURED}.jsonl","cwd":"/work/proj"}}"# ); - assert_eq!(Omp.parse_capture(&payload).as_deref(), Some(CAPTURED)); + assert_eq!(parse(&payload).as_deref(), Some(CAPTURED)); } - assert_eq!(Omp.parse_capture("not json"), None); - assert_eq!(Omp.parse_capture("{}"), None); - assert_eq!(Omp.parse_capture(r#"{"sessionId":"my session"}"#), None); - assert_eq!(Omp.parse_capture(r#"{"sessionId":"x'; rm -rf ~'"}"#), None); + assert_eq!(parse("not json"), None); + assert_eq!(parse("{}"), None); + assert_eq!(parse(r#"{"sessionId":"my session"}"#), None); + assert_eq!(parse(r#"{"sessionId":"x'; rm -rf ~'"}"#), None); // Reject uppercase hex during UUID validation. assert_eq!( - Omp.parse_capture(&format!(r#"{{"sessionId":"{}"}}"#, CAPTURED.to_uppercase())), + parse(&format!(r#"{{"sessionId":"{}"}}"#, CAPTURED.to_uppercase())), None ); - assert_eq!(Omp.parse_capture(""), None); + assert_eq!(parse(""), None); } } diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 81d20a6..d74a701 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -331,8 +331,9 @@ const CODEX_EFFORT: &[&str] = &[ /// depth, so counting them would push the status row out of reach. const CODEX_STATUS_WINDOW: usize = 10; -/// Codex summary adapter for the primary-screen inline UI and approval modals -/// on either screen. Anchor at the composer: the bottom-most column-0 prompt-glyph +/// Codex summary adapter for the inline UI on the primary screen and the +/// fullscreen transcript on the alternate screen, with the same matchers on +/// either screen. Anchor at the composer: the bottom-most column-0 prompt-glyph /// row that is not a modal selector. Scan above it for status rows, stopping at /// the first foreign row. Check for an approval modal first, with the composer /// absent. Also check below the composer for a status line or indented hints. @@ -476,9 +477,12 @@ fn codex_warning_shortcut(hint: &str) -> bool { for modifier in ["ctrl+", "shift+"] { key = key.strip_prefix(modifier).unwrap_or(key); } + // Since Codex 0.160.0, `⌥w` is displayed instead of `⌥+w`. + // Treat a trailing `+` as the key itself. key = key .strip_prefix("alt+") - .or_else(|| key.strip_prefix("⌥+")) + .or_else(|| key.strip_prefix("⌥+").filter(|rest| !rest.is_empty())) + .or_else(|| key.strip_prefix('⌥')) .unwrap_or(key); (key.len() == 1 && !key.starts_with(char::is_whitespace)) || matches!( diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 1d062e7..37cf0e2 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -710,6 +710,14 @@ fn codex_label_separates_right_aligned_warning_badges() { "⚠ 5 · ctrl+x ctrl+w", "⚠ 6 · /warnings", "⚠ 1 warning · ctrl+shift+⌥+f24 to view", + // Since Codex 0.160.0, `⌥` is displayed without `+` before the key. + "⚠ 3 · ⌥w", + "⚠ 2 warnings · ⌥w to view", + "⚠ 1 warning · ctrl+shift+⌥f24 to view", + "⚠ 5 · ctrl+x ⌥w", + // The `+` key itself, in the 0.160 and 0.159 spellings. + "⚠ 1 · ⌥+", + "⚠ 1 · ⌥++", "⚠ 1 · fwd del", "⚠ 1 · ctrl+x ctrl+fwd del", "⚠ 1", @@ -748,6 +756,12 @@ fn codex_label_rejects_warning_prose_and_incomplete_footers() { " gpt-5.4 high ⚠ 1 ·", " gpt-5.4 high ⚠ 1 · f2 extra words", " gpt-5.4 high ⚠ 1 · f25", + // Require a key immediately after `⌥`, with no space or repeated modifier. + " gpt-5.4 high ⚠ 1 · ⌥", + " gpt-5.4 high ⚠ 1 · ⌥ w", + " gpt-5.4 high ⚠ 1 warning · ⌥ to view", + " gpt-5.4 high ⚠ 1 warning · ⌥ w to view", + " gpt-5.4 high ⚠ 1 · ⌥⌥w", " gpt-5.4 high ⚠ 1 warning · f2 to vie…", " gpt-5.4 high ⚠ 1 warning · f2 to view", " gpt-5.4 high⚠ 1", diff --git a/src/supervisor.rs b/src/supervisor.rs index 0cd4810..3054b34 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -132,12 +132,13 @@ fn fnv1a_hex(bytes: &[u8]) -> String { } /// Resolve the session ID in precedence order: capture file, live registry, -/// then spawn-time ID. The first two can reflect a session selected -/// after launch and therefore outrank the spawn-time value. +/// then spawn-time ID. Prefer the first two sources because a session may have +/// been selected after launch. Validate the capture with the task's leader PID +/// and launch-time home through the harness. If rejected, try the next source. fn current_resume_id(task: &Task) -> Option { if let (Some(h), Some(path)) = (task.harness, &task.capture_file) && let Ok(payload) = std::fs::read_to_string(path) - && let Some(id) = h.parse_capture(&payload) + && let Some(id) = h.parse_capture(&payload, task.pid(), task.harness_home.as_deref()) { return Some(id); } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index b64a80f..694c1ec 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::{ harness::fixtures::{ID as CAP_ID, OTHER as CAP_OTHER}, protocol::{Preview, PreviewSource}, + testutil::{codex_session_meta, install_codex_rollout, install_codex_root}, }; // --- session-capture wiring ------------------------------------------- @@ -88,6 +89,15 @@ fn install_status_record(home: &Path, pid: u32, cwd: &Path, status: &str) { .unwrap(); } +/// Capture-file contents from the task's own `SessionStart` hook: +/// the leader PID on the first line, then the hook's JSON and its newline. +fn stamped(task: &Task, json: &str) -> String { + format!( + "{}\n{json}\n", + task.pid().expect("a spawned task has a pid") + ) +} + /// Save a recipe and return its persisted JSON. fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { s.apply(Command::SaveSession { name: name.into() }); @@ -243,8 +253,11 @@ fn rerun_resumes_the_captured_conversation() { let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write( &cap, - format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + stamped( + &s.tasks[0], + &format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), ), ) .unwrap(); @@ -293,11 +306,12 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { spawn(&mut s, "claude", dir.to_path_buf()); let id = s.tasks[0].id; // The old run's final capture becomes the new run's launch ID. - let stale = format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"startup"}}"# - ); let old_cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write(&old_cap, format!(r#"{{"session_id":"{CAP_ID}"}}"#)).unwrap(); + std::fs::write( + &old_cap, + stamped(&s.tasks[0], &format!(r#"{{"session_id":"{CAP_ID}"}}"#)), + ) + .unwrap(); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); @@ -312,6 +326,13 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { ); // A late hook write can recreate the old path, but the new run cannot read it. + // Stamp with the new leader's PID to test isolation by path alone. + let stale = stamped( + &s.tasks[0], + &format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"startup"}}"# + ), + ); std::fs::write(&old_cap, &stale).unwrap(); let text = save_and_read(&mut s, &config, "stalecap"); assert!( @@ -439,10 +460,10 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { ); } -/// A `codex` spawn receives a `notify=[...]` override naming an executable -/// capture script. +/// For a `codex` spawn, add `notify=[...]` with the executable capture script, +/// then the override for embedded mode. #[test] -fn spawn_codex_installs_the_notify_override() { +fn spawn_codex_installs_the_notify_and_embedded_overrides() { use std::os::unix::fs::PermissionsExt; let dir = scratch("cap_codex"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); @@ -465,6 +486,11 @@ fn spawn_codex_installs_the_notify_override() { .strip_prefix("notify=[\"") .and_then(|t| t.strip_suffix("\"]")) .unwrap_or_else(|| panic!("malformed notify override: {:?}", argv[ci + 1])); + assert_eq!( + argv[ci + 2..], + ["-c", "features.daemon_auto_start=false"], + "the embedded-mode override must follow the notify override" + ); let meta = std::fs::metadata(script).expect("the notify program must exist"); assert!( meta.permissions().mode() & 0o111 != 0, @@ -618,7 +644,7 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_commands() { "claude" => { std::fs::write( s.tasks[0].capture_file.as_ref().unwrap(), - format!(r#"{{"session_id":"{CAP_OTHER}"}}"#), + stamped(&s.tasks[0], &format!(r#"{{"session_id":"{CAP_OTHER}"}}"#)), ) .unwrap(); Some(CAP_OTHER.to_string()) @@ -780,8 +806,11 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write( &cap, - format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + stamped( + &s.tasks[0], + &format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), ), ) .unwrap(); @@ -793,6 +822,110 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { std::fs::write(&done, b"").unwrap(); } +/// Session ID reported by a process that is not the task. +const FOREIGN_ID: &str = "22222222-3333-4444-8555-666666666666"; + +/// Run the installed hook from a process other than the task leader to +/// replace the capture with a foreign session. Reject the foreign parent's +/// PID stamp and use the spawn-time ID, then the registry ID once available. +#[test] +fn capture_stamped_by_a_foreign_process_falls_back_to_the_next_source() { + let dir = scratch("cap_foreign"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let claude_home = dir.join("claude_home"); + let json = |id: &str, source: &str| { + format!(r#"{{"session_id":"{id}","hook_event_name":"SessionStart","source":"{source}"}}"#) + }; + // The stub is the task leader only when `/bin/sh -c` replaces itself with + // it, which Ubuntu's `/bin/sh` does not. The test therefore writes the + // task's own capture itself, with the leader's stamp. The stub runs the + // installed hook under an intermediate shell with the same inherited + // environment, as in Claude's daemon. Append `:` to prevent an exec of + // the pipeline's last command: the hook's `$PPID` must be the + // intermediate shell, which is never the leader. + install_script( + &bin, + "claude", + &format!( + r#"until [ -e '{d}/foreign' ]; do sleep 0.05; done +sh -c 'printf "%s\n" "$1" | sh "$0"; :' '{d}/hook' '{foreign}' +: > '{d}/foreign-done' +until [ -e '{d}/done' ]; do sleep 0.05; done"#, + d = dir.display(), + foreign = json(FOREIGN_ID, "startup"), + ), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CLAUDE_CONFIG_DIR", &claude_home), + ], + )); + spawn(&mut s, "claude", dir.to_path_buf()); + let pinned = s.tasks[0] + .resume_id + .clone() + .expect("a fresh claude launch pins an id"); + let pid = s.tasks[0].pid().expect("a live task has a pid"); + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + + // The foreign stage runs the hook command from the installed overlay. + let overlay = std::fs::read_to_string(cap.with_file_name("claude-settings.json")).unwrap(); + let hook = jzon::parse(&overlay).unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .expect("the overlay must carry the hook command") + .to_string(); + std::fs::write(dir.join("hook"), hook).unwrap(); + + std::fs::write(&cap, stamped(&s.tasks[0], &json(CAP_OTHER, "clear"))).unwrap(); + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(CAP_OTHER), + "the task's own capture must beat the pin" + ); + + std::fs::write(dir.join("foreign"), b"").unwrap(); + assert!( + wait_until(Duration::from_secs(5), || dir.join("foreign-done").exists()), + "the stub never ran the hook under the intermediate shell" + ); + let text = std::fs::read_to_string(&cap).unwrap(); + let (stamp, rest) = text + .split_once('\n') + .expect("the hook must write a stamp line"); + assert_eq!( + rest, + format!("{}\n", json(FOREIGN_ID, "startup")), + "the foreign session must have replaced the task's capture" + ); + assert!( + stamp.parse::().is_ok_and(|p| p != pid), + "the stamp must name the hook's own parent, not the leader: {stamp:?}" + ); + + // With no registry record yet, use the pinned ID after rejecting the capture. + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(pinned.as_str()), + "a foreign capture must fall through to the spawn-time id" + ); + install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(CAP_ID), + "a foreign capture must fall through to the registry" + ); + let text = save_and_read(&mut s, &config, "foreign"); + assert!( + text.contains(&format!("claude --resume '{CAP_ID}'")) && !text.contains(FOREIGN_ID), + "the recipe must never resume the foreign session; got {text}" + ); + std::fs::write(dir.join("done"), b"").unwrap(); +} + /// Two tasks sharing a directory do not own a nearby rollout. Named saves /// and recovery must preserve both authored commands when capture is silent. #[test] @@ -802,8 +935,9 @@ fn silent_codex_tasks_keep_authored_commands_in_saves_and_recovery() { let codex_home = dir.join("codex_home"); install_script(&bin, "codex", "exit 0"); - // This sole rollout matches the directory and the former 30-second - // window for both tasks, but predates both launches. + // Use the same directory and the former 30-second window for both tasks, + // but write the rollout before either launch. Include a complete root + // header; it must be read only after a notification for this thread. let ms = now_ms(); let id = format!( "{:08x}-{:04x}-7000-8000-000000000001", @@ -815,10 +949,10 @@ fn silent_codex_tasks_keep_authored_commands_in_saves_and_recovery() { std::fs::create_dir_all(&rollouts).unwrap(); std::fs::write( rollouts.join(format!("rollout-2026-07-13T09-00-00-{id}.jsonl")), - format!( - r#"{{"type":"session_meta","payload":{{"id":"{id}","cwd":"{}"}}}}"#, + codex_session_meta(&format!( + r#"{{"id":"{id}","session_id":"{id}","source":"cli","cwd":"{}"}}"#, dir.display() - ), + )), ) .unwrap(); @@ -890,6 +1024,138 @@ fn silent_codex_tasks_keep_authored_commands_in_saves_and_recovery() { ); } +/// Thread IDs reported through one codex process's notifier: the +/// conversation, a spawned sub-agent, and the hidden title thread. +const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; +const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; +const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; + +/// Capture contents for a completed turn of `thread`: the notification JSON +/// written by the injected notifier. `last` is the final assistant message, +/// already escaped for a JSON string. +fn turn_complete(thread: &str, last: &str) -> String { + format!( + r#"{{"type":"agent-turn-complete","thread-id":"{thread}","turn-id":"t","cwd":"/w","input-messages":["ping"],"last-assistant-message":"{last}"}}"# + ) +} + +/// The title thread's notification: its final message is the generated title. +fn title_turn() -> String { + turn_complete(CODEX_TITLE, r#"{\"title\":\"Ping the sub-agent\"}"#) +} + +/// Replace the sole task's capture with `payload`, save under `name`, and +/// return the persisted command. +fn saved_command(s: &mut Supervisor, config: &Path, name: &str, payload: &str) -> String { + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write(&cap, payload).unwrap(); + save_and_read(s, config, name); + session::load_in(&config.join("sessions"), name) + .unwrap() + .into_values() + .flatten() + .next() + .expect("the recipe must hold the task") + .cmd +} + +/// Report three threads from one codex process in the order observed after +/// the first prompt: the title thread, a sub-agent, then the conversation. +/// Replace the capture on each write. With no rollout for the title thread, +/// preserve the authored command. Resolve the sub-agent to its conversation +/// and the conversation to itself. +#[test] +fn codex_capture_resolves_each_notifying_thread_to_the_root() { + let dir = scratch("codex_threads"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let codex_home = dir.join("codex_home"); + install_script(&bin, "codex", "exit 0"); + install_codex_root(&codex_home, CODEX_ROOT); + install_codex_rollout( + &codex_home, + CODEX_CHILD, + codex_session_meta(&format!( + r#"{{"id":"{CODEX_CHILD}","session_id":"{CODEX_ROOT}","parent_thread_id":"{CODEX_ROOT}","source":{{"subagent":{{"thread_spawn":{{"parent_thread_id":"{CODEX_ROOT}","depth":1,"agent_path":"/root/pong","agent_nickname":"Pong"}}}}}}}}"# + )), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CODEX_HOME", &codex_home), + ], + )); + spawn(&mut s, "codex", dir.to_path_buf()); + assert!(s.tasks[0].resume_id.is_none(), "codex pins no id at launch"); + + assert_eq!( + saved_command(&mut s, &config, "title", &title_turn()), + "codex", + "the title thread must not become the resume target" + ); + let resumes_root = format!("codex resume '{CODEX_ROOT}'"); + assert_eq!( + saved_command( + &mut s, + &config, + "child", + &turn_complete(CODEX_CHILD, "pong") + ), + resumes_root, + "a sub-agent's turn must resume the conversation that spawned it" + ); + assert_eq!( + saved_command(&mut s, &config, "root", &turn_complete(CODEX_ROOT, "done")), + resumes_root, + "the conversation's own turn must resume it" + ); +} + +/// For `codex resume`, preserve the launch ID after rejecting notifications +/// for the title thread or a rollout outside the launch-time Codex home. +/// Prefer a captured root thread in the task's own home over the launch ID. +#[test] +fn refused_codex_capture_keeps_the_launch_target() { + /// A root thread saved under another Codex home. + const FOREIGN: &str = "019f5460-1a2b-7c3d-8e4f-5a6b7c8d9e0f"; + let dir = scratch("codex_refused"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let codex_home = dir.join("codex_home"); + install_script(&bin, "codex", "exit 0"); + install_codex_root(&dir.join("other_home"), FOREIGN); + install_codex_root(&codex_home, CODEX_ROOT); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CODEX_HOME", &codex_home), + ], + )); + let authored = format!("codex resume '{CAP_ID}'"); + spawn(&mut s, &authored, dir.to_path_buf()); + assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); + + assert_eq!( + saved_command(&mut s, &config, "title", &title_turn()), + authored, + "the title thread must not displace the launch target" + ); + assert_eq!( + saved_command(&mut s, &config, "foreign", &turn_complete(FOREIGN, "hi")), + authored, + "a thread outside the task's Codex home must not displace the launch target" + ); + assert_eq!( + saved_command(&mut s, &config, "root", &turn_complete(CODEX_ROOT, "done")), + format!("codex resume '{CODEX_ROOT}'"), + "the task's own root thread must still outrank the launch target" + ); +} + /// Home resolution order: the tool's own var, then the launch env's HOME /// joined with the tool's dot directory, then nothing. #[test] @@ -948,9 +1214,10 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { "HOME alone must resolve the harness home" ); let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - !argv.iter().any(|a| a.contains("notify=")), - "the guard must read /.codex/config.toml; argv: {argv:?}" + assert_eq!( + argv, + [""], + "the guard must read /.codex/config.toml and inject nothing" ); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished @@ -976,6 +1243,7 @@ fn stale_inherited_notify_chain_is_never_executed() { let (bin, runtime) = (dir.join("bin"), dir.join("run")); // No config.toml exists: nothing routed, so nothing may be chained. let codex_home = dir.join("codex_home"); + install_codex_root(&codex_home, CAP_ID); let stale = dir.join("stale"); let record = dir.join("stale-record"); write_executable(&stale, &format!("touch '{}'", record.display())); @@ -1014,6 +1282,11 @@ fn stale_inherited_notify_chain_is_never_executed() { !record.exists(), "the stale inherited chain must not execute" ); + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(CAP_ID), + "the notifier's payload for a root thread must pass the capture gate" + ); } /// Without a known ID, an agent recipe retains the original command. @@ -1057,7 +1330,7 @@ fn config_toml_notify_chains_through_the_injected_script() { let dir = scratch("cfg_chain"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); let codex_home = dir.join("codex_home"); - std::fs::create_dir_all(&codex_home).unwrap(); + install_codex_root(&codex_home, CAP_ID); // The notifier path contains spaces and carries a fixed argument. let notifier = dir.join("Fake App.app").join("Sky Client"); let record = dir.join("notifier-record"); @@ -1115,6 +1388,11 @@ fn config_toml_notify_chains_through_the_injected_script() { payload, "the capture write must precede the chain handoff" ); + assert_eq!( + current_resume_id(&s.tasks[0]).as_deref(), + Some(CAP_ID), + "the notifier's payload for a root thread must pass the capture gate" + ); } /// An unrepresentable `notify` value disables injection, while a commented @@ -1140,9 +1418,10 @@ fn unrepresentable_config_notify_suppresses_injection() { )); spawn(&mut s, "codex", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); - assert!( - !argv.iter().any(|a| a.contains("notify=")), - "fleetcom must preserve an unrepresentable notify; argv: {argv:?}" + assert_eq!( + argv, + [""], + "fleetcom must preserve an unrepresentable notify and inject nothing" ); // The same route commented out is inert: the injection returns. @@ -1227,8 +1506,11 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write( &cap, - format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + stamped( + &s.tasks[0], + &format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), ), ) .unwrap(); diff --git a/src/testutil.rs b/src/testutil.rs index 9b255ef..a313b3a 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -196,6 +196,41 @@ pub(crate) fn install_fake_notifier(path: &Path, record: &Path) { ); } +/// First line of a Codex rollout: the `session_meta` envelope around +/// `payload`, newline-terminated like every rollout line. +pub(crate) fn codex_session_meta(payload: &str) -> String { + format!( + r#"{{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"session_meta","payload":{payload}}}"# + ) + "\n" +} + +/// Write `contents` as the rollout of `thread` under the Codex `home` and +/// return its path. +pub(crate) fn install_codex_rollout( + home: &Path, + thread: &str, + contents: impl AsRef<[u8]>, +) -> PathBuf { + let day = home.join("sessions/2026/10/04"); + fs::create_dir_all(&day).unwrap(); + let path = day.join(format!("rollout-2026-10-04T13-49-56-{thread}.jsonl")); + fs::write(&path, contents).unwrap(); + path +} + +/// Install the rollout header of a root thread: `session_id` equals `id` and +/// `source` is a string. Include only the fields used for capture validation; +/// other fields are present in a real header. +pub(crate) fn install_codex_root(home: &Path, thread: &str) -> PathBuf { + install_codex_rollout( + home, + thread, + codex_session_meta(&format!( + r#"{{"id":"{thread}","session_id":"{thread}","source":"cli"}}"# + )), + ) +} + /// Corpus geometry: the fixture recordings in `tests/corpus/` were captured /// under a 40-row, 120-column PTY (tests/corpus/README.md). pub(crate) const CORPUS_LINES: usize = 40; diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 1ff998b..452ba23 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -23,6 +23,10 @@ const RUN_MARKER: &str = "-- run --"; /// Fixed v7-shaped thread ID reported by the `codex` stub. const CODEX_ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; +/// Config override for embedded mode, placed after the notify override on +/// every instrumented `codex` launch. +const CODEX_EMBEDDED: &str = "features.daemon_auto_start=false"; + /// Scratch tree containing every executable, store, working directory, and argv /// record used by one test. struct Scratch { @@ -131,8 +135,10 @@ fn install_stub(s: &Scratch, name: &str, body: &str) { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); } -/// `claude` stub that records argv, writes a `SessionStart` payload to the -/// capture file, and prints a resumable exit hint. +/// Install a `claude` stub to record argv, write a `SessionStart` payload, +/// and print a resumable exit hint. Prefix the payload with the same PID +/// stamp as the real hook: after an exec from the shell, the stub's `$$` +/// is the task leader PID. fn install_claude_stub(s: &Scratch) { let body = format!( r#"id='' @@ -143,7 +149,7 @@ for a in "$@"; do done printf '%s\n' '{marker}' "$@" >> '{rec}' if [ -n "$id" ] && [ -n "$FLEETCOM_CAPTURE_FILE" ]; then - printf '{{"session_id":"%s","hook_event_name":"SessionStart","source":"startup"}}' "$id" > "$FLEETCOM_CAPTURE_FILE" + printf '%s\n{{"session_id":"%s","hook_event_name":"SessionStart","source":"startup"}}\n' "$$" "$id" > "$FLEETCOM_CAPTURE_FILE" fi printf 'Resume this session with:\nclaude --resume %s\n' "$id""#, marker = RUN_MARKER, @@ -152,12 +158,16 @@ printf 'Resume this session with:\nclaude --resume %s\n' "$id""#, install_stub(s, "claude", &body); } -/// `codex` stub that records argv and writes `CODEX_ID` only to the capture -/// file. +/// Install a `codex` stub to record argv and report `CODEX_ID` only through +/// the capture file. Before notifying, save a root rollout header under +/// `$CODEX_HOME`: set `session_id` to `id` and use a string `source`. fn install_codex_stub(s: &Scratch) { let body = format!( r#"printf '%s\n' '{marker}' "$@" >> '{rec}' if [ -n "$FLEETCOM_CAPTURE_FILE" ]; then + day="$CODEX_HOME/sessions/2026/10/04" + mkdir -p "$day" + printf '%s\n' '{{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"session_meta","payload":{{"id":"{id}","session_id":"{id}","source":"cli"}}}}' > "$day/rollout-2026-10-04T13-49-56-{id}.jsonl" printf '{{"type":"agent-turn-complete","thread-id":"{id}"}}' > "$FLEETCOM_CAPTURE_FILE" fi"#, marker = RUN_MARKER, @@ -335,6 +345,11 @@ fn codex_capture_file_drives_save_and_load_resumes() { .map(PathBuf::from) .unwrap_or_else(|| panic!("spawn must route notify at one script: {argv:?}")); assert_daemon_namespaced(&script, daemon.0.id(), "notify script", &argv); + assert_eq!( + argv, + ["-c", notify.as_str(), "-c", CODEX_EMBEDDED], + "a bare spawn must receive the two overrides and nothing else" + ); // The stub exits silently, so its capture write is the only id channel; // wait for the file, then a single save must persist the resuming form. @@ -358,9 +373,9 @@ fn codex_capture_file_drives_save_and_load_resumes() { "the respawn must lead with the resume form: {argv:?}" ); assert_eq!( - value_after(&argv, "-c"), - notify, - "the respawn must be re-instrumented with the notify override: {argv:?}" + argv[2..], + ["-c", notify.as_str(), "-c", CODEX_EMBEDDED], + "the respawn must be re-instrumented with both overrides: {argv:?}" ); stop_daemon(&mut daemon);