diff --git a/README.md b/README.md
index 0a7bd7b9d..710040311 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,12 @@ Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex
This plugin is for Claude Code users who want an easy way to start using Codex from the workflow
they already have.
+> [!NOTE]
+> This repository is a fork of [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc).
+> It tracks upstream `main` and carries a set of community pull requests that are still open
+> upstream — mostly broker/background-job lifecycle fixes plus a few extra flags. See
+> [Differences From Upstream](#differences-from-upstream) for the full list.
+
## What You Get
@@ -12,21 +18,43 @@ they already have.
- `/codex:review` for a normal read-only Codex review
- `/codex:adversarial-review` for a steerable challenge review
- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs
+- `/codex:setup` to check that Codex is installed and signed in, and to toggle the optional stop-time review gate
+
+### Commands At A Glance
+
+| Command | What it does | Main flags |
+| --- | --- | --- |
+| [`/codex:review`](#codexreview) | read-only Codex review of your current work | `--wait`, `--background`, `--base [`, `--scope `, `--model `, `--effort ` |
+| [`/codex:adversarial-review`](#codexadversarial-review) | steerable review that challenges the approach | same as `/codex:review`, plus free-form focus text |
+| [`/codex:rescue`](#codexrescue) | delegate investigation or a fix to Codex | `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--model`, `--effort`, `--write`, `--sandbox `, `--read-root ` |
+| [`/codex:transfer`](#codextransfer) | turn this Claude session into a resumable Codex thread | `--source ` |
+| [`/codex:status`](#codexstatus) | show active and recent Codex jobs | `[job-id]`, `--wait`, `--timeout-ms `, `--all` |
+| [`/codex:result`](#codexresult) | show the stored output of a finished job | `[job-id]` |
+| [`/codex:cancel`](#codexcancel) | cancel an active background job | `[job-id]` |
+| [`/codex:setup`](#codexsetup) | check the Codex install, manage the review gate | `--enable-review-gate`, `--disable-review-gate` |
+
+Accepted `--effort` values are `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. An
+unrecognised `--flag` is not silently swallowed into the prompt: the plugin warns on stderr and
+passes the token through as text.
## Requirements
- **ChatGPT subscription (incl. Free) or OpenAI API key.**
- Usage will contribute to your Codex usage limits. [Learn more](https://developers.openai.com/codex/pricing).
-- **Node.js 18.18 or later**
+- **Node.js 18.18 or later.**
+ - It does not have to be on the system PATH: the hooks resolve Node through `scripts/run-node.sh`, which also looks in nvm, fnm, asdf, mise, Volta and Homebrew toolchains, preferring one that ships `codex` alongside it. Set `CODEX_COMPANION_NODE` to an executable path to pin a specific one.
## Install
Add the marketplace in Claude Code:
```bash
-/plugin marketplace add openai/codex-plugin-cc
+/plugin marketplace add Edo771977/codex-plugin-cc
```
+(Use `openai/codex-plugin-cc` instead if you want upstream without the imported fixes. The
+marketplace name is `openai-codex` either way, so only one of the two can be added at a time.)
+
Install the plugin:
```bash
@@ -86,7 +114,7 @@ Use it when you want:
- a review of your current uncommitted changes
- a review of your branch compared to a base branch like `main`
-Use `--base ][` for branch review. It also supports `--wait` and `--background`. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area.
+Use `--base ][` for branch review. It also supports `--wait`, `--background`, `--scope `, and — like `/codex:rescue` — `--model ` and `--effort ` to pick the reviewing model and how hard it thinks. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area.
Examples:
@@ -94,6 +122,7 @@ Examples:
/codex:review
/codex:review --base main
/codex:review --background
+/codex:review --base main --model gpt-5.4-mini --effort high
```
This command is read-only and will not perform any changes. When run in the background you can use [`/codex:status`](#codexstatus) to check on the progress and [`/codex:cancel`](#codexcancel) to cancel the ongoing task.
@@ -105,7 +134,7 @@ Runs a **steerable** review that questions the chosen implementation and design.
It can be used to pressure-test assumptions, tradeoffs, failure modes, and whether a different approach would have been safer or simpler.
It uses the same review target selection as `/codex:review`, including `--base ][` for branch review.
-It also supports `--wait` and `--background`. Unlike `/codex:review`, it can take extra focus text after the flags.
+It also supports `--wait`, `--background`, `--model `, and `--effort `. Unlike `/codex:review`, it can take extra focus text after the flags.
Use it when you want:
@@ -137,7 +166,7 @@ Use it when you want Codex to:
> [!NOTE]
> Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background.
-It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo.
+It supports `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--model `, `--effort `, `--write`, `--sandbox `, and repeatable `--read-root `. If you omit the resume flags, the plugin can offer to continue the latest rescue thread for this repo.
Examples:
@@ -145,9 +174,13 @@ Examples:
/codex:rescue investigate why the tests started failing
/codex:rescue fix the failing test with the smallest safe patch
/codex:rescue --resume apply the top fix from the last run
+/codex:rescue --resume-thread thr_0199... keep working on that specific thread
/codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test
/codex:rescue --model spark fix the issue quickly
/codex:rescue --background investigate the regression
+/codex:rescue --sandbox read-only explain how the cache invalidation works
+/codex:rescue --sandbox danger-full-access run the integration tests and fix what fails
+/codex:rescue --read-root ./src --read-root ./tests inspect only the approved paths
```
You can also just ask for a task to be delegated to Codex:
@@ -161,6 +194,13 @@ Ask Codex to redesign the database connection to be more resilient.
- if you do not pass `--model` or `--effort`, Codex chooses its own defaults.
- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark`
- follow-up rescue requests can continue the latest Codex task in the repo
+- `--resume`/`--resume-last` continues the newest thread for this repository; `--resume-thread ` continues one specific thread (the id is printed by `/codex:status` and `/codex:result`). `--resume`, `--resume-thread`, and `--fresh` are mutually exclusive.
+- `--sandbox` applies to `/codex:rescue` only; the review commands stay read-only. It takes precedence over `--write` and counts only before the task text. Rescue runs edit files inside the repository by default (`workspace-write`); `read-only` blocks edits, and `danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks.
+- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session (Codex CLI 0.153.2 applies a new mode only when it loads the thread again from disk). `task` refuses a resume whose sandbox differs from what the app-server reports; resume with the same `--sandbox`, or start a new thread with `--fresh`.
+- each `--read-root ` must name an existing directory and opts into an OS-enforced permission profile that denies local command reads outside the listed directories and Codex's minimal runtime paths
+- a scoped `--write` (or `--sandbox workspace-write`) requires the approved read roots to cover the workspace directory and uses Codex's built-in `:workspace` write policy; `--read-root` is rejected together with `--sandbox danger-full-access`
+- scoped reads require Codex 0.138.0 or later and fail closed when the runtime cannot enforce permission profiles
+- filesystem profiles apply to local sandboxed commands, not web search, MCP servers, connectors, browser tools, or computer use
### `/codex:transfer`
@@ -175,7 +215,7 @@ Examples:
/codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl
```
-The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command.
+The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must live under a Claude projects root — `~/.claude/projects`, or `$CLAUDE_CONFIG_DIR/projects` when you have relocated your Claude config — and older Codex versions that do not expose session import must be upgraded before using this command.
### `/codex:status`
@@ -233,6 +273,8 @@ You can also use `/codex:setup` to manage the optional review gate.
When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first.
+On the continuation turn that follows a block, the gate does not review again: Claude Code re-invokes the hook with `stop_hook_active`, and re-running the review there would just block again until the harness's retry cap ends the turn. The skip is reported as a system message, so run `/codex:review --wait` yourself when you want the fixes verified.
+
> [!WARNING]
> The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session.
@@ -309,6 +351,39 @@ Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also
This way you can review the Codex work or continue the work there.
+## Differences From Upstream
+
+This fork is [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) `main` plus a
+set of community pull requests that are still open upstream. Each one is a separate merge commit,
+so any of them can be reverted on its own.
+
+Broker and background-job lifecycle:
+
+| Upstream PR | What it fixes |
+| --- | --- |
+| [#541](https://github.com/openai/codex-plugin-cc/pull/541) | broker leaks, state races, and signal-masked command failures in the test runtime |
+| [#623](https://github.com/openai/codex-plugin-cc/pull/623) | session end no longer tears down the shared broker while another session's jobs are still using it |
+| [#652](https://github.com/openai/codex-plugin-cc/pull/652) | bounds the lifetime of detached brokers and task workers (see [Background Runtime Limits](#background-runtime-limits)) |
+
+Commands and flags:
+
+| Upstream PR | What it adds |
+| --- | --- |
+| [#565](https://github.com/openai/codex-plugin-cc/pull/565) | the stop-review gate honors `stop_hook_active` instead of re-blocking a continuation turn |
+| [#724](https://github.com/openai/codex-plugin-cc/pull/724) | `--read-root ` for OS-enforced scoped reads |
+| [#727](https://github.com/openai/codex-plugin-cc/pull/727) | `--resume-thread ` to continue one specific Codex thread |
+| [#729](https://github.com/openai/codex-plugin-cc/pull/729) | `/codex:transfer` works with a relocated `CLAUDE_CONFIG_DIR` |
+| [#742](https://github.com/openai/codex-plugin-cc/pull/742) | `--sandbox ` on `task` and `/codex:rescue` |
+| [#746](https://github.com/openai/codex-plugin-cc/pull/746) | `--model`/`--effort` on the review commands, and a warning for unrecognised options |
+| [#748](https://github.com/openai/codex-plugin-cc/pull/748) | `CLAUDE_ENV_FILE` keeps one export per key instead of growing on every session |
+| [#731](https://github.com/openai/codex-plugin-cc/pull/731) | the review-gate flag is persisted outside the transient state dir, so a different `CLAUDE_PLUGIN_DATA` no longer silently disables it |
+| [#737](https://github.com/openai/codex-plugin-cc/pull/737) | hooks resolve Node through `scripts/run-node.sh`, so nvm/fnm/asdf/mise/Volta/Homebrew toolchains work under the minimal hook PATH |
+| [#747](https://github.com/openai/codex-plugin-cc/pull/747) | `runCommand` sets an explicit 256 MiB `maxBuffer`, so a large `git diff` is no longer truncated at Node's 1 MiB default |
+| [#763](https://github.com/openai/codex-plugin-cc/pull/763) | a turn that fails without throwing stores its error text, so `/codex:result` says why it failed |
+
+Where two of these PRs disagreed, the merge commit says which side won and why. The plugin version
+is deliberately left at the upstream number: these merges do not cut a release.
+
## FAQ
### Do I need a separate Codex account for this plugin?
diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md
index d647561bb..7905d0fe6 100644
--- a/plugins/codex/CHANGELOG.md
+++ b/plugins/codex/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## Unreleased
+
+- Add opt-in `--read-root` enforcement for Codex rescue tasks using request-scoped permission profiles (openai/codex-plugin-cc#724).
+- Preserve scoped roots across foreground, background, and resumed tasks, with fail-closed runtime compatibility errors.
+- Require every `--read-root` to be an existing, non-empty directory so scoped tasks do not claim unsupported file-level isolation on macOS.
+- Use the built-in `:workspace` profile for scoped write tasks and require the approved read roots to cover the workspace.
+- Deny inherited system temp roots in scoped profiles so only approved directories and minimal runtime paths remain readable.
+- Reject `--read-root` together with `--sandbox danger-full-access`, which disables the sandbox entirely.
+- Treat a busy broker's shutdown refusal as a refusal rather than an identity rejection, so SessionEnd leaves the shared runtime to the sessions still using it instead of exiting with an error.
+- Set an explicit 256 MiB `maxBuffer` for spawned commands so a large `git diff` is no longer truncated at Node's 1 MiB default.
+- Persist the failure text of a turn that fails without throwing, and shorten job summaries to 96 characters.
+- Keep the review-gate flag in a durable per-workspace file under `CODEX_HOME`, written privately and atomically.
+- Resolve Node through `scripts/run-node.sh` in the hooks, preferring a user-managed toolchain over a system install.
+
## 1.0.0
- Initial version of the Codex plugin for Claude Code
diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md
index 7009ec86a..60fc71f30 100644
--- a/plugins/codex/agents/codex-rescue.md
+++ b/plugins/codex/agents/codex-rescue.md
@@ -31,11 +31,15 @@ Forwarding rules:
- If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`.
- If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`.
- Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through.
-- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
-- Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through.
+- If the user passes `--sandbox ` before the task text, forward it in that position and do not add `--write`; the sandbox already decides whether Codex may edit. A `--sandbox` inside the task text is part of the prompt: leave it there. Never add a `--sandbox` on your own.
+- Preserve every `--read-root ` pair as a runtime control and do not include either token in the task text you pass through.
+- When forwarding both scoped roots and a write-capable sandbox (`--write` or `--sandbox workspace-write`), include an approved read root that covers the workspace directory.
+- Otherwise default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
+- Treat `--resume`, `--resume-thread `, and `--fresh` as routing controls and do not include them in the task text you pass through.
- `--resume` means add `--resume-last`.
+- `--resume-thread ` means pass that exact routing pair through and do not add `--resume-last`.
- `--fresh` means do not add `--resume-last`.
-- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present.
+- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` or `--resume-thread ` is present.
- Otherwise forward the task as a fresh `task` run.
- Preserve the user's task text as-is apart from stripping routing flags.
- Return the stdout of the `codex-companion` command exactly as-is.
diff --git a/plugins/codex/commands/adversarial-review.md b/plugins/codex/commands/adversarial-review.md
index da440ab4d..262e64c1e 100644
--- a/plugins/codex/commands/adversarial-review.md
+++ b/plugins/codex/commands/adversarial-review.md
@@ -1,6 +1,6 @@
---
description: Run a Codex review that challenges the implementation approach and design choices
-argument-hint: '[--wait|--background] [--base ][] [--scope auto|working-tree|branch] [focus ...]'
+argument-hint: '[--wait|--background] [--base ][] [--scope auto|working-tree|branch] [--model ] [--effort ] [focus ...]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion
---
diff --git a/plugins/codex/commands/cancel.md b/plugins/codex/commands/cancel.md
index a1472b836..f596bfa2d 100644
--- a/plugins/codex/commands/cancel.md
+++ b/plugins/codex/commands/cancel.md
@@ -2,7 +2,7 @@
description: Cancel an active background Codex job in this repository
argument-hint: '[job-id]'
disable-model-invocation: true
-allowed-tools: Bash(node:*)
+allowed-tools: Bash(bash:*)
---
-!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel "$ARGUMENTS"`
+!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" cancel "$ARGUMENTS"`
diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md
index 56de9555d..dbc0f3892 100644
--- a/plugins/codex/commands/rescue.md
+++ b/plugins/codex/commands/rescue.md
@@ -1,6 +1,6 @@
---
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
-argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]"
+argument-hint: "[--background|--wait] [--resume|--resume-thread |--fresh] [--model ] [--effort ] [--sandbox ] [--read-root ...] [what Codex should investigate, solve, or continue]"
allowed-tools: Bash(node:*), AskUserQuestion, Agent
---
@@ -18,8 +18,12 @@ Execution mode:
- If neither flag is present, default to foreground.
- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text.
- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text.
+- `--sandbox` is a runtime-selection flag when it comes before the task text. Forward the request in the user's order and never add a `--sandbox` yourself; a `--sandbox` inside the task text is part of the prompt and stays there.
+- Preserve every `--read-root ` pair for the forwarded `task` call and remove both tokens from the natural-language task text.
+- When scoped roots are combined with a write-capable sandbox, the approved read roots must cover the workspace directory.
- If the request includes `--resume`, do not ask whether to continue. The user already chose.
- If the request includes `--fresh`, do not ask whether to continue. The user already chose.
+- If the request includes `--resume-thread `, do not ask whether to continue. The user already chose the exact Codex thread.
- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running:
```bash
@@ -44,6 +48,6 @@ Operating rules:
- Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own.
- Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort.
- Leave the model unset unless the user explicitly asks for one. If they ask for `spark`, map it to `gpt-5.3-codex-spark`.
-- Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command.
+- Leave `--resume`, `--resume-thread `, and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command.
- If the helper reports that Codex is missing or unauthenticated, stop and tell the user to run `/codex:setup`.
- If the user did not supply a request, ask what Codex should investigate or fix.
diff --git a/plugins/codex/commands/result.md b/plugins/codex/commands/result.md
index 3abc2d931..799923dfc 100644
--- a/plugins/codex/commands/result.md
+++ b/plugins/codex/commands/result.md
@@ -2,10 +2,10 @@
description: Show the stored final output for a finished Codex job in this repository
argument-hint: '[job-id]'
disable-model-invocation: true
-allowed-tools: Bash(node:*)
+allowed-tools: Bash(bash:*)
---
-!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result "$ARGUMENTS"`
+!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" result "$ARGUMENTS"`
Present the full command output to the user. Do not summarize or condense it. Preserve all details including:
- Job ID and status
diff --git a/plugins/codex/commands/review.md b/plugins/codex/commands/review.md
index fb70a4876..d53c406c7 100644
--- a/plugins/codex/commands/review.md
+++ b/plugins/codex/commands/review.md
@@ -1,6 +1,6 @@
---
description: Run a Codex code review against local git state
-argument-hint: '[--wait|--background] [--base ][] [--scope auto|working-tree|branch]'
+argument-hint: '[--wait|--background] [--base ][] [--scope auto|working-tree|branch] [--model ] [--effort ]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion
---
diff --git a/plugins/codex/commands/status.md b/plugins/codex/commands/status.md
index 8f70663d1..17536358a 100644
--- a/plugins/codex/commands/status.md
+++ b/plugins/codex/commands/status.md
@@ -2,10 +2,10 @@
description: Show active and recent Codex jobs for this repository, including review-gate status
argument-hint: '[job-id] [--wait] [--timeout-ms ] [--all]'
disable-model-invocation: true
-allowed-tools: Bash(node:*)
+allowed-tools: Bash(bash:*)
---
-!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status "$ARGUMENTS"`
+!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" status "$ARGUMENTS"`
If the user did not pass a job ID:
- Render the command output as a single Markdown table for the current and past runs in this session.
diff --git a/plugins/codex/commands/transfer.md b/plugins/codex/commands/transfer.md
index 42170e51d..027f9b78c 100644
--- a/plugins/codex/commands/transfer.md
+++ b/plugins/codex/commands/transfer.md
@@ -2,9 +2,9 @@
description: Transfer the current Claude Code session into a resumable Codex thread
argument-hint: "[--source ]"
disable-model-invocation: true
-allowed-tools: Bash(node:*)
+allowed-tools: Bash(bash:*)
---
-!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transfer "$ARGUMENTS"`
+!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" transfer "$ARGUMENTS"`
Present the command output to the user exactly as returned. Preserve the Codex session ID and the `codex resume ` command.
diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json
index 1917ac986..425cc13ec 100644
--- a/plugins/codex/hooks/hooks.json
+++ b/plugins/codex/hooks/hooks.json
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionStart",
+ "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"session-lifecycle-hook.mjs\" SessionStart",
"timeout": 5
}
]
@@ -17,7 +17,7 @@
"hooks": [
{
"type": "command",
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionEnd",
+ "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"session-lifecycle-hook.mjs\" SessionEnd",
"timeout": 30
}
]
@@ -28,7 +28,7 @@
"hooks": [
{
"type": "command",
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs\"",
+ "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"stop-review-gate-hook.mjs\"",
"timeout": 900
}
]
diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs
index 3042cc696..d54b1855d 100644
--- a/plugins/codex/scripts/codex-companion.mjs
+++ b/plugins/codex/scripts/codex-companion.mjs
@@ -21,7 +21,7 @@ import {
runAppServerReview,
runAppServerTurn
} from "./lib/codex.mjs";
-import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
+import { prepareClaudeSessionImport, resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import { armTimeout, disarmTimeout, workerTtlMs } from "./lib/lifecycle-limits.mjs";
@@ -76,6 +76,7 @@ const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
const CANCEL_TURN_INTERRUPT_TIMEOUT_MS = 5000;
const CANCEL_TURN_IDENTITY_WAIT_MS = 3000;
const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
+const VALID_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]);
const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";
@@ -85,8 +86,8 @@ function printUsage() {
"Usage:",
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]",
" node scripts/codex-companion.mjs review [--wait|--background] [--base ][] [--scope ]",
- " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ][] [--scope ] [focus text]",
- " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]",
+ " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ][] [--scope ] [--model ] [--effort ] [focus text]",
+ " node scripts/codex-companion.mjs task [--background] [--write] [--sandbox ] [--read-root ...] [--resume-last|--resume|--resume-thread |--fresh] [--model ] [--effort ] [prompt]",
" node scripts/codex-companion.mjs transfer [--source ] [--json]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
@@ -134,6 +135,26 @@ function normalizeReasoningEffort(effort) {
return normalized;
}
+function normalizeSandboxMode(sandbox) {
+ if (sandbox === undefined) {
+ return null;
+ }
+ const normalized = String(sandbox).trim().toLowerCase();
+ if (!normalized) {
+ throw new Error("Missing value for --sandbox. Use one of: read-only, workspace-write, danger-full-access.");
+ }
+ if (!VALID_SANDBOX_MODES.has(normalized)) {
+ throw new Error(
+ `Unsupported sandbox mode "${sandbox}". Use one of: read-only, workspace-write, danger-full-access.`
+ );
+ }
+ return normalized;
+}
+
+function defaultTaskSandbox(write) {
+ return write ? "workspace-write" : "read-only";
+}
+
function normalizeArgv(argv) {
if (argv.length === 1) {
const [raw] = argv;
@@ -146,13 +167,25 @@ function normalizeArgv(argv) {
}
function parseCommandInput(argv, config = {}) {
- return parseArgs(normalizeArgv(argv), {
+ const parsed = parseArgs(normalizeArgv(argv), {
...config,
aliasMap: {
C: "cwd",
...(config.aliasMap ?? {})
}
});
+
+ // An unrecognised long option is still treated as a positional, because some
+ // commands take free-form text. Say so on stderr rather than swallowing it:
+ // a mistyped or unsupported flag would otherwise be silently folded into a
+ // prompt, and the run would look like it did what was asked.
+ for (const token of parsed.unknownOptions ?? []) {
+ console.warn(
+ `Warning: unrecognised option ${token}; treating it as text. It will be passed through verbatim, not interpreted as a flag.`
+ );
+ }
+
+ return parsed;
}
function resolveCommandCwd(options = {}) {
@@ -163,6 +196,22 @@ function resolveCommandWorkspace(options = {}) {
return resolveWorkspaceRoot(resolveCommandCwd(options));
}
+function resolveReadRoot(cwd, readRoot) {
+ if (typeof readRoot !== "string" || !readRoot.trim()) {
+ throw new Error("--read-root must name an existing directory: value is empty");
+ }
+ const resolved = path.resolve(cwd, readRoot);
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
+ throw new Error(`--read-root must name an existing directory: ${readRoot}`);
+ }
+ return fs.realpathSync(resolved);
+}
+
+function pathCovers(parent, child) {
+ const relative = path.relative(parent, child);
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
+}
+
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -406,7 +455,8 @@ async function executeReviewRun(request) {
turnId: result.turnId,
payload,
rendered,
- summary: firstMeaningfulLine(result.reviewText, `${reviewName} completed.`),
+ summary: shorten(firstMeaningfulLine(result.reviewText, `${reviewName} completed.`), 96),
+ errorMessage: result.error?.message ?? result.stderr ?? null,
jobTitle: `Codex ${reviewName}`,
jobClass: "review",
targetLabel: target.label
@@ -418,6 +468,7 @@ async function executeReviewRun(request) {
const result = await runAppServerTurn(context.repoRoot, {
prompt,
model: request.model,
+ effort: request.effort,
sandbox: "read-only",
outputSchema: readOutputSchema(REVIEW_SCHEMA),
onProgress: request.onProgress
@@ -426,6 +477,7 @@ async function executeReviewRun(request) {
status: result.status,
failureMessage: result.error?.message ?? result.stderr
});
+ const failureMessage = result.error?.message ?? result.stderr ?? parsed.parseError ?? "";
const payload = {
review: reviewName,
target,
@@ -457,7 +509,8 @@ async function executeReviewRun(request) {
targetLabel: context.target.label,
reasoningSummary: result.reasoningSummary
}),
- summary: parsed.parsed?.summary ?? parsed.parseError ?? firstMeaningfulLine(result.finalMessage, `${reviewName} finished.`),
+ summary: parsed.parsed?.summary ?? shorten(firstMeaningfulLine(failureMessage || result.finalMessage, `${reviewName} finished.`), 96),
+ errorMessage: failureMessage || null,
jobTitle: `Codex ${reviewName}`,
jobClass: "review",
targetLabel: context.target.label
@@ -471,11 +524,13 @@ async function executeTaskRun(request) {
const taskMetadata = buildTaskRunMetadata({
prompt: request.prompt,
- resumeLast: request.resumeLast
+ resumeLast: request.resumeLast || Boolean(request.resumeThread)
});
let resumeThreadId = null;
- if (request.resumeLast) {
+ if (request.resumeThread) {
+ resumeThreadId = request.resumeThread;
+ } else if (request.resumeLast) {
const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, {
excludeJobId: request.jobId
});
@@ -486,7 +541,7 @@ async function executeTaskRun(request) {
}
if (!request.prompt && !resumeThreadId) {
- throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
+ throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last / --resume-thread .");
}
const result = await runAppServerTurn(workspaceRoot, {
@@ -495,7 +550,9 @@ async function executeTaskRun(request) {
defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "",
model: request.model,
effort: request.effort,
- sandbox: request.write ? "workspace-write" : "read-only",
+ sandbox: request.sandbox ?? defaultTaskSandbox(Boolean(request.write)),
+ readRoots: request.readRoots,
+ write: request.write,
onProgress: request.onProgress,
persistThread: true,
threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT)
@@ -522,6 +579,10 @@ async function executeTaskRun(request) {
touchedFiles: result.touchedFiles,
reasoningSummary: result.reasoningSummary
};
+ const summary = shorten(
+ firstMeaningfulLine(failureMessage || rawOutput, `${taskMetadata.title} finished.`),
+ 96
+ );
return {
exitStatus: result.status,
@@ -529,7 +590,8 @@ async function executeTaskRun(request) {
turnId: result.turnId,
payload,
rendered,
- summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)),
+ summary,
+ errorMessage: failureMessage || null,
jobTitle: taskMetadata.title,
jobClass: "task",
write: Boolean(request.write)
@@ -608,14 +670,17 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) {
});
}
-function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) {
+function buildTaskRequest({ cwd, model, effort, prompt, write, sandbox, readRoots, resumeLast, resumeThread = null, jobId }) {
return {
cwd,
model,
effort,
prompt,
write,
+ sandbox,
+ readRoots,
resumeLast,
+ resumeThread,
jobId
};
}
@@ -633,7 +698,13 @@ async function executeTransfer(cwd, options = {}) {
const sourcePath = resolveClaudeSessionPath(cwd, {
source: options.source
});
- const result = await importExternalAgentSession(cwd, { sourcePath });
+ const prepared = prepareClaudeSessionImport(cwd, sourcePath);
+ let result;
+ try {
+ result = await importExternalAgentSession(cwd, { sourcePath: prepared.importPath });
+ } finally {
+ prepared.cleanup();
+ }
const payload = {
threadId: result.threadId,
resumeCommand: `codex resume ${result.threadId}`,
@@ -656,9 +727,9 @@ function readTaskPrompt(cwd, options, positionals) {
return positionalPrompt || readStdinIfPiped();
}
-function requireTaskRequest(prompt, resumeLast) {
- if (!prompt && !resumeLast) {
- throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
+function requireTaskRequest(prompt, resumeLast, resumeThread = null) {
+ if (!prompt && !resumeLast && !resumeThread) {
+ throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last / --resume-thread .");
}
}
@@ -751,7 +822,7 @@ function enqueueBackgroundTask(cwd, job, request) {
async function handleReviewCommand(argv, config) {
const { options, positionals } = parseCommandInput(argv, {
- valueOptions: ["base", "scope", "model", "cwd"],
+ valueOptions: ["base", "scope", "model", "effort", "cwd"],
booleanOptions: ["json", "background", "wait"],
aliasMap: {
m: "model"
@@ -760,6 +831,7 @@ async function handleReviewCommand(argv, config) {
const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
+ const effort = normalizeReasoningEffort(options.effort);
const focusText = positionals.join(" ").trim();
const target = resolveReviewTarget(cwd, {
base: options.base,
@@ -784,6 +856,7 @@ async function handleReviewCommand(argv, config) {
base: options.base,
scope: options.scope,
model: options.model,
+ effort,
focusText,
reviewName: config.reviewName,
onProgress: progress
@@ -801,8 +874,10 @@ async function handleReview(argv) {
async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
- valueOptions: ["model", "effort", "cwd", "prompt-file"],
+ valueOptions: ["model", "effort", "cwd", "prompt-file", "sandbox", "resume-thread"],
+ multiValueOptions: ["read-root"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
+ leadingOnlyOptions: ["sandbox"],
aliasMap: {
m: "model"
}
@@ -815,19 +890,36 @@ async function handleTask(argv) {
const prompt = readTaskPrompt(cwd, options, positionals);
const resumeLast = Boolean(options["resume-last"] || options.resume);
+ const resumeThreadOption = options["resume-thread"];
+ const resumeThread = resumeThreadOption === undefined ? null : String(resumeThreadOption).trim();
const fresh = Boolean(options.fresh);
- if (resumeLast && fresh) {
- throw new Error("Choose either --resume/--resume-last or --fresh.");
+ if (resumeThreadOption !== undefined && !resumeThread) {
+ throw new Error("--resume-thread requires a non-empty thread id.");
+ }
+ if (Number(resumeLast) + Number(Boolean(resumeThread)) + Number(fresh) > 1) {
+ throw new Error("Choose only one of --resume/--resume-last, --resume-thread , or --fresh.");
+ }
+ const sandbox = normalizeSandboxMode(options.sandbox) ?? defaultTaskSandbox(Boolean(options.write));
+ const write = sandbox !== "read-only";
+ const readRoots = (options["read-root"] ?? []).map((readRoot) => resolveReadRoot(cwd, readRoot));
+ if (readRoots.length > 0 && sandbox === "danger-full-access") {
+ throw new Error(
+ "--read-root cannot be combined with --sandbox danger-full-access: that mode disables the Codex sandbox, so no read scope is enforced."
+ );
+ }
+ if (write && readRoots.length > 0 && !readRoots.some((readRoot) => pathCovers(readRoot, workspaceRoot))) {
+ throw new Error(
+ "--write requires an approved --read-root that covers the workspace directory; the same applies to --sandbox workspace-write."
+ );
}
- const write = Boolean(options.write);
const taskMetadata = buildTaskRunMetadata({
prompt,
- resumeLast
+ resumeLast: resumeLast || Boolean(resumeThread)
});
if (options.background) {
ensureCodexAvailable(cwd);
- requireTaskRequest(prompt, resumeLast);
+ requireTaskRequest(prompt, resumeLast, resumeThread);
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
const request = buildTaskRequest({
@@ -836,7 +928,10 @@ async function handleTask(argv) {
effort,
prompt,
write,
+ sandbox,
+ readRoots,
resumeLast,
+ resumeThread,
jobId: job.id
});
const { payload } = enqueueBackgroundTask(cwd, job, request);
@@ -854,7 +949,10 @@ async function handleTask(argv) {
effort,
prompt,
write,
+ sandbox,
+ readRoots,
resumeLast,
+ resumeThread,
jobId: job.id,
onProgress: progress
}),
diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs
index 397de3fb0..d7a912f75 100644
--- a/plugins/codex/scripts/lib/app-server.mjs
+++ b/plugins/codex/scripts/lib/app-server.mjs
@@ -70,6 +70,12 @@ class AppServerClientBase {
this.notificationHandler = null;
this.lineBuffer = "";
this.transport = "unknown";
+ // Only the spawned transport owns a child process; the broker transport
+ // talks to one it does not own. Declaring it here keeps the shared cleanup
+ // paths honest about the union instead of reaching for a property half the
+ // clients never have.
+ /** @type {import("node:child_process").ChildProcess | null} */
+ this.proc = null;
this.exitPromise = new Promise((resolve) => {
this.resolveExit = resolve;
diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs
index 6b1518502..7955c2c88 100644
--- a/plugins/codex/scripts/lib/args.mjs
+++ b/plugins/codex/scripts/lib/args.mjs
@@ -1,9 +1,12 @@
export function parseArgs(argv, config = {}) {
const valueOptions = new Set(config.valueOptions ?? []);
+ const multiValueOptions = new Set(config.multiValueOptions ?? []);
const booleanOptions = new Set(config.booleanOptions ?? []);
const aliasMap = config.aliasMap ?? {};
+ const leadingOnlyOptions = new Set(config.leadingOnlyOptions ?? []);
const options = {};
const positionals = [];
+ const unknownOptions = [];
let passthrough = false;
for (let index = 0; index < argv.length; index += 1) {
@@ -25,26 +28,42 @@ export function parseArgs(argv, config = {}) {
}
if (token.startsWith("--")) {
- const [rawKey, inlineValue] = token.slice(2).split("=", 2);
+ const body = token.slice(2);
+ const separator = body.indexOf("=");
+ const rawKey = separator === -1 ? body : body.slice(0, separator);
+ const inlineValue = separator === -1 ? undefined : body.slice(separator + 1);
const key = aliasMap[rawKey] ?? rawKey;
+ if (leadingOnlyOptions.has(key) && positionals.length > 0) {
+ positionals.push(token);
+ continue;
+ }
+
if (booleanOptions.has(key)) {
- options[key] = inlineValue === undefined ? true : inlineValue !== "false";
+ if (inlineValue !== undefined && inlineValue !== "true" && inlineValue !== "false") {
+ throw new Error(`Invalid value for --${rawKey}: expected true or false`);
+ }
+ options[key] = inlineValue !== "false";
continue;
}
- if (valueOptions.has(key)) {
+ if (valueOptions.has(key) || multiValueOptions.has(key)) {
const nextValue = inlineValue ?? argv[index + 1];
if (nextValue === undefined) {
throw new Error(`Missing value for --${rawKey}`);
}
- options[key] = nextValue;
+ if (multiValueOptions.has(key)) {
+ options[key] = [...(options[key] ?? []), nextValue];
+ } else {
+ options[key] = nextValue;
+ }
if (inlineValue === undefined) {
index += 1;
}
continue;
}
+ unknownOptions.push(token);
positionals.push(token);
continue;
}
@@ -57,12 +76,16 @@ export function parseArgs(argv, config = {}) {
continue;
}
- if (valueOptions.has(key)) {
+ if (valueOptions.has(key) || multiValueOptions.has(key)) {
const nextValue = argv[index + 1];
if (nextValue === undefined) {
throw new Error(`Missing value for -${shortKey}`);
}
- options[key] = nextValue;
+ if (multiValueOptions.has(key)) {
+ options[key] = [...(options[key] ?? []), nextValue];
+ } else {
+ options[key] = nextValue;
+ }
index += 1;
continue;
}
@@ -70,7 +93,7 @@ export function parseArgs(argv, config = {}) {
positionals.push(token);
}
- return { options, positionals };
+ return { options, positionals, unknownOptions };
}
export function splitRawArgumentString(raw) {
diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs
index c75c5473f..397d552e9 100644
--- a/plugins/codex/scripts/lib/broker-lifecycle.mjs
+++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs
@@ -71,7 +71,7 @@ function probeEndpoint(endpoint, timeoutMs) {
// promise pending forever on a connection that never settles.
socket.setTimeout(Math.max(1, timeoutMs), () => finish("timeout"));
socket.on("connect", () => finish("connect"));
- socket.on("error", (error) => finish(error?.code ?? "error"));
+ socket.on("error", (/** @type {NodeJS.ErrnoException} */ error) => finish(error?.code ?? "error"));
});
}
@@ -442,7 +442,7 @@ export async function shutdownBrokerSession(cwd, options = {}) {
async function shutdownBrokerSessionLocked(cwd, options = {}) {
const session = loadBrokerSession(cwd);
if (!session) {
- return { found: false, exited: true, forced: false, reclaimedStaleEndpoint: false };
+ return { found: false, exited: true, forced: false, refused: false, reclaimedStaleEndpoint: false };
}
const pid = resolveBrokerPid(session);
@@ -469,7 +469,7 @@ async function shutdownBrokerSessionLocked(cwd, options = {}) {
if (legacySession) {
if (canDiscardUnownedSession(session, pid, livenessOptions)) {
teardownAndClear(cwd, session, false);
- return { found: true, exited: true, forced: false, reclaimedStaleEndpoint: false };
+ return { found: true, exited: true, forced: false, refused: false, reclaimedStaleEndpoint: false };
}
legacyProcessVerified = processMatchesLegacyBroker(session, pid, options);
const legacyEndpointIsSafelyStale =
@@ -486,6 +486,17 @@ async function shutdownBrokerSessionLocked(cwd, options = {}) {
instanceToken: session.instanceToken
});
}
+ // A busy refusal is not an identity rejection: the broker recognized us and
+ // declined because another connection is still admitted. That is the normal
+ // outcome when a client connects between the session-end guard check and
+ // this request, so it must not raise -- the hook's own pre-check treats a
+ // refusal as "leave the runtime for the survivors", and a throw here made
+ // SessionEnd exit(1) blaming the instance token instead. Leave the process
+ // and the persisted record alone; a later session end (or the broker's own
+ // idle shutdown) retires it.
+ if (shutdownResponse?.refused) {
+ return { found: true, exited: false, forced: false, refused: true, reclaimedStaleEndpoint: false };
+ }
if (shutdownResponse?.error) {
throw new Error(
`Codex app-server broker rejected shutdown identity; persisted state was preserved: ${
@@ -583,7 +594,7 @@ async function shutdownBrokerSessionLocked(cwd, options = {}) {
const endpointIsOurs = endpointProven || reclaimedStaleEndpoint;
teardownAndClear(cwd, session, endpointIsOurs);
- return { found: true, exited: true, forced, reclaimedStaleEndpoint };
+ return { found: true, exited: true, forced, refused: false, reclaimedStaleEndpoint };
}
export async function ensureBrokerSession(cwd, options = {}) {
diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs
index eea0aeba2..ae228a80c 100644
--- a/plugins/codex/scripts/lib/claude-session-transfer.mjs
+++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs
@@ -1,11 +1,12 @@
import fs from "node:fs";
+import { createHash, randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { ensureAbsolutePath } from "./fs.mjs";
export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH";
-const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
+const CLAUDE_CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR";
function resolveUserPath(cwd, value) {
if (value === "~") {
@@ -17,6 +18,180 @@ function resolveUserPath(cwd, value) {
return ensureAbsolutePath(cwd, value);
}
+function defaultClaudeProjectsDir() {
+ return path.join(os.homedir(), ".claude", "projects");
+}
+
+function configuredClaudeProjectsDir(cwd) {
+ const configured = process.env[CLAUDE_CONFIG_DIR_ENV];
+ return configured ? path.join(resolveUserPath(cwd, configured), "projects") : null;
+}
+
+function allowedClaudeProjectsDirs(cwd) {
+ return [...new Set([configuredClaudeProjectsDir(cwd), defaultClaudeProjectsDir()].filter(Boolean))];
+}
+
+function realpathIfExists(value) {
+ try {
+ return fs.realpathSync(value);
+ } catch {
+ return null;
+ }
+}
+
+function nearestExistingAncestor(value) {
+ let current = value;
+ while (!fs.existsSync(current)) {
+ const parent = path.dirname(current);
+ if (parent === current) return null;
+ current = parent;
+ }
+ return fs.realpathSync(current);
+}
+
+function fileSha256(filePath) {
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+}
+
+function filesHaveSameContent(source, candidate, sourceSha256) {
+ try {
+ if (fs.statSync(source).size !== fs.statSync(candidate).size) return false;
+ return fileSha256(candidate) === sourceSha256;
+ } catch {
+ return false;
+ }
+}
+
+const STAGING_MARKER_CONTENT = "codex-plugin-cc-staging-v1\n";
+const STAGING_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
+
+function processIsAlive(pid) {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return error?.code === "EPERM";
+ }
+}
+
+function staleLockCanBeRemoved(lockPath) {
+ const ownerPath = path.join(lockPath, "owner");
+ try {
+ const pid = Number(fs.readFileSync(ownerPath, "utf8"));
+ return !(Number.isSafeInteger(pid) && pid > 0 && processIsAlive(pid));
+ } catch {
+ try {
+ return Date.now() - fs.statSync(lockPath).mtimeMs > 1000;
+ } catch {
+ return true;
+ }
+ }
+}
+
+function copyToExclusiveStagingPath(source, preferredPath) {
+ const parsed = path.parse(preferredPath);
+ const sourceSha256 = fileSha256(source);
+ const stableFallback = path.join(parsed.dir, `${parsed.name}.codex-import-${sourceSha256}${parsed.ext}`);
+ for (const candidate of [preferredPath, stableFallback]) {
+ try {
+ fs.copyFileSync(source, candidate, fs.constants.COPYFILE_EXCL);
+ return { path: candidate, created: true, sourceSha256 };
+ } catch (error) {
+ if (error?.code !== "EEXIST") throw error;
+ if (filesHaveSameContent(source, candidate, sourceSha256)) {
+ return { path: candidate, created: false, sourceSha256 };
+ }
+ }
+ }
+ throw new Error(`Cannot allocate a stable staging path under ${parsed.dir}`);
+}
+
+function withStagingLock(stagedPath, callback) {
+ const lockPath = `${stagedPath}.codex-staging-lock`;
+ const ownerPath = path.join(lockPath, "owner");
+ const deadline = Date.now() + 5000;
+ while (true) {
+ try {
+ fs.mkdirSync(lockPath);
+ try {
+ fs.writeFileSync(ownerPath, `${process.pid}\n`, { flag: "wx" });
+ } catch (error) {
+ fs.rmSync(lockPath, { recursive: true, force: true });
+ throw error;
+ }
+ break;
+ } catch (error) {
+ if (error?.code !== "EEXIST") throw error;
+ if (staleLockCanBeRemoved(lockPath)) {
+ try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch {}
+ continue;
+ }
+ if (Date.now() >= deadline) throw new Error(`Timed out acquiring staging lock for ${stagedPath}`);
+ Atomics.wait(STAGING_LOCK_WAIT, 0, 0, 5);
+ }
+ }
+ try { return callback(); } finally { fs.rmSync(lockPath, { recursive: true, force: true }); }
+}
+
+function managedMarkerMatches(markerPath) {
+ try { return fs.readFileSync(markerPath, "utf8") === STAGING_MARKER_CONTENT; } catch { return false; }
+}
+
+function acquireStagingLease(stagedPath, staged) {
+ const directory = path.dirname(stagedPath);
+ const base = path.basename(stagedPath);
+ const markerPath = `${stagedPath}.codex-staging-managed`;
+ const leasePrefix = `${base}.codex-staging-lease-`;
+ const leasePath = path.join(directory, `${leasePrefix}${process.pid}-${randomUUID()}`);
+ let managed = false;
+
+ withStagingLock(stagedPath, () => {
+ const markerMatches = managedMarkerMatches(markerPath);
+ if (staged.created) {
+ if (fs.existsSync(markerPath) && !markerMatches) {
+ throw new Error(`Refusing to manage unknown staging marker: ${markerPath}`);
+ }
+ if (!markerMatches) fs.writeFileSync(markerPath, STAGING_MARKER_CONTENT, { flag: "wx" });
+ managed = true;
+ } else {
+ managed = markerMatches;
+ }
+ if (managed) fs.writeFileSync(leasePath, "", { flag: "wx" });
+ });
+
+ return {
+ release() {
+ if (!managed) return;
+ withStagingLock(stagedPath, () => {
+ try { fs.unlinkSync(leasePath); } catch (error) { if (error?.code !== "ENOENT") throw error; }
+ const activeLeases = fs.readdirSync(directory).filter((name) => name.startsWith(leasePrefix));
+ if (activeLeases.length > 0 || !managedMarkerMatches(markerPath)) return;
+ if (fs.existsSync(stagedPath) && fileSha256(stagedPath) !== staged.sourceSha256) {
+ fs.unlinkSync(markerPath);
+ return;
+ }
+ try { fs.unlinkSync(stagedPath); } catch (error) { if (error?.code !== "ENOENT") throw error; }
+ try { fs.unlinkSync(markerPath); } catch (error) { if (error?.code !== "ENOENT") throw error; }
+ });
+ }
+ };
+}
+
+function isWithin(root, candidate) {
+ const relative = path.relative(root, candidate);
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
+}
+
+function findContainingProjectsRoot(cwd, source) {
+ for (const configuredPath of allowedClaudeProjectsDirs(cwd)) {
+ const realRoot = realpathIfExists(configuredPath);
+ if (realRoot && isWithin(realRoot, source)) {
+ return { configuredPath, realRoot };
+ }
+ }
+ return null;
+}
+
export function resolveClaudeSessionPath(cwd, options = {}) {
const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV];
if (!requestedPath) {
@@ -29,16 +204,63 @@ export function resolveClaudeSessionPath(cwd, options = {}) {
}
let source;
- let projects;
try {
source = fs.realpathSync(sourcePath);
- projects = fs.realpathSync(CLAUDE_PROJECTS_DIR);
} catch {
throw new Error(`Claude session file not found: ${sourcePath}`);
}
- const relative = path.relative(projects, source);
- if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
- throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`);
+
+ if (!findContainingProjectsRoot(cwd, source)) {
+ throw new Error(
+ `Codex can import Claude sessions only from ${allowedClaudeProjectsDirs(cwd).join(" or ")}: ${source}`
+ );
}
return source;
}
+
+export function prepareClaudeSessionImport(cwd, sourcePath) {
+ const source = fs.realpathSync(sourcePath);
+ const defaultProjects = defaultClaudeProjectsDir();
+ const defaultRoot = realpathIfExists(defaultProjects);
+ if (defaultRoot && isWithin(defaultRoot, source)) {
+ return { sourcePath: source, importPath: source, staged: false, cleanup() {} };
+ }
+
+ const sourceRoot = findContainingProjectsRoot(cwd, source);
+ if (!sourceRoot) {
+ throw new Error(`Claude session is outside the configured projects roots: ${source}`);
+ }
+
+ const relative = path.relative(sourceRoot.realRoot, source);
+ const importPath = path.join(defaultProjects, relative);
+ fs.mkdirSync(defaultProjects, { recursive: true });
+
+ const canonicalDefaultRoot = fs.realpathSync(defaultProjects);
+ const importParent = path.dirname(importPath);
+ const canonicalExistingAncestor = nearestExistingAncestor(importParent);
+ if (!canonicalExistingAncestor || (canonicalExistingAncestor !== canonicalDefaultRoot && !isWithin(canonicalDefaultRoot, canonicalExistingAncestor))) {
+ throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalExistingAncestor ?? importParent}`);
+ }
+
+ fs.mkdirSync(importParent, { recursive: true });
+ const canonicalImportParent = fs.realpathSync(importParent);
+ if (canonicalImportParent !== canonicalDefaultRoot && !isWithin(canonicalDefaultRoot, canonicalImportParent)) {
+ throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportParent}`);
+ }
+
+ const staged = copyToExclusiveStagingPath(source, importPath);
+ const canonicalImportPath = fs.realpathSync(staged.path);
+ if (!isWithin(canonicalDefaultRoot, canonicalImportPath)) {
+ if (staged.created) fs.unlinkSync(canonicalImportPath);
+ throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportPath}`);
+ }
+ const lease = acquireStagingLease(canonicalImportPath, staged);
+ return {
+ sourcePath: source,
+ importPath: canonicalImportPath,
+ staged: true,
+ cleanup() {
+ lease.release();
+ }
+ };
+}
diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs
index 2c4dc43e1..c60536369 100644
--- a/plugins/codex/scripts/lib/codex.mjs
+++ b/plugins/codex/scripts/lib/codex.mjs
@@ -50,6 +50,51 @@ const DEFAULT_CONTINUE_PROMPT =
"Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.";
const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed";
const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
+const SCOPED_PERMISSION_PROFILE = "claude_companion_scoped";
+
+function buildThreadAccessParams(cwd, options = {}) {
+ const readRoots = Array.isArray(options.readRoots) ? options.readRoots : [];
+ if (readRoots.length === 0) {
+ return { sandbox: options.sandbox ?? "read-only" };
+ }
+
+ const filesystem = {
+ ":root": "deny",
+ ":minimal": "read",
+ ":tmpdir": "deny",
+ ":slash_tmp": "deny"
+ };
+ for (const readRoot of readRoots) {
+ filesystem[path.resolve(cwd, readRoot)] = "read";
+ }
+
+ return {
+ config: {
+ default_permissions: SCOPED_PERMISSION_PROFILE,
+ permissions: {
+ [SCOPED_PERMISSION_PROFILE]: {
+ description: "Claude companion request-scoped filesystem access",
+ ...(options.write ? { extends: ":workspace" } : {}),
+ filesystem
+ }
+ }
+ }
+ };
+}
+
+function scopedAccessError(error, options = {}) {
+ const message = String(error?.message ?? error ?? "");
+ if (
+ (options.readRoots?.length ?? 0) > 0 &&
+ /default_permissions|permission profiles?|unknown field.*config|invalid.*permissions/i.test(message)
+ ) {
+ return new Error(
+ `Codex cannot enforce the requested read scope. Upgrade to a runtime with permission profiles (0.138.0 or later). Original error: ${message}`,
+ { cause: error }
+ );
+ }
+ return error;
+}
function cleanCodexStderr(stderr) {
return stderr
@@ -59,13 +104,50 @@ function cleanCodexStderr(stderr) {
.join("\n");
}
+const SANDBOX_POLICY_TYPES = new Map([
+ ["read-only", "readOnly"],
+ ["workspace-write", "workspaceWrite"],
+ ["danger-full-access", "dangerFullAccess"]
+]);
+
+function sandboxModeForPolicy(policy) {
+ const type = policy && typeof policy === "object" ? policy.type : null;
+ for (const [mode, policyType] of SANDBOX_POLICY_TYPES) {
+ if (policyType === type) {
+ return mode;
+ }
+ }
+ return null;
+}
+
+function assertResumedSandbox(threadId, requestedMode, response) {
+ if (!requestedMode || !SANDBOX_POLICY_TYPES.has(requestedMode)) {
+ return;
+ }
+ const effectiveMode = sandboxModeForPolicy(response?.sandbox);
+ if (effectiveMode === requestedMode) {
+ return;
+ }
+ if (effectiveMode) {
+ throw new Error(
+ `Thread ${threadId} still has sandbox ${effectiveMode} in the shared app-server, so this turn would not run ${requestedMode}. ` +
+ `Resume with --sandbox ${effectiveMode}, or start a fresh thread with --fresh.`
+ );
+ }
+ const reported = typeof response?.sandbox?.type === "string" ? response.sandbox.type : "none";
+ throw new Error(
+ `Thread ${threadId} reports a sandbox policy (${reported}) this plugin cannot compare with the requested ${requestedMode}. ` +
+ "Start a fresh thread with --fresh."
+ );
+}
+
/** @returns {ThreadStartParams} */
function buildThreadParams(cwd, options = {}) {
return {
cwd,
model: options.model ?? null,
approvalPolicy: options.approvalPolicy ?? "never",
- sandbox: options.sandbox ?? "read-only",
+ ...buildThreadAccessParams(cwd, options),
serviceName: SERVICE_NAME,
ephemeral: options.ephemeral ?? true
};
@@ -78,7 +160,7 @@ function buildResumeParams(threadId, cwd, options = {}) {
cwd,
model: options.model ?? null,
approvalPolicy: options.approvalPolicy ?? "never",
- sandbox: options.sandbox ?? "read-only"
+ ...buildThreadAccessParams(cwd, options)
};
}
@@ -536,7 +618,7 @@ function applyTurnNotification(state, message) {
break;
case "error":
state.error = message.params.error;
- emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed");
+ emitProgress(state.onProgress, `Codex error: ${shorten(message.params.error.message, 96)}`, "failed");
break;
case "turn/completed":
if ((message.params.threadId ?? null) !== state.threadId) {
@@ -1243,23 +1325,32 @@ export async function runAppServerTurn(cwd, options = {}) {
return withAppServer(cwd, async (client) => {
let threadId;
- if (options.resumeThreadId) {
- emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
- const response = await resumeThread(client, options.resumeThreadId, cwd, {
- model: options.model,
- sandbox: options.sandbox,
- ephemeral: false
- });
- threadId = response.thread.id;
- } else {
- emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
- const response = await startThread(client, cwd, {
- model: options.model,
- sandbox: options.sandbox,
- ephemeral: options.persistThread ? false : true,
- threadName: options.persistThread ? options.threadName : options.threadName ?? null
- });
- threadId = response.thread.id;
+ try {
+ if (options.resumeThreadId) {
+ emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
+ const response = await resumeThread(client, options.resumeThreadId, cwd, {
+ model: options.model,
+ sandbox: options.sandbox,
+ readRoots: options.readRoots,
+ write: options.write,
+ ephemeral: false
+ });
+ assertResumedSandbox(options.resumeThreadId, options.sandbox, response);
+ threadId = response.thread.id;
+ } else {
+ emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
+ const response = await startThread(client, cwd, {
+ model: options.model,
+ sandbox: options.sandbox,
+ readRoots: options.readRoots,
+ write: options.write,
+ ephemeral: options.persistThread ? false : true,
+ threadName: options.persistThread ? options.threadName : options.threadName ?? null
+ });
+ threadId = response.thread.id;
+ }
+ } catch (error) {
+ throw scopedAccessError(error, options);
}
emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", {
diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs
index bc88a41cf..1f42896aa 100644
--- a/plugins/codex/scripts/lib/process.mjs
+++ b/plugins/codex/scripts/lib/process.mjs
@@ -2,13 +2,16 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import process from "node:process";
+// Git metadata can exceed Node's 1 MiB spawnSync default. Keep a generous explicit bound.
+const DEFAULT_MAX_BUFFER = 256 * 1024 * 1024;
+
export function runCommand(command, args = [], options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: "utf8",
input: options.input,
- maxBuffer: options.maxBuffer,
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
timeout: options.timeout,
killSignal: options.killSignal,
stdio: options.stdio ?? "pipe",
@@ -397,6 +400,10 @@ export function processHasLaunchToken(pid, token, options = {}) {
* Only meaningful for a group leader; a caller that is not one signals nothing, which is the
* existing behaviour of `terminateProcessTree`.
*/
+/**
+ * @param {number} pid
+ * @param {{ graceMs?: number, exitCode?: number, beforeKill?: () => void }} [options]
+ */
export function terminateProcessTreeAndExit(pid, { graceMs = 5000, exitCode = 1, beforeKill } = {}) {
if (pid === process.pid) {
process.on("SIGTERM", () => {});
diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs
index 173685677..c3469432b 100644
--- a/plugins/codex/scripts/lib/state.mjs
+++ b/plugins/codex/scripts/lib/state.mjs
@@ -13,6 +13,8 @@ import { resolveWorkspaceRoot } from "./workspace.mjs";
const STATE_VERSION = 1;
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
+const CODEX_HOME_ENV = "CODEX_HOME";
+const CONFIG_DIR_NAME = path.join("plugin-cc", "config");
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
const STATE_FILE_NAME = "state.json";
const JOBS_DIR_NAME = "jobs";
@@ -33,7 +35,7 @@ function defaultState() {
};
}
-export function resolveStateDir(cwd) {
+function resolveWorkspaceKey(cwd) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
let canonicalWorkspaceRoot = workspaceRoot;
try {
@@ -41,13 +43,21 @@ export function resolveStateDir(cwd) {
} catch {
canonicalWorkspaceRoot = workspaceRoot;
}
-
const slugSource = path.basename(workspaceRoot) || "workspace";
const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
+ return `${slug}-${hash}`;
+}
+
+export function resolveStateDir(cwd) {
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
- return path.join(stateRoot, `${slug}-${hash}`);
+ return path.join(stateRoot, resolveWorkspaceKey(cwd));
+}
+
+export function resolveConfigFile(cwd) {
+ const codexHome = path.resolve(process.env[CODEX_HOME_ENV] || path.join(os.homedir(), ".codex"));
+ return path.join(codexHome, CONFIG_DIR_NAME, `${resolveWorkspaceKey(cwd)}.json`);
}
export function resolveStateFile(cwd) {
@@ -193,17 +203,40 @@ export function listJobs(cwd) {
return loadState(cwd).jobs;
}
+function readDurableConfig(cwd) {
+ const configFile = resolveConfigFile(cwd);
+ if (!fs.existsSync(configFile)) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(fs.readFileSync(configFile, "utf8"));
+ return { ...defaultState().config, ...(parsed ?? {}) };
+ } catch {
+ return null;
+ }
+}
+
+function writeDurableConfig(cwd, config) {
+ const configFile = resolveConfigFile(cwd);
+ // Every other artifact this module creates is private and written
+ // atomically; the durable config is no different, and a partially written
+ // file here would silently disable the review gate on the next read.
+ ensurePrivateDir(path.dirname(configFile));
+ const nextConfig = { ...defaultState().config, ...(config ?? {}) };
+ writeJsonFileAtomic(configFile, nextConfig);
+ return nextConfig;
+}
+
export function setConfig(cwd, key, value) {
- return updateState(cwd, (state) => {
- state.config = {
- ...state.config,
- [key]: value
- };
+ const nextConfig = writeDurableConfig(cwd, { ...getConfig(cwd), [key]: value });
+ updateState(cwd, (state) => {
+ state.config = { ...state.config, ...nextConfig };
});
+ return nextConfig;
}
export function getConfig(cwd) {
- return loadState(cwd).config;
+ return readDurableConfig(cwd) ?? loadState(cwd).config;
}
export function writeJobFile(cwd, jobId, payload) {
diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs
index 685fe0142..4f95084d4 100644
--- a/plugins/codex/scripts/lib/tracked-jobs.mjs
+++ b/plugins/codex/scripts/lib/tracked-jobs.mjs
@@ -366,6 +366,8 @@ export async function runTrackedJob(job, runner, options = {}) {
return execution;
}
const completionStatus = execution.exitStatus === 0 ? "completed" : "failed";
+ const errorMessage =
+ completionStatus === "failed" ? (execution.errorMessage ?? null) : null;
const completedAt = nowIso();
writeJobFile(job.workspaceRoot, job.id, {
...runningRecord,
@@ -375,6 +377,7 @@ export async function runTrackedJob(job, runner, options = {}) {
pid: null,
phase: completionStatus === "completed" ? "done" : "failed",
completedAt,
+ errorMessage,
result: execution.payload,
rendered: execution.rendered
});
@@ -386,6 +389,7 @@ export async function runTrackedJob(job, runner, options = {}) {
summary: execution.summary,
phase: completionStatus === "completed" ? "done" : "failed",
pid: null,
+ errorMessage,
completedAt
});
appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered);
diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh
new file mode 100644
index 000000000..3b1e59654
--- /dev/null
+++ b/plugins/codex/scripts/run-node.sh
@@ -0,0 +1,241 @@
+#!/bin/sh
+set -u
+
+target=${1:-}
+if [ -z "$target" ]; then
+ echo "Codex Companion Node launcher requires a script name." >&2
+ exit 64
+fi
+shift
+
+case "$0" in
+ */*) script_base=${0%/*} ;;
+ *) script_base=. ;;
+esac
+script_dir=$(CDPATH= cd -- "$script_base" && pwd)
+
+is_supported_node() {
+ "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 18 || (major === 18 && minor >= 18) ? 0 : 1)' >/dev/null 2>&1
+}
+
+windows_path_to_posix() {
+ value=$1
+ [ -n "$value" ] || return 1
+ if command -v cygpath >/dev/null 2>&1; then
+ cygpath -u "$value"
+ return
+ fi
+
+ normalized=
+ remaining=$value
+ while [ -n "$remaining" ]; do
+ char=${remaining%"${remaining#?}"}
+ remaining=${remaining#?}
+ case "$char" in
+ \\) normalized="${normalized}/" ;;
+ *) normalized="${normalized}${char}" ;;
+ esac
+ done
+ case "$normalized" in
+ [A-Za-z]:/*)
+ drive=${normalized%%:*}
+ rest=${normalized#?:}
+ printf '/%s%s\n' "$drive" "$rest"
+ ;;
+ *) printf '%s\n' "$normalized" ;;
+ esac
+}
+
+find_windows_node() {
+ root=
+ if [ -n "${NVM_SYMLINK:-}" ]; then
+ root=$(windows_path_to_posix "$NVM_SYMLINK") || root=
+ for candidate in "$root/node.exe" "$root/node"; do
+ [ -x "$candidate" ] || continue
+ is_supported_node "$candidate" || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+ fi
+ if [ -n "${VOLTA_HOME:-}" ]; then
+ root=$(windows_path_to_posix "$VOLTA_HOME") || root=
+ for candidate in "$root/bin/node.exe" "$root/bin/node"; do
+ [ -x "$candidate" ] || continue
+ is_supported_node "$candidate" || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+ fi
+ if [ -n "${LOCALAPPDATA:-}" ]; then
+ root=$(windows_path_to_posix "$LOCALAPPDATA") || root=
+ for candidate in "$root/Volta/bin/node.exe" "$root/Volta/bin/node"; do
+ [ -x "$candidate" ] || continue
+ is_supported_node "$candidate" || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+ fi
+ program_files=${PROGRAMFILES:-${PROGRAMW6432:-${ProgramFiles:-}}}
+ if [ -n "$program_files" ]; then
+ root=$(windows_path_to_posix "$program_files") || root=
+ for candidate in "$root/nodejs/node.exe" "$root/nodejs/node"; do
+ [ -x "$candidate" ] || continue
+ is_supported_node "$candidate" || continue
+ printf '%s\n' "$candidate"
+ return 0
+ done
+ fi
+ return 1
+}
+
+node_dir_has_codex() {
+ candidate=$1
+ case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac
+ [ -x "$candidate_dir/codex" ] || [ -f "$candidate_dir/codex.cmd" ] || [ -f "$candidate_dir/codex.exe" ]
+}
+
+find_managed_node() {
+ require_codex=$1
+ home=${HOME:-}
+ nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir=
+ fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir=
+ asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir=
+ mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir=
+ homebrew_prefix=$(windows_path_to_posix "${HOMEBREW_PREFIX:-}") || homebrew_prefix=
+ homebrew_node=
+ [ -n "$homebrew_prefix" ] && homebrew_node="$homebrew_prefix/bin/node"
+ # A toolchain the user manages wins over a system install: on any machine
+ # that has /usr/local/bin/node (Debian/Ubuntu packages, CI images, this
+ # plugin's own dev containers) the hardcoded paths would otherwise shadow
+ # every nvm/fnm/asdf/mise version, which is exactly what this launcher
+ # exists to avoid. HOMEBREW_PREFIX stays first because it is explicitly
+ # configured rather than guessed.
+ for candidate in \
+ "$homebrew_node" \
+ "$home/.volta/bin/node" \
+ "$nvm_dir"/versions/node/*/bin/node \
+ "$fnm_dir"/node-versions/*/installation/bin/node \
+ "$asdf_dir"/installs/nodejs/*/bin/node \
+ "$mise_dir"/installs/node/*/bin/node \
+ /home/linuxbrew/.linuxbrew/bin/node /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node; do
+ [ -x "$candidate" ] || continue
+ is_supported_node "$candidate" || continue
+ if [ "$require_codex" = "true" ]; then
+ node_dir_has_codex "$candidate" || continue
+ fi
+ printf '%s\n' "$candidate"
+ return 0
+ done
+ return 1
+}
+
+find_node() {
+ if [ -n "${CODEX_COMPANION_NODE:-}" ]; then
+ configured_node=$(windows_path_to_posix "$CODEX_COMPANION_NODE") || configured_node=$CODEX_COMPANION_NODE
+ if [ -x "$configured_node" ] && is_supported_node "$configured_node"; then
+ printf '%s\n' "$configured_node"
+ return 0
+ fi
+ fi
+
+ path_node=
+ if command -v node >/dev/null 2>&1; then
+ candidate=$(command -v node)
+ if is_supported_node "$candidate"; then
+ path_node=$candidate
+ if command -v codex >/dev/null 2>&1; then
+ printf '%s\n' "$path_node"
+ return 0
+ fi
+ fi
+ fi
+
+ if candidate=$(find_managed_node true); then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ if [ -n "$path_node" ]; then
+ printf '%s\n' "$path_node"
+ return 0
+ fi
+ if candidate=$(find_managed_node false); then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ find_windows_node && return 0
+ return 1
+}
+
+node_bin=$(find_node) || {
+ echo "Codex Companion requires Node.js >=18.18. Add a supported node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2
+ exit 127
+}
+
+add_supported_node_dir_to_path() {
+ candidate=$1
+ [ -x "$candidate" ] || return 0
+ is_supported_node "$candidate" || return 0
+ case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac
+ case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac
+}
+
+add_dir_to_path() {
+ candidate_dir=$1
+ [ -d "$candidate_dir" ] || return 0
+ case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac
+}
+
+add_npm_prefix_dirs_to_path() {
+ prefix=${NPM_CONFIG_PREFIX:-}
+ if [ -z "$prefix" ] && command -v npm >/dev/null 2>&1; then
+ prefix=$(npm prefix -g 2>/dev/null) || prefix=
+ fi
+ [ -n "$prefix" ] || return 0
+ prefix=$(windows_path_to_posix "$prefix") || return 0
+ add_dir_to_path "$prefix/bin"
+ add_dir_to_path "$prefix"
+}
+
+add_windows_node_dirs_to_path() {
+ root=
+ if [ -n "${NVM_SYMLINK:-}" ]; then
+ root=$(windows_path_to_posix "$NVM_SYMLINK") || root=
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/node.exe"
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/node"
+ fi
+ if [ -n "${VOLTA_HOME:-}" ]; then
+ root=$(windows_path_to_posix "$VOLTA_HOME") || root=
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/bin/node.exe"
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/bin/node"
+ fi
+ if [ -n "${LOCALAPPDATA:-}" ]; then
+ root=$(windows_path_to_posix "$LOCALAPPDATA") || root=
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/Volta/bin/node.exe"
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/Volta/bin/node"
+ fi
+ program_files=${PROGRAMFILES:-${PROGRAMW6432:-${ProgramFiles:-}}}
+ if [ -n "$program_files" ]; then
+ root=$(windows_path_to_posix "$program_files") || root=
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/nodejs/node.exe"
+ [ -n "$root" ] && add_supported_node_dir_to_path "$root/nodejs/node"
+ fi
+}
+
+case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac
+PATH="$node_dir${PATH:+:$PATH}"
+home=${HOME:-}
+nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir=
+fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir=
+asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir=
+mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir=
+homebrew_prefix=$(windows_path_to_posix "${HOMEBREW_PREFIX:-}") || homebrew_prefix=
+homebrew_node=
+[ -n "$homebrew_prefix" ] && homebrew_node="$homebrew_prefix/bin/node"
+for candidate in "$homebrew_node" /home/linuxbrew/.linuxbrew/bin/node /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do
+ add_supported_node_dir_to_path "$candidate"
+done
+add_windows_node_dirs_to_path
+add_npm_prefix_dirs_to_path
+export PATH
+
+exec "$node_bin" "$script_dir/$target" "$@"
diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs
index 9c29f2d57..4a3f0906d 100644
--- a/plugins/codex/scripts/session-lifecycle-hook.mjs
+++ b/plugins/codex/scripts/session-lifecycle-hook.mjs
@@ -75,11 +75,27 @@ function shellEscape(value) {
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
}
-function appendEnvVar(name, value) {
- if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
+function setEnv(name, value) {
+ const envFile = process.env.CLAUDE_ENV_FILE;
+ if (!envFile || value == null || value === "") {
return;
}
- fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");
+ const prefix = `export ${name}=`;
+ const line = `${prefix}${shellEscape(value)}`;
+
+ let content = "";
+ try {
+ content = fs.readFileSync(envFile, "utf8");
+ } catch (err) {
+ if (err.code !== "ENOENT") throw err;
+ }
+
+ const lines = content.split(/\r?\n/).filter((l) => l && !l.startsWith(prefix));
+ lines.push(line);
+
+ const tmp = `${envFile}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, lines.join("\n") + "\n", "utf8");
+ fs.renameSync(tmp, envFile);
}
// A pid-less active record has no liveness signal at all (current code
@@ -417,9 +433,9 @@ async function cleanupSessionJobs(cwd, sessionId, { interruptTurns = false, inte
}
function handleSessionStart(input) {
- appendEnvVar(SESSION_ID_ENV, input.session_id);
- appendEnvVar(TRANSCRIPT_PATH_ENV, input.transcript_path);
- appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
+ setEnv(SESSION_ID_ENV, input.session_id);
+ setEnv(TRANSCRIPT_PATH_ENV, input.transcript_path);
+ setEnv(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
}
async function handleSessionEnd(input) {
diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs
index 2346bdcf4..8d480cac0 100644
--- a/plugins/codex/scripts/stop-review-gate-hook.mjs
+++ b/plugins/codex/scripts/stop-review-gate-hook.mjs
@@ -156,6 +156,27 @@ function main() {
return;
}
+ if (input.stop_hook_active) {
+ // Claude Code re-invokes the Stop hook with stop_hook_active: true when a
+ // prior invocation returned a "block" decision. Running the review again
+ // here would just block again -- every non-ok outcome (no output, timeout,
+ // failure, invalid JSON) re-blocks unconditionally -- until the harness's
+ // forced-retry cap kicks in and ends the turn anyway. Skip the re-run and
+ // let this retry succeed instead of repeating it up to the cap.
+ //
+ // logNote() alone is invisible to the user (it only writes to stderr, which
+ // isn't surfaced for a successful hook run) -- a skipped review would look
+ // identical to a passed one. Emit a systemMessage instead: it reaches the
+ // user without blocking (no "decision" key), so the skip is visible right
+ // when it matters -- the previous review blocked on something.
+ emitDecision({
+ systemMessage:
+ "Codex stop-gate review skipped on this continuation turn. Run /codex:review --wait to verify the fixes."
+ });
+ logNote(runningTaskNote);
+ return;
+ }
+
const setupNote = buildSetupNote(cwd);
if (setupNote) {
logNote(setupNote);
diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md
index 0e91bfb50..713063e24 100644
--- a/plugins/codex/skills/codex-cli-runtime/SKILL.md
+++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md
@@ -28,15 +28,21 @@ Command selection:
- If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text.
- If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`.
- If the forwarded request includes `--effort`, pass it through to `task`.
+- If the forwarded request carries `--sandbox` before the task text (alongside `--model`, `--effort`, `--resume` or `--fresh`), pass it through in that position and do not add `--write`. A `--sandbox` inside the task text is prompt text; leave it in place.
+- Preserve every `--read-root ` pair, pass it through to `task`, and exclude both tokens from the natural-language task text. Each value must name an existing directory; files and missing paths fail before Codex starts.
+- With scoped access, a write-capable sandbox requires an approved read root to cover the workspace directory and uses Codex's built-in `:workspace` write policy. `--read-root` cannot be combined with `--sandbox danger-full-access`, which disables the sandbox entirely.
- If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`.
- If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`.
+- If the forwarded request includes `--resume-thread `, strip both tokens from the task text and pass `--resume-thread ` to `task`; this resumes that exact Codex thread instead of the latest one.
- `--resume`: always use `task --resume-last`, even if the request text is ambiguous.
- `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up.
- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`.
+- `--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`. It takes precedence over `--write`; without it, `task` runs `workspace-write` when `--write` is set and `read-only` otherwise.
- `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run.
Safety rules:
- Default to write-capable Codex work in `codex:codex-rescue` unless the user explicitly asks for read-only behavior.
+- Never add a `--sandbox` the user did not type. `danger-full-access` disables the Codex sandbox entirely.
- Preserve the user's task text as-is apart from stripping routing flags.
- Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own.
- Return the stdout of the `task` command exactly as-is.
diff --git a/tests/args.test.mjs b/tests/args.test.mjs
new file mode 100644
index 000000000..4f41b2924
--- /dev/null
+++ b/tests/args.test.mjs
@@ -0,0 +1,65 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { parseArgs, splitRawArgumentString } from "../plugins/codex/scripts/lib/args.mjs";
+
+const REVIEW_CONFIG = {
+ valueOptions: ["base", "scope", "model", "effort", "cwd"],
+ booleanOptions: ["json", "background", "wait"],
+ aliasMap: { m: "model" }
+};
+
+test("parseArgs reports an unrecognised long option instead of silently demoting it", () => {
+ const { options, positionals, unknownOptions } = parseArgs(
+ ["--model", "gpt-6-astra", "--nonsense", "value"],
+ { valueOptions: ["model"], booleanOptions: [] }
+ );
+
+ assert.equal(options.model, "gpt-6-astra");
+ // Behaviour is unchanged: the token still reaches positionals, because some
+ // commands take free-form text after their flags.
+ assert.deepEqual(positionals, ["--nonsense", "value"]);
+ // But it is now reported, so a caller can warn rather than swallow it.
+ assert.deepEqual(unknownOptions, ["--nonsense"]);
+});
+
+test("parseArgs reports nothing when every option is recognised", () => {
+ const { unknownOptions } = parseArgs(["--model", "gpt-6-astra", "--json"], {
+ valueOptions: ["model"],
+ booleanOptions: ["json"]
+ });
+
+ assert.deepEqual(unknownOptions, []);
+});
+
+test("parseArgs does not treat text after -- as an unrecognised option", () => {
+ const { positionals, unknownOptions } = parseArgs(["--", "--not-a-flag"], {
+ valueOptions: [],
+ booleanOptions: []
+ });
+
+ assert.deepEqual(positionals, ["--not-a-flag"]);
+ assert.deepEqual(unknownOptions, []);
+});
+
+test("review commands accept --effort rather than folding it into the focus text", () => {
+ const { options, positionals, unknownOptions } = parseArgs(
+ ["--model", "gpt-6-astra", "--effort", "xhigh", "focus", "on", "auth"],
+ REVIEW_CONFIG
+ );
+
+ assert.equal(options.model, "gpt-6-astra");
+ assert.equal(options.effort, "xhigh");
+ assert.deepEqual(unknownOptions, []);
+ // The regression this guards: --effort and xhigh used to land here and be
+ // joined into the prompt the reviewer was given.
+ assert.deepEqual(positionals, ["focus", "on", "auth"]);
+ assert.equal(positionals.join(" "), "focus on auth");
+});
+
+test("splitRawArgumentString keeps a quoted focus phrase together", () => {
+ assert.deepEqual(
+ splitRawArgumentString('--model gpt-6-astra --effort xhigh "the auth path"'),
+ ["--model", "gpt-6-astra", "--effort", "xhigh", "the auth path"]
+ );
+});
diff --git a/tests/broker-lifecycle.test.mjs b/tests/broker-lifecycle.test.mjs
index f2a047e55..4b8a09dbe 100644
--- a/tests/broker-lifecycle.test.mjs
+++ b/tests/broker-lifecycle.test.mjs
@@ -121,21 +121,38 @@ test("broker rejects a shutdown token that does not identify its instance", asyn
assert.ok(loadBrokerSession(workspace));
});
-test("shutdown closes idle half-open broker clients", { skip: process.platform === "win32" }, async (t) => {
+test("shutdown closes a lingering half-open peer requester", { skip: process.platform === "win32" }, async (t) => {
const workspace = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir);
const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir) });
- const idleClient = net.createConnection({
+
+ // A racing session-end hook that has already sent its own broker/shutdown is
+ // a peer requester, not work, so the busy check must skip it. It keeps its
+ // read side open (allowHalfOpen), which means end() alone would leave
+ // server.close() pending on it: only an explicit destroy lets the broker go.
+ const peer = net.createConnection({
path: session.endpoint.slice("unix:".length),
allowHalfOpen: true
});
- idleClient.on("end", () => {});
+ peer.setEncoding("utf8");
+ t.after(() => peer.destroy());
await new Promise((resolve, reject) => {
- idleClient.once("connect", resolve);
- idleClient.once("error", reject);
+ peer.once("connect", resolve);
+ peer.once("error", reject);
+ });
+ const refusal = new Promise((resolve) => peer.once("data", resolve));
+ peer.write(
+ `${JSON.stringify({ id: 1, method: "broker/shutdown", params: { instanceToken: "not-this-instance" } })}\n`
+ );
+ assert.match(await refusal, /identity did not match/i);
+ // allowHalfOpen means the client never closes itself: it only learns the
+ // broker let go when the read side ends (or the socket dies outright).
+ const peerReleased = new Promise((resolve) => {
+ peer.once("end", resolve);
+ peer.once("close", resolve);
+ peer.once("error", resolve);
});
- t.after(() => idleClient.destroy());
const outcome = await shutdownBrokerSession(workspace, {
timeoutMs: 500,
@@ -146,6 +163,50 @@ test("shutdown closes idle half-open broker clients", { skip: process.platform =
assert.equal(outcome.exited, true);
assert.equal(outcome.forced, false);
assert.equal(loadBrokerSession(workspace), null);
+ // Released by the broker rather than waited on: this resolving at all is the
+ // assertion — a shutdown that waited on a half-open peer would hang here.
+ await peerReleased;
+});
+
+test("a busy broker refuses shutdown instead of failing it", { skip: process.platform === "win32" }, async (t) => {
+ const workspace = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir);
+ const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir) });
+
+ // An ordinary connected client — a worker that has not sent its first
+ // request yet — is exactly what the busy check protects: it must keep the
+ // broker alive, without turning session end into an error.
+ const client = net.createConnection({ path: session.endpoint.slice("unix:".length) });
+ t.after(() => client.destroy());
+ await new Promise((resolve, reject) => {
+ client.once("connect", resolve);
+ client.once("error", reject);
+ });
+
+ const refused = await shutdownBrokerSession(workspace, {
+ timeoutMs: 500,
+ intervalMs: 10,
+ killProcess: terminateProcessTree
+ });
+
+ assert.equal(refused.refused, true);
+ assert.equal(refused.exited, false);
+ assert.equal(refused.forced, false);
+ assert.ok(loadBrokerSession(workspace), "a refused shutdown must preserve the persisted record");
+ assert.equal(isProcessTreeRunning(session.pid), true);
+
+ // Once the client is gone the very same call retires the broker.
+ client.destroy();
+ await new Promise((resolve) => client.once("close", resolve));
+ const retired = await shutdownBrokerSession(workspace, {
+ timeoutMs: 2000,
+ intervalMs: 10,
+ killProcess: terminateProcessTree
+ });
+ assert.equal(retired.refused, false);
+ assert.equal(retired.exited, true);
+ assert.equal(loadBrokerSession(workspace), null);
});
test("shutdown request always uses a finite deadline", { skip: process.platform === "win32" }, async (t) => {
@@ -173,7 +234,16 @@ test("shutdown request always uses a finite deadline", { skip: process.platform
instanceToken: "instance-token-1234567890",
timeoutMs
});
- assert.equal(response, null);
+ // A broker that accepts the connection and then says nothing is ambiguous:
+ // it may be alive and busy with its refusal lost on the wire. The outcome
+ // has to report that — not delivered, not refused, and deliberately not
+ // unreachable — so teardown never reaps a broker that may still be serving
+ // someone.
+ assert.equal(response.delivered, false);
+ assert.equal(response.refused, false);
+ assert.equal(response.unreachable, false);
+ assert.equal(response.result, null);
+ assert.equal(response.error, null);
assert.ok(Date.now() - startedAt < 500, "shutdown request exceeded its deadline");
}
assert.equal(fs.existsSync(socketPath), true);
@@ -1021,7 +1091,13 @@ test("shutdown reclaims a tokened session whose dead leader left an orphaned gro
const outcome = await shutdownBrokerSession(workspace, { timeoutMs: 40, killProcess });
assert.equal(killProcessCalled, false, "an abandoned orphaned tree must never be signaled");
- assert.deepEqual(outcome, { found: true, exited: true, forced: false, reclaimedStaleEndpoint: true });
+ assert.deepEqual(outcome, {
+ found: true,
+ exited: true,
+ forced: false,
+ refused: false,
+ reclaimedStaleEndpoint: true
+ });
assert.equal(isProcessRunning(grandchildPid), true, "the grandchild must be left running untouched");
assert.equal(fs.existsSync(socketPath), false, "the stale socket must be removed");
assert.equal(fs.existsSync(pidFile), false, "the pid file must be removed");
diff --git a/tests/claude-session-transfer.test.mjs b/tests/claude-session-transfer.test.mjs
new file mode 100644
index 000000000..0ba7b48eb
--- /dev/null
+++ b/tests/claude-session-transfer.test.mjs
@@ -0,0 +1,47 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { prepareClaudeSessionImport } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs";
+
+function tempDir(prefix) {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+test("shared staged imports keep the file alive until the final cleanup", () => {
+ const home = tempDir("codex-transfer-home-");
+ const repo = path.join(home, "repo");
+ const claudeConfigDir = path.join(home, ".claude-work");
+ const projectDir = path.join(claudeConfigDir, "projects", "-repo");
+ const source = path.join(projectDir, "session.jsonl");
+ const previousHome = process.env.USERPROFILE;
+ const previousConfig = process.env.CLAUDE_CONFIG_DIR;
+
+ fs.mkdirSync(repo, { recursive: true });
+ fs.mkdirSync(projectDir, { recursive: true });
+ fs.writeFileSync(source, '{"type":"user","message":{"content":"hello"}}\n', "utf8");
+ process.env.USERPROFILE = home;
+ process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
+
+ try {
+ const first = prepareClaudeSessionImport(repo, source);
+ const second = prepareClaudeSessionImport(repo, source);
+
+ assert.equal(path.resolve(first.importPath), path.resolve(second.importPath));
+ assert.equal(fs.existsSync(first.importPath), true);
+
+ first.cleanup();
+ assert.equal(fs.existsSync(second.importPath), true);
+
+ second.cleanup();
+ assert.equal(fs.existsSync(second.importPath), false);
+ } finally {
+ if (previousHome === undefined) delete process.env.USERPROFILE;
+ else process.env.USERPROFILE = previousHome;
+ if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR;
+ else process.env.CLAUDE_CONFIG_DIR = previousConfig;
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs
index c34b06059..162ddbd92 100644
--- a/tests/commands.test.mjs
+++ b/tests/commands.test.mjs
@@ -49,7 +49,9 @@ test("adversarial review command uses AskUserQuestion and background Bash while
assert.match(source, /```bash/);
assert.match(source, /```typescript/);
assert.match(source, /adversarial-review "\$ARGUMENTS"/);
- assert.match(source, /\[--scope auto\|working-tree\|branch\] \[focus \.\.\.\]/);
+ assert.match(source, /\[--scope auto\|working-tree\|branch\]/);
+ assert.match(source, /\[--effort \]/);
+ assert.match(source, /\[focus \.\.\.\]/);
assert.match(source, /run_in_background:\s*true/);
assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" adversarial-review "\$ARGUMENTS"`/);
assert.match(source, /description:\s*"Codex adversarial review"/);
@@ -102,9 +104,12 @@ test("rescue command absorbs continue semantics", () => {
assert.match(rescue, /do not call `Skill\(codex:codex-rescue\)`/i);
assert.doesNotMatch(rescue, /^context:\s*fork\b/m);
assert.match(rescue, /--background\|--wait/);
- assert.match(rescue, /--resume\|--fresh/);
+ assert.match(rescue, /--resume\|--resume-thread \|--fresh/);
assert.match(rescue, /--model /);
assert.match(rescue, /--effort /);
+ assert.match(rescue, /--sandbox /);
+ assert.match(rescue, /--read-root /);
+ assert.match(rescue, /Preserve every `--read-root `/i);
assert.match(rescue, /task-resume-candidate --json/);
assert.match(rescue, /AskUserQuestion/);
assert.match(rescue, /Continue current Codex thread/);
@@ -113,20 +118,28 @@ test("rescue command absorbs continue semantics", () => {
assert.match(rescue, /default to foreground/i);
assert.match(rescue, /Do not forward them to `task`/i);
assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i);
+ assert.match(rescue, /`--sandbox` is a runtime-selection flag/i);
+ assert.match(rescue, /never add a `--sandbox` yourself/i);
+ assert.match(rescue, /a `--sandbox` inside the task text is part of the prompt and stays there/i);
assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i);
assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i);
assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i);
assert.match(rescue, /If the request includes `--fresh`, do not ask whether to continue/i);
+ assert.match(rescue, /If the request includes `--resume-thread `, do not ask whether to continue/i);
assert.match(rescue, /If the user chooses continue, add `--resume`/i);
assert.match(rescue, /If the user chooses a new thread, add `--fresh`/i);
assert.match(rescue, /thin forwarder only/i);
assert.match(rescue, /Return the Codex companion stdout verbatim to the user/i);
assert.match(rescue, /Do not paraphrase, summarize, rewrite, or add commentary before or after it/i);
assert.match(rescue, /return that command's stdout as-is/i);
- assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i);
+ assert.match(rescue, /Leave `--resume`, `--resume-thread `, and `--fresh` in the forwarded request/i);
assert.match(agent, /--resume/);
assert.match(agent, /--fresh/);
+ assert.match(agent, /--resume-thread /);
+ assert.match(agent, /Preserve every `--read-root `/i);
assert.match(agent, /thin forwarding wrapper/i);
+ assert.match(agent, /Treat `--resume`, `--resume-thread `, and `--fresh` as routing controls/i);
+ assert.match(agent, /`--resume-thread ` means pass that exact routing pair through and do not add `--resume-last`/i);
assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i);
assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i);
assert.match(agent, /Use exactly one `Bash` call/i);
@@ -136,6 +149,9 @@ test("rescue command absorbs continue semantics", () => {
assert.match(agent, /Leave model unset by default/i);
assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i);
assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i);
+ assert.match(agent, /If the user passes `--sandbox ` before the task text, forward it in that position and do not add `--write`/i);
+ assert.match(agent, /A `--sandbox` inside the task text is part of the prompt: leave it there/i);
+ assert.match(agent, /Never add a `--sandbox` on your own/i);
assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i);
assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
assert.match(agent, /gpt-5-4-prompting/);
@@ -151,13 +167,29 @@ test("rescue command absorbs continue semantics", () => {
assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i);
assert.match(runtimeSkill, /Strip it before calling `task`/i);
assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i);
+ assert.match(runtimeSkill, /If the forwarded request carries `--sandbox` before the task text \(alongside `--model`, `--effort`, `--resume` or `--fresh`\), pass it through in that position and do not add `--write`/i);
+ assert.match(runtimeSkill, /A `--sandbox` inside the task text is prompt text; leave it in place/i);
+ assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i);
+ assert.match(runtimeSkill, /It takes precedence over `--write`/i);
+ assert.match(runtimeSkill, /Never add a `--sandbox` the user did not type/i);
+ assert.match(runtimeSkill, /Preserve every `--read-root `/i);
+ assert.match(runtimeSkill, /existing directory/i);
+ assert.match(runtimeSkill, /a write-capable sandbox requires an approved read root to cover the workspace directory/i);
assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i);
assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
assert.match(readme, /`codex:codex-rescue` subagent/i);
assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i);
assert.match(readme, /--model gpt-5\.4-mini --effort medium/i);
+ assert.match(readme, /--read-root/);
+ assert.match(readme, /`--write`.*cover the workspace directory/i);
assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i);
assert.match(readme, /continue a previous Codex task/i);
+ assert.match(readme, /--sandbox /);
+ assert.match(readme, /`danger-full-access` disables the Codex sandbox entirely/i);
+ assert.match(readme, /a resumed thread keeps the sandbox it was started with/i);
+ assert.match(readme, /counts only before the task text/i);
+ assert.match(readme, /refuses a resume whose sandbox differs from what the app-server reports/i);
+ assert.match(readme, /`--sandbox` applies to `\/codex:rescue` only/i);
assert.match(readme, /### `\/codex:setup`/);
assert.match(readme, /### `\/codex:review`/);
assert.match(readme, /### `\/codex:adversarial-review`/);
@@ -195,6 +227,7 @@ test("internal docs use task terminology for rescue runs", () => {
assert.match(runtimeSkill, /codex-companion\.mjs" task ""/);
assert.match(runtimeSkill, /Use `task` for every rescue request/i);
assert.match(runtimeSkill, /task --resume-last/i);
+ assert.match(runtimeSkill, /--resume-thread /i);
assert.match(promptingSkill, /Use `task` when the task is diagnosis/i);
assert.match(promptRecipes, /Codex task prompts/i);
assert.match(promptRecipes, /Use these as starting templates for Codex task prompts/i);
@@ -202,6 +235,19 @@ test("internal docs use task terminology for rescue runs", () => {
assert.match(promptRecipes, /## Narrow Fix/);
});
+test("hooks and deterministic commands use the portable Node launcher", () => {
+ const hooks = read("hooks/hooks.json");
+ for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md", "commands/transfer.md"]) {
+ const source = read(relative);
+ assert.match(source, /scripts\/run-node\.sh/);
+ assert.match(source, /cygpath/);
+ assert.doesNotMatch(source, /!`node "/);
+ }
+ assert.match(hooks, /scripts\/run-node\.sh/);
+ assert.match(hooks, /cygpath/);
+ assert.doesNotMatch(hooks, /"command": "node /);
+});
+
test("hooks keep session-end cleanup and stop gating enabled", () => {
const source = read("hooks/hooks.json");
assert.match(source, /SessionStart/);
diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs
index b547a6881..6b17012d4 100644
--- a/tests/fake-codex-fixture.mjs
+++ b/tests/fake-codex-fixture.mjs
@@ -116,13 +116,24 @@ function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
-function nextThread(state, cwd, ephemeral) {
+function sandboxPolicy(mode) {
+ if (mode === "danger-full-access") {
+ return { type: "dangerFullAccess" };
+ }
+ if (mode === "workspace-write") {
+ return { type: "workspaceWrite", writableRoots: [], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false };
+ }
+ return { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false };
+}
+
+function nextThread(state, cwd, ephemeral, sandbox) {
const thread = {
id: "thr_" + state.nextThreadId++,
cwd: cwd || process.cwd(),
name: null,
preview: "",
ephemeral: Boolean(ephemeral),
+ sandbox: sandbox || "read-only",
createdAt: now(),
updatedAt: now()
};
@@ -309,11 +320,20 @@ rl.on("line", (line) => {
if (BEHAVIOR === "auth-run-fails") {
throw new Error("authentication expired; run codex login");
}
+ if (BEHAVIOR === "permission-profiles-unsupported" && message.params.config) {
+ throw new Error("unknown field config.default_permissions");
+ }
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
}
- const thread = nextThread(state, message.params.cwd, message.params.ephemeral);
- send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
+ const thread = nextThread(state, message.params.cwd, message.params.ephemeral, message.params.sandbox);
+ state.lastThreadStart = {
+ ...message.params,
+ threadId: thread.id,
+ approvalPolicy: message.params.approvalPolicy ?? null
+ };
+ saveState(state);
+ send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: sandboxPolicy(thread.sandbox), reasoningEffort: null } });
send({ method: "thread/started", params: { thread: { id: thread.id } } });
break;
}
@@ -341,13 +361,24 @@ rl.on("line", (line) => {
}
case "thread/resume": {
+ if (BEHAVIOR === "permission-profiles-unsupported" && message.params.config) {
+ throw new Error("unknown field config.default_permissions");
+ }
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/resume.persistFullHistory requires experimentalApi capability");
}
const thread = ensureThread(state, message.params.threadId);
+ if (message.params.cwd) {
+ thread.cwd = message.params.cwd;
+ }
thread.updatedAt = now();
+ const resumedPolicy = BEHAVIOR === "external-sandbox" ? { type: "externalSandbox", networkAccess: "restricted" } : sandboxPolicy(thread.sandbox);
+ state.lastThreadResume = {
+ ...message.params,
+ threadId: thread.id
+ };
saveState(state);
- send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
+ send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: resumedPolicy, reasoningEffort: null } });
break;
}
@@ -670,6 +701,7 @@ export function buildEnv(binDir) {
const sep = process.platform === "win32" ? ";" : ":";
return {
...process.env,
+ CODEX_HOME: path.join(binDir, "codex-home"),
PATH: `${binDir}${sep}${process.env.PATH}`
};
}
diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs
new file mode 100644
index 000000000..e51128241
--- /dev/null
+++ b/tests/node-launcher.test.mjs
@@ -0,0 +1,419 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh");
+const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash");
+const VERSION_MANAGER_ENV_KEYS = new Set([
+ "nvm_dir",
+ "nvm_symlink",
+ "fnm_dir",
+ "asdf_data_dir",
+ "mise_data_dir",
+ "homebrew_prefix",
+ "volta_home",
+ "localappdata",
+ "programfiles",
+ "programw6432"
+]);
+
+function cleanVersionManagerEnv() {
+ return Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => !VERSION_MANAGER_ENV_KEYS.has(key.toLowerCase()))
+ );
+}
+
+
+function installFakeNode(home, version, supported) {
+ const binDir = path.join(home, ".nvm", "versions", "node", version, "bin");
+ const nodePath = path.join(binDir, "node");
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.writeFileSync(nodePath, `#!/bin/sh\nif [ "\${1:-}" = "-e" ]; then exit ${supported ? 0 : 1}; fi\nprintf 'FAKE_NODE_${version}:%s\\n' "$*"\nprintf 'CODEX:%s\\n' "$(command -v codex || true)"\n`, "utf8");
+ fs.chmodSync(nodePath, 0o755);
+ return binDir;
+}
+
+function runWithMinimalPath(home) {
+ const emptyBin = path.join(home, "empty-bin");
+ fs.mkdirSync(emptyBin, { recursive: true });
+ return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" }
+ });
+}
+
+test("portable launcher preserves the selected Node toolchain directory on PATH", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-path-"));
+ const binDir = installFakeNode(home, "v22.0.0", true);
+ const codexPath = path.join(binDir, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+ try {
+ const result = runWithMinimalPath(home);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("portable launcher skips unsupported Node versions", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-version-"));
+ installFakeNode(home, "v12.22.0", false);
+ installFakeNode(home, "v22.0.0", true);
+ try {
+ const result = runWithMinimalPath(home);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/);
+ assert.doesNotMatch(result.stdout, /FAKE_NODE_v12\.22\.0:/);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("portable launcher keeps searching compatible toolchains for codex", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-mixed-path-"));
+ const systemBin = path.join(home, "system-bin");
+ fs.mkdirSync(systemBin, { recursive: true });
+ const systemNode = path.join(systemBin, "node");
+ fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf \'SYSTEM_NODE:%s\\n\' "$*"\nprintf \'CODEX:%s\\n\' "$(command -v codex || true)"\n', "utf8");
+ fs.chmodSync(systemNode, 0o755);
+
+ const managedBin = installFakeNode(home, "v22.0.0", true);
+ const codexPath = path.join(managedBin, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/);
+ assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/);
+ assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+// Regression for review: minimal Git Bash PATH must still discover a normal Windows Node install.
+test("portable launcher discovers Windows Node install roots under Git Bash", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-windows-"));
+ const emptyBin = path.join(home, "empty-bin");
+ const programFiles = path.join(home, "Program Files");
+ const programFilesPosix = programFiles
+ .replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
+ .replaceAll("\\", "/");
+ const nodeDir = path.join(programFiles, "nodejs");
+ fs.mkdirSync(emptyBin, { recursive: true });
+ fs.mkdirSync(nodeDir, { recursive: true });
+ const cygpath = path.join(emptyBin, "cygpath");
+ fs.writeFileSync(cygpath, '#!/bin/sh\nif [ "${1:-}" = "-u" ]; then shift; fi\nprintf "%s\\n" "$1"\n', "utf8");
+ fs.chmodSync(cygpath, 0o755);
+ const nodePath = path.join(nodeDir, "node.exe");
+ fs.copyFileSync(process.execPath, nodePath);
+ const probeName = `windows-node-probe-${process.pid}.mjs`;
+ const probePath = path.join(path.dirname(LAUNCHER), probeName);
+ fs.writeFileSync(probePath, 'console.log("WINDOWS_NODE")\n', "utf8");
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), PROGRAMFILES: programFilesPosix, CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /WINDOWS_NODE/);
+ } finally {
+ fs.rmSync(probePath, { force: true });
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+
+test("portable launcher restores a configured npm global prefix for codex", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-npm-prefix-"));
+ const nodeBin = installFakeNode(home, "v22.0.0", true);
+ const prefix = path.join(home, ".npm-global");
+ const prefixBin = path.join(prefix, "bin");
+ fs.mkdirSync(prefixBin, { recursive: true });
+ const codexPath = path.join(prefixBin, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: {
+ ...cleanVersionManagerEnv(),
+ HOME: home.replaceAll("\\", "/"),
+ PATH: nodeBin.replaceAll("\\", "/"),
+ NPM_CONFIG_PREFIX: prefix.replaceAll("\\", "/"),
+ CODEX_COMPANION_NODE: ""
+ }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CODEX:.+[\\/]\.npm-global[\\/]bin[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+
+test("portable launcher restores npm prefix reported by npm config", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-npm-config-"));
+ const nodeBin = installFakeNode(home, "v22.0.0", true);
+ const prefix = path.join(home, ".npm-configured");
+ const prefixBin = path.join(prefix, "bin");
+ fs.mkdirSync(prefixBin, { recursive: true });
+ const codexPath = path.join(prefixBin, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+ const npmPath = path.join(nodeBin, "npm");
+ fs.writeFileSync(
+ npmPath,
+ `#!/bin/sh\nif [ "\${1:-}" = "prefix" ] && [ "\${2:-}" = "-g" ]; then printf '%s\\n' "${prefix.replaceAll("\\", "/")}"; exit 0; fi\nexit 1\n`,
+ "utf8"
+ );
+ fs.chmodSync(npmPath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: {
+ ...cleanVersionManagerEnv(),
+ HOME: home.replaceAll("\\", "/"),
+ PATH: nodeBin.replaceAll("\\", "/"),
+ NPM_CONFIG_PREFIX: "",
+ CODEX_COMPANION_NODE: ""
+ }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CODEX:.+[\\/]\.npm-configured[\\/]bin[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+
+test("portable launcher discovers Node from a custom NVM_DIR", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-nvm-"));
+ const emptyBin = path.join(home, "empty-bin");
+ const nvmDir = path.join(home, "custom-nvm");
+ const binDir = path.join(nvmDir, "versions", "node", "v22.0.0", "bin");
+ fs.mkdirSync(emptyBin, { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ const nodePath = path.join(binDir, "node");
+ fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_NVM_NODE:%s\\n" "$*"\n', "utf8");
+ fs.chmodSync(nodePath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CUSTOM_NVM_NODE:/);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("portable launcher enriches PATH from a custom NVM_DIR", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-nvm-path-"));
+ const systemBin = path.join(home, "system-bin");
+ const nvmDir = path.join(home, "custom-nvm");
+ const managedBin = path.join(nvmDir, "versions", "node", "v22.0.0", "bin");
+ fs.mkdirSync(systemBin, { recursive: true });
+ fs.mkdirSync(managedBin, { recursive: true });
+ const systemNode = path.join(systemBin, "node");
+ fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8");
+ fs.chmodSync(systemNode, 0o755);
+ const managedNode = path.join(managedBin, "node");
+ fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_NVM_MANAGED:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8");
+ fs.chmodSync(managedNode, 0o755);
+ const codexPath = path.join(managedBin, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CUSTOM_NVM_MANAGED:/);
+ assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/);
+ assert.match(result.stdout, /CODEX:.+[\\/]custom-nvm[\\/]versions[\\/]node[\\/]v22\.0\.0[\\/]bin[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+
+test("portable launcher discovers Node from a custom FNM_DIR", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-fnm-"));
+ const emptyBin = path.join(home, "empty-bin");
+ const fnmDir = path.join(home, "custom-fnm");
+ const binDir = path.join(fnmDir, "node-versions", "v22.0.0", "installation", "bin");
+ fs.mkdirSync(emptyBin, { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ const nodePath = path.join(binDir, "node");
+ fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_FNM_NODE:%s\\n" "$*"\n', "utf8");
+ fs.chmodSync(nodePath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), FNM_DIR: fnmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CUSTOM_FNM_NODE:/);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("portable launcher aligns Node and codex from a custom FNM_DIR", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-fnm-path-"));
+ const systemBin = path.join(home, "system-bin");
+ const fnmDir = path.join(home, "custom-fnm");
+ const managedBin = path.join(fnmDir, "node-versions", "v24.2.0", "installation", "bin");
+ fs.mkdirSync(systemBin, { recursive: true });
+ fs.mkdirSync(managedBin, { recursive: true });
+ const systemNode = path.join(systemBin, "node");
+ fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\n', "utf8");
+ fs.chmodSync(systemNode, 0o755);
+ const managedNode = path.join(managedBin, "node");
+ fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_FNM_MANAGED:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8");
+ fs.chmodSync(managedNode, 0o755);
+ const codexPath = path.join(managedBin, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), FNM_DIR: fnmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CUSTOM_FNM_MANAGED:/);
+ assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/);
+ assert.match(result.stdout, /CODEX:.+[\\/]custom-fnm[\\/]node-versions[\\/]v24\.2\.0[\\/]installation[\\/]bin[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+
+test("portable launcher honors custom ASDF_DATA_DIR and MISE_DATA_DIR", () => {
+ const cases = [
+ { envKey: "ASDF_DATA_DIR", label: "ASDF", relative: ["installs", "nodejs", "v22.0.0", "bin"] },
+ { envKey: "MISE_DATA_DIR", label: "MISE", relative: ["installs", "node", "v22.0.0", "bin"] }
+ ];
+ for (const { envKey, label, relative } of cases) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), `codex-node-custom-${label.toLowerCase()}-`));
+ const emptyBin = path.join(home, "empty-bin");
+ const dataDir = path.join(home, `custom-${label.toLowerCase()}`);
+ const binDir = path.join(dataDir, ...relative);
+ fs.mkdirSync(emptyBin, { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ const nodePath = path.join(binDir, "node");
+ fs.writeFileSync(nodePath, `#!/bin/sh\nif [ "\${1:-}" = "-e" ]; then exit 0; fi\nprintf '${label}_NODE:%s\\n' "$*"\n`, "utf8");
+ fs.chmodSync(nodePath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: {
+ ...cleanVersionManagerEnv(),
+ HOME: home.replaceAll("\\\\", "/"),
+ PATH: emptyBin.replaceAll("\\\\", "/"),
+ ASDF_DATA_DIR: "",
+ MISE_DATA_DIR: "",
+ [envKey]: dataDir.replaceAll("\\\\", "/"),
+ CODEX_COMPANION_NODE: ""
+ }
+ });
+ assert.equal(result.status, 0, `${label}: ${result.stderr}`);
+ assert.match(result.stdout, new RegExp(`${label}_NODE:`));
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ }
+});
+
+test("portable launcher honors HOMEBREW_PREFIX under a minimal PATH", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-homebrew-prefix-"));
+ const emptyBin = path.join(home, "empty-bin");
+ const brewPrefix = path.join(home, "linuxbrew-prefix");
+ const binDir = path.join(brewPrefix, "bin");
+ fs.mkdirSync(emptyBin, { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ const nodePath = path.join(binDir, "node");
+ fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "HOMEBREW_NODE:%s\\n" "$*"\n', "utf8");
+ fs.chmodSync(nodePath, 0o755);
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], {
+ encoding: "utf8",
+ env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), HOMEBREW_PREFIX: brewPrefix.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /HOMEBREW_NODE:/);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-"));
+ const emptyBin = path.join(home, "empty-bin");
+ const portableDir = path.join(home, "Portable Node");
+ fs.mkdirSync(emptyBin, { recursive: true });
+ fs.mkdirSync(portableDir, { recursive: true });
+ const nodePath = path.join(portableDir, "node.exe");
+ fs.copyFileSync(process.execPath, nodePath);
+ const probeName = `configured-node-probe-${process.pid}.mjs`;
+ const probePath = path.join(path.dirname(LAUNCHER), probeName);
+ fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE"); console.log("PATH:" + (process.env.PATH ?? ""));\n', "utf8");
+ try {
+ const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], {
+ encoding: "utf8",
+ env: {
+ ...cleanVersionManagerEnv(),
+ HOME: home.replaceAll("\\", "/"),
+ PATH: emptyBin.replaceAll("\\", "/"),
+ CODEX_COMPANION_NODE: nodePath,
+ NVM_DIR: "",
+ NVM_SYMLINK: "",
+ VOLTA_HOME: "",
+ LOCALAPPDATA: "",
+ ProgramFiles: "",
+ PROGRAMFILES: "",
+ PROGRAMW6432: ""
+ }
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /CONFIGURED_WINDOWS_NODE/);
+ assert.match(result.stdout, /PATH:.*Portable Node/i);
+ } finally {
+ fs.rmSync(probePath, { force: true });
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("portable launcher aligns Node with the supported toolchain that contains codex", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-multi-supported-"));
+ installFakeNode(home, "v20.19.0", true);
+ const newerBin = installFakeNode(home, "v24.2.0", true);
+ const codexPath = path.join(newerBin, "codex");
+ fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8");
+ fs.chmodSync(codexPath, 0o755);
+ try {
+ const result = runWithMinimalPath(home);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /FAKE_NODE_v24\.2\.0:/);
+ assert.doesNotMatch(result.stdout, /FAKE_NODE_v20\.19\.0:/);
+ assert.match(result.stdout, /CODEX:.+[\\/]v24\.2\.0[\\/]bin[\\/]codex\n$/m);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+});
diff --git a/tests/process.test.mjs b/tests/process.test.mjs
index 87da53291..9b1bd8f98 100644
--- a/tests/process.test.mjs
+++ b/tests/process.test.mjs
@@ -152,6 +152,28 @@ test("process launch sequence fallback requires arguments in order", () => {
);
});
+test("runCommand allows output larger than Node's default maxBuffer", () => {
+ const outputBytes = 2 * 1024 * 1024;
+ const result = runCommand(process.execPath, [
+ "-e",
+ `process.stdout.write("x".repeat(${outputBytes}))`
+ ]);
+
+ assert.equal(result.error, null);
+ assert.equal(result.status, 0);
+ assert.equal(Buffer.byteLength(result.stdout), outputBytes);
+});
+
+test("runCommand preserves an explicit maxBuffer override", () => {
+ const result = runCommand(
+ process.execPath,
+ ["-e", `process.stdout.write("x".repeat(${2 * 1024}))`],
+ { maxBuffer: 1024 }
+ );
+
+ assert.equal(result.error?.code, "ENOBUFS");
+});
+
test("terminateProcessTree uses taskkill on Windows", () => {
let captured = null;
const outcome = terminateProcessTree(1234, {
diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs
index 0123dc050..da3e5d5a1 100644
--- a/tests/runtime.test.mjs
+++ b/tests/runtime.test.mjs
@@ -221,6 +221,7 @@ test("transfer delegates the current Claude session directly to native import",
env: {
...buildEnv(binDir),
HOME: home,
+ USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex"),
CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath
}
@@ -245,6 +246,116 @@ test("transfer delegates the current Claude session directly to native import",
);
});
+test("transfer supports CLAUDE_CONFIG_DIR and stages a temporary default-root copy", () => {
+ const home = makeTempDir();
+ const repo = path.join(home, "repo");
+ const binDir = makeTempDir();
+ const claudeConfigDir = path.join(home, ".claude-work");
+ const projectDir = path.join(claudeConfigDir, "projects", "-repo");
+ const sourcePath = path.join(projectDir, "session-alt-root.jsonl");
+ fs.mkdirSync(repo, { recursive: true });
+ fs.mkdirSync(projectDir, { recursive: true });
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(
+ sourcePath,
+ `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Transfer from relocated config." } })}\n`,
+ "utf8"
+ );
+
+ const result = run("node", [SCRIPT, "transfer", "--json"], {
+ cwd: repo,
+ env: {
+ ...buildEnv(binDir),
+ HOME: home,
+ USERPROFILE: home,
+ CODEX_HOME: path.join(home, ".codex"),
+ CLAUDE_CONFIG_DIR: claudeConfigDir,
+ CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath
+ }
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const payload = JSON.parse(result.stdout);
+ const original = fs.realpathSync(sourcePath);
+ assert.equal(payload.sourcePath, original);
+
+ const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
+ const importedPath = fakeState.lastExternalAgentImport.sourcePath;
+ const defaultProjects = path.join(home, ".claude", "projects");
+ const relativeImport = path.relative(defaultProjects, importedPath);
+ assert.equal(relativeImport.startsWith("..") || path.isAbsolute(relativeImport), false);
+ assert.notEqual(importedPath, original);
+ assert.equal(fs.existsSync(importedPath), false);
+ assert.equal(fs.existsSync(original), true);
+});
+
+test("transfer retries with a collision-free staged filename when the mirrored destination exists", () => {
+ const home = makeTempDir();
+ const repo = path.join(home, "repo");
+ const binDir = makeTempDir();
+ const claudeConfigDir = path.join(home, ".claude-work");
+ const projectDir = path.join(claudeConfigDir, "projects", "-repo");
+ const sourcePath = path.join(projectDir, "session-collision.jsonl");
+ const mirroredPath = path.join(home, ".claude", "projects", "-repo", "session-collision.jsonl");
+ fs.mkdirSync(repo, { recursive: true });
+ fs.mkdirSync(projectDir, { recursive: true });
+ fs.mkdirSync(path.dirname(mirroredPath), { recursive: true });
+ fs.writeFileSync(mirroredPath, "STALE-STAGING\n", "utf8");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Retry safely." } })}\n`, "utf8");
+
+ const result = run("node", [SCRIPT, "transfer", "--json"], {
+ cwd: repo,
+ env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: claudeConfigDir, CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath }
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(fs.readFileSync(mirroredPath, "utf8"), "STALE-STAGING\n");
+ const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
+ const firstImportedPath = fakeState.lastExternalAgentImport.sourcePath;
+ assert.notEqual(path.resolve(firstImportedPath), path.resolve(mirroredPath));
+ assert.equal(fs.existsSync(firstImportedPath), false);
+
+ const retry = run("node", [SCRIPT, "transfer", "--json"], {
+ cwd: repo,
+ env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: claudeConfigDir, CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath }
+ });
+ assert.equal(retry.status, 0, retry.stderr);
+ const retryState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
+ assert.equal(path.resolve(retryState.lastExternalAgentImport.sourcePath), path.resolve(firstImportedPath));
+ assert.equal(fs.existsSync(retryState.lastExternalAgentImport.sourcePath), false);
+});
+
+test("transfer rejects a staging path that escapes the default projects root through a symlink", () => {
+ const home = makeTempDir();
+ const repo = path.join(home, "repo");
+ const binDir = makeTempDir();
+ const claudeConfigDir = path.join(home, ".claude-work");
+ const projectDir = path.join(claudeConfigDir, "projects", "-repo");
+ const sourcePath = path.join(projectDir, "new", "session-symlink.jsonl");
+ const defaultProjects = path.join(home, ".claude", "projects");
+ const escapedDir = path.join(home, "escaped-staging");
+ fs.mkdirSync(repo, { recursive: true });
+ fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
+ fs.mkdirSync(defaultProjects, { recursive: true });
+ fs.mkdirSync(escapedDir, { recursive: true });
+ fs.symlinkSync(escapedDir, path.join(defaultProjects, "-repo"), "junction");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not escape staging." } })}\n`, "utf8");
+
+ const result = run("node", [SCRIPT, "transfer", "--json"], {
+ cwd: repo,
+ env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: claudeConfigDir, CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath }
+ });
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /outside.*default Claude projects root|staging.*outside/i);
+ assert.equal(fs.existsSync(path.join(escapedDir, "new")), false);
+});
+
test("transfer reports an actionable upgrade error when native import is unsupported", () => {
const home = makeTempDir();
const repo = path.join(home, "repo");
@@ -266,6 +377,7 @@ test("transfer reports an actionable upgrade error when native import is unsuppo
env: {
...buildEnv(binDir),
HOME: home,
+ USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex")
}
});
@@ -296,6 +408,7 @@ test("transfer fails visibly when native import completes without a ledger recor
env: {
...buildEnv(binDir),
HOME: home,
+ USERPROFILE: home,
CODEX_HOME: path.join(home, ".codex")
}
});
@@ -321,7 +434,7 @@ test("transfer rejects sources outside the Claude projects directory", () => {
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], {
cwd: repo,
- env: { ...buildEnv(binDir), HOME: home }
+ env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home }
});
assert.notEqual(result.status, 0);
@@ -504,6 +617,89 @@ test("task --resume-last resumes the latest persisted task thread", () => {
assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n");
});
+test("task --resume-thread resumes the requested thread instead of the latest one", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ assert.equal(run("node", [SCRIPT, "task", "first task"], { cwd: repo, env: buildEnv(binDir) }).status, 0);
+ assert.equal(run("node", [SCRIPT, "task", "second task"], { cwd: repo, env: buildEnv(binDir) }).status, 0);
+
+ const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "follow up"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
+ assert.equal(fakeState.lastTurnStart.prompt, "follow up");
+});
+
+test("task --resume-thread rebinds a foreign thread to the current workspace", () => {
+ const repoA = makeTempDir();
+ const repoB = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repoA);
+ initGitRepo(repoB);
+
+ assert.equal(run("node", [SCRIPT, "task", "first task"], { cwd: repoA, env: buildEnv(binDir) }).status, 0);
+ const resumed = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "follow up"], {
+ cwd: repoB,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(resumed.status, 0, resumed.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(path.resolve(fakeState.threads.find((thread) => thread.id === "thr_1").cwd), path.resolve(repoB));
+ assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
+});
+
+test("task --resume-thread can continue without an explicit prompt", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+ assert.equal(run("node", [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }).status, 0);
+
+ const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
+ assert.equal(fakeState.lastTurnStart.prompt, "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.");
+});
+test("task --resume-thread rejects conflicting routing controls", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ const env = buildEnv(binDir);
+
+ for (const args of [
+ ["task", "--resume-thread", "thr_1", "--resume-last", "follow up"],
+ ["task", "--resume-thread", "thr_1", "--fresh", "follow up"]
+ ]) {
+ const result = run("node", [SCRIPT, ...args], { cwd: repo, env });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Choose only one of --resume\/--resume-last, --resume-thread , or --fresh/);
+ }
+});
+
test("task-resume-candidate returns the latest rescue thread from the current session", () => {
const workspace = makeTempDir();
const stateDir = resolveStateDir(workspace);
@@ -596,132 +792,588 @@ test("task --resume-last does not resume a task from another Claude session", ()
});
assert.equal(firstRun.status, 0, firstRun.stderr);
- const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], {
+ const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], {
+ cwd: repo,
+ env: currentEnv
+ });
+ assert.equal(candidate.status, 0, candidate.stderr);
+ assert.equal(JSON.parse(candidate.stdout).available, false);
+
+ const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
+ cwd: repo,
+ env: currentEnv
+ });
+ assert.equal(resume.status, 1);
+ assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
+
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
+ assert.equal(fakeState.lastTurnStart.prompt, "initial task");
+});
+
+test("task --resume-last ignores running tasks from other Claude sessions", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const stateDir = resolveStateDir(repo);
+ fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true });
+ fs.writeFileSync(
+ path.join(stateDir, "state.json"),
+ `${JSON.stringify(
+ {
+ version: 1,
+ config: { stopReviewGate: false },
+ jobs: [
+ {
+ id: "task-other-running",
+ status: "running",
+ title: "Codex Task",
+ jobClass: "task",
+ sessionId: "sess-other",
+ threadId: "thr_other",
+ summary: "Other session active task",
+ updatedAt: "2026-03-24T20:05:00.000Z"
+ }
+ ]
+ },
+ null,
+ 2
+ )}\n`,
+ "utf8"
+ );
+
+ const env = {
+ ...buildEnv(binDir),
+ CODEX_COMPANION_SESSION_ID: "sess-current"
+ };
+ const status = run("node", [SCRIPT, "status", "--json"], {
+ cwd: repo,
+ env
+ });
+ assert.equal(status.status, 0, status.stderr);
+ assert.deepEqual(JSON.parse(status.stdout).running, []);
+
+ const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
+ cwd: repo,
+ env
+ });
+ assert.equal(resume.status, 1);
+ assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
+});
+
+test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => {
+ const repo = makeTempDir();
+ const envFile = path.join(makeTempDir(), "claude-env.sh");
+ fs.writeFileSync(envFile, "", "utf8");
+ const pluginDataDir = makeTempDir();
+ const transcriptPath = path.join(repo, "session.jsonl");
+
+ const result = run("node", [SESSION_HOOK, "SessionStart"], {
+ cwd: repo,
+ env: {
+ ...process.env,
+ CLAUDE_ENV_FILE: envFile,
+ CLAUDE_PLUGIN_DATA: pluginDataDir
+ },
+ input: JSON.stringify({
+ hook_event_name: "SessionStart",
+ session_id: "sess-current",
+ transcript_path: transcriptPath,
+ cwd: repo
+ })
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(
+ fs.readFileSync(envFile, "utf8"),
+ `export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
+ );
+});
+
+test("write task output focuses on the Codex result without generic follow-up hints", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const result = run("node", [SCRIPT, "task", "--write", "fix the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n");
+});
+
+test("task --read-root sends a scoped permission profile without legacy sandbox", () => {
+ const repo = makeTempDir();
+ const extraReadRoot = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+
+ const result = run(
+ "node",
+ [SCRIPT, "task", "--write", "--read-root", repo, "--read-root", extraReadRoot, "fix the test"],
+ { cwd: repo, env: buildEnv(binDir) }
+ );
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ const params = fakeState.lastThreadStart;
+ const profile = params.config.permissions.claude_companion_scoped;
+ assert.equal(params.sandbox, undefined);
+ assert.equal(params.config.default_permissions, "claude_companion_scoped");
+ assert.equal(profile.filesystem[":root"], "deny");
+ assert.equal(profile.filesystem[":minimal"], "read");
+ assert.equal(profile.filesystem[":tmpdir"], "deny");
+ assert.equal(profile.filesystem[":slash_tmp"], "deny");
+ assert.equal(profile.filesystem[fs.realpathSync(repo)], "read");
+ assert.equal(profile.filesystem[fs.realpathSync(extraReadRoot)], "read");
+ assert.equal(profile.extends, ":workspace");
+});
+
+test("task --write requires the approved read scope to cover the workspace", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.mkdirSync(path.join(repo, "src"));
+
+ const result = run("node", [SCRIPT, "task", "--write", "--read-root", "src", "fix"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /--write requires an approved --read-root that covers the workspace/);
+});
+
+test("task --resume-last reapplies the approved read roots", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+
+ const first = run("node", [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) });
+ assert.equal(first.status, 0, first.stderr);
+
+ const result = run("node", [SCRIPT, "task", "--resume-last", "--read-root", repo, "follow up"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadResume.sandbox, undefined);
+ assert.equal(fakeState.lastThreadResume.config.default_permissions, "claude_companion_scoped");
+ assert.equal(
+ fakeState.lastThreadResume.config.permissions.claude_companion_scoped.filesystem[fs.realpathSync(repo)],
+ "read"
+ );
+});
+
+test("task --read-root fails closed when permission profiles are unsupported", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir, "permission-profiles-unsupported");
+ initGitRepo(repo);
+
+ const result = run("node", [SCRIPT, "task", "--read-root", repo, "inspect the file"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /cannot enforce the requested read scope/i);
+ assert.match(result.stderr, /0\.138\.0 or later/);
+});
+
+test("task --read-root rejects files and missing directories before starting Codex", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "allowed.txt"), "fixture\n");
+
+ for (const readRoot of ["", "allowed.txt", "missing-directory"]) {
+ const result = run("node", [SCRIPT, "task", "--read-root", readRoot, "inspect"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /--read-root must name an existing directory/);
+ }
+});
+
+test("task --resume acts like --resume-last without leaking the flag into the prompt", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const firstRun = run("node", [SCRIPT, "task", "initial task"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+ assert.equal(firstRun.status, 0, firstRun.stderr);
+
+ const result = run("node", [SCRIPT, "task", "--resume", "follow up"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
+ assert.equal(fakeState.lastTurnStart.prompt, "follow up");
+});
+
+test("task --fresh is treated as routing control and does not leak into the prompt", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const result = run("node", [SCRIPT, "task", "--fresh", "diagnose the flaky test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test");
+});
+
+test("task forwards model selection and reasoning effort to app-server turn/start", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark");
+ assert.equal(fakeState.lastTurnStart.effort, "low");
+});
+
+test("task defaults to a read-only sandbox and --write selects workspace-write", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const readOnly = run("node", [SCRIPT, "task", "diagnose the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(readOnly.status, 0, readOnly.stderr);
+ assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastThreadStart.sandbox, "read-only");
+
+ const write = run("node", [SCRIPT, "task", "--write", "fix the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(write.status, 0, write.stderr);
+ assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastThreadStart.sandbox, "workspace-write");
+});
+
+test("task --sandbox forwards the requested sandbox mode to thread/start", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const fullAccess = run("node", [SCRIPT, "task", "--sandbox", "danger-full-access", "run the integration tests"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(fullAccess.status, 0, fullAccess.stderr);
+ let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access");
+ assert.equal(fakeState.lastThreadStart.approvalPolicy, "never");
+ assert.equal(fakeState.lastTurnStart.prompt, "run the integration tests");
+
+ const readOnly = run("node", [SCRIPT, "task", "--sandbox", "READ-ONLY", "review the diff"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(readOnly.status, 0, readOnly.stderr);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "read-only");
+});
+
+test("task reads --sandbox only before the task text", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const mentioned = run("node", [SCRIPT, "task", "explain how --sandbox danger-full-access works in this plugin"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(mentioned.status, 0, mentioned.stderr);
+ let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "read-only");
+ assert.equal(fakeState.lastTurnStart.prompt, "explain how --sandbox danger-full-access works in this plugin");
+
+ const splitTokens = run("node", [SCRIPT, "task", "document", "why", "--sandbox", "danger-full-access", "is", "off"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(splitTokens.status, 0, splitTokens.stderr);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "read-only");
+ assert.equal(fakeState.lastTurnStart.prompt, "document why --sandbox danger-full-access is off");
+
+ const leading = run("node", [SCRIPT, "task", "-m", "spark", "--sandbox=danger-full-access", "--write", "run the integration tests"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(leading.status, 0, leading.stderr);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access");
+ assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark");
+ assert.equal(fakeState.lastTurnStart.prompt, "run the integration tests");
+
+ const quoted = run("node", [SCRIPT, "task", "--sandbox read-only '{\"key\":\"value\"}'"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(quoted.status, 0, quoted.stderr);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "read-only");
+ assert.equal(fakeState.lastTurnStart.prompt, '{"key":"value"}');
+});
+
+test("task --sandbox rejects unknown modes and takes precedence over --write", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const unknown = run("node", [SCRIPT, "task", "--sandbox", "everything", "diagnose the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(unknown.status > 0, true);
+ assert.match(unknown.stderr, /Unsupported sandbox mode "everything"/);
+ assert.match(unknown.stderr, /read-only, workspace-write, danger-full-access/);
+
+ const suffixed = run("node", [SCRIPT, "task", "--sandbox=danger-full-access=false", "diagnose the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(suffixed.status > 0, true);
+ assert.match(suffixed.stderr, /Unsupported sandbox mode "danger-full-access=false"/);
+
+ for (const argv of [["--sandbox=", "diagnose the failing test"], ["--resume", "--sandbox"]]) {
+ const empty = run("node", [SCRIPT, "task", ...argv], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(empty.status > 0, true, argv.join(" "));
+ assert.match(empty.stderr, /Missing value for --sandbox/);
+ }
+
+ const malformedBoolean = run("node", [SCRIPT, "task", "--write=false=x", "fix the failing test"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(malformedBoolean.status > 0, true);
+ assert.match(malformedBoolean.stderr, /Invalid value for --write: expected true or false/);
+
+ const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : [];
+ assert.equal(threads.length, 0);
+
+ const explicit = run("node", [SCRIPT, "task", "--write", "--sandbox", "read-only", "fix the failing test"], {
cwd: repo,
- env: currentEnv
+ env: buildEnv(binDir)
});
- assert.equal(candidate.status, 0, candidate.stderr);
- assert.equal(JSON.parse(candidate.stdout).available, false);
- const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
+ assert.equal(explicit.status, 0, explicit.stderr);
+ let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "read-only");
+ assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test");
+
+ const writeFalse = run("node", [SCRIPT, "task", "--write=false", "diagnose the failing test"], {
cwd: repo,
- env: currentEnv
+ env: buildEnv(binDir)
});
- assert.equal(resume.status, 1);
- assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
- const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
- assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
- assert.equal(fakeState.lastTurnStart.prompt, "initial task");
+ assert.equal(writeFalse.status, 0, writeFalse.stderr);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "read-only");
});
-test("task --resume-last ignores running tasks from other Claude sessions", () => {
+test("task --resume-last refuses a sandbox the app-server does not grant the resumed thread", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
installFakeCodex(binDir);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
- const stateDir = resolveStateDir(repo);
- fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true });
- fs.writeFileSync(
- path.join(stateDir, "state.json"),
- `${JSON.stringify(
- {
- version: 1,
- config: { stopReviewGate: false },
- jobs: [
- {
- id: "task-other-running",
- status: "running",
- title: "Codex Task",
- jobClass: "task",
- sessionId: "sess-other",
- threadId: "thr_other",
- summary: "Other session active task",
- updatedAt: "2026-03-24T20:05:00.000Z"
- }
- ]
- },
- null,
- 2
- )}\n`,
- "utf8"
- );
+ const firstRun = run("node", [SCRIPT, "task", "--sandbox", "danger-full-access", "initial task"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+ assert.equal(firstRun.status, 0, firstRun.stderr);
- const env = {
- ...buildEnv(binDir),
- CODEX_COMPANION_SESSION_ID: "sess-current"
- };
- const status = run("node", [SCRIPT, "status", "--json"], {
+ const resumedWithSandbox = run("node", [SCRIPT, "task", "--resume", "--sandbox", "danger-full-access", "follow up"], {
cwd: repo,
- env
+ env: buildEnv(binDir)
});
- assert.equal(status.status, 0, status.stderr);
- assert.deepEqual(JSON.parse(status.stdout).running, []);
- const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
+ assert.equal(resumedWithSandbox.status, 0, resumedWithSandbox.stderr);
+ let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadResume.threadId, "thr_1");
+ assert.equal(fakeState.lastThreadResume.sandbox, "danger-full-access");
+ assert.equal(fakeState.lastTurnStart.prompt, "follow up");
+
+ const resumedWithoutSandbox = run("node", [SCRIPT, "task", "--resume", "keep going"], {
cwd: repo,
- env
+ env: buildEnv(binDir)
});
- assert.equal(resume.status, 1);
- assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
-});
-test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => {
- const repo = makeTempDir();
- const envFile = path.join(makeTempDir(), "claude-env.sh");
- fs.writeFileSync(envFile, "", "utf8");
- const pluginDataDir = makeTempDir();
- const transcriptPath = path.join(repo, "session.jsonl");
+ assert.equal(resumedWithoutSandbox.status > 0, true);
+ assert.match(resumedWithoutSandbox.stderr, /still has sandbox danger-full-access in the shared app-server/);
+ assert.match(resumedWithoutSandbox.stderr, /would not run read-only/);
+ assert.match(resumedWithoutSandbox.stderr, /--fresh/);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadResume.sandbox, "read-only");
+ assert.equal(fakeState.lastTurnStart.prompt, "follow up");
- const result = run("node", [SESSION_HOOK, "SessionStart"], {
+ const narrowedWithWrite = run("node", [SCRIPT, "task", "--resume", "--write", "apply the fix"], {
cwd: repo,
- env: {
- ...process.env,
- CLAUDE_ENV_FILE: envFile,
- CLAUDE_PLUGIN_DATA: pluginDataDir
- },
- input: JSON.stringify({
- hook_event_name: "SessionStart",
- session_id: "sess-current",
- transcript_path: transcriptPath,
- cwd: repo
- })
+ env: buildEnv(binDir)
});
- assert.equal(result.status, 0, result.stderr);
- assert.equal(
- fs.readFileSync(envFile, "utf8"),
- `export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
- );
+ assert.equal(narrowedWithWrite.status > 0, true);
+ assert.match(narrowedWithWrite.stderr, /would not run workspace-write/);
+ fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.prompt, "follow up");
});
-test("write task output focuses on the Codex result without generic follow-up hints", () => {
+test("task-worker replays a stored request without a sandbox field using the --write mapping", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
installFakeCodex(binDir);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
- const result = run("node", [SCRIPT, "task", "--write", "fix the failing test"], {
+ const jobsDir = path.join(resolveStateDir(repo), "jobs");
+ fs.mkdirSync(jobsDir, { recursive: true });
+ const jobId = "task-legacy";
+ fs.writeFileSync(
+ path.join(jobsDir, `${jobId}.json`),
+ `${JSON.stringify(
+ {
+ id: jobId,
+ kind: "task",
+ kindLabel: "task",
+ status: "queued",
+ phase: "queued",
+ title: "Codex Task",
+ jobClass: "task",
+ summary: "fix the failing test",
+ workspaceRoot: repo,
+ write: true,
+ createdAt: "2026-03-18T15:30:00.000Z",
+ updatedAt: "2026-03-18T15:30:00.000Z",
+ request: {
+ cwd: repo,
+ model: null,
+ effort: null,
+ prompt: "fix the failing test",
+ write: true,
+ resumeLast: false,
+ jobId
+ }
+ },
+ null,
+ 2
+ )}\n`,
+ "utf8"
+ );
+
+ const result = run("node", [SCRIPT, "task-worker", "--cwd", repo, "--job-id", jobId], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
- assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n");
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write");
+ assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test");
});
-test("task --resume acts like --resume-last without leaking the flag into the prompt", () => {
+test("task --resume-last refuses a resume when the app-server reports a sandbox policy it cannot compare", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
const statePath = path.join(binDir, "fake-codex-state.json");
- installFakeCodex(binDir);
+ installFakeCodex(binDir, "external-sandbox");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
@@ -733,18 +1385,20 @@ test("task --resume acts like --resume-last without leaking the flag into the pr
});
assert.equal(firstRun.status, 0, firstRun.stderr);
- const result = run("node", [SCRIPT, "task", "--resume", "follow up"], {
+ const resumed = run("node", [SCRIPT, "task", "--resume", "follow up"], {
cwd: repo,
env: buildEnv(binDir)
});
- assert.equal(result.status, 0, result.stderr);
+ assert.equal(resumed.status > 0, true);
+ assert.match(resumed.stderr, /sandbox policy \(externalSandbox\) this plugin cannot compare with the requested read-only/);
+ assert.match(resumed.stderr, /--fresh/);
const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
- assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
- assert.equal(fakeState.lastTurnStart.prompt, "follow up");
+ assert.equal(fakeState.lastThreadResume.threadId, "thr_1");
+ assert.equal(fakeState.lastTurnStart.prompt, "initial task");
});
-test("task --fresh is treated as routing control and does not leak into the prompt", () => {
+test("task --background stores the sandbox in the job request so the detached worker reuses it", async () => {
const repo = makeTempDir();
const binDir = makeTempDir();
const statePath = path.join(binDir, "fake-codex-state.json");
@@ -754,35 +1408,47 @@ test("task --fresh is treated as routing control and does not leak into the prom
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
- const result = run("node", [SCRIPT, "task", "--fresh", "diagnose the flaky test"], {
- cwd: repo,
- env: buildEnv(binDir)
- });
+ const launched = run(
+ "node",
+ [SCRIPT, "task", "--background", "--json", "--sandbox", "danger-full-access", "run the integration tests"],
+ {
+ cwd: repo,
+ env: buildEnv(binDir)
+ }
+ );
- assert.equal(result.status, 0, result.stderr);
- const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
- assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test");
-});
+ assert.equal(launched.status, 0, launched.stderr);
+ const launchPayload = JSON.parse(launched.stdout);
+ assert.equal(launchPayload.status, "queued");
-test("task forwards model selection and reasoning effort to app-server turn/start", () => {
- const repo = makeTempDir();
- const binDir = makeTempDir();
- const statePath = path.join(binDir, "fake-codex-state.json");
- installFakeCodex(binDir);
- initGitRepo(repo);
- fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
- run("git", ["add", "README.md"], { cwd: repo });
- run("git", ["commit", "-m", "init"], { cwd: repo });
+ const waitedStatus = run(
+ "node",
+ [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"],
+ {
+ cwd: repo,
+ env: buildEnv(binDir)
+ }
+ );
- const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], {
- cwd: repo,
- env: buildEnv(binDir)
- });
+ assert.equal(waitedStatus.status, 0, waitedStatus.stderr);
+ assert.equal(JSON.parse(waitedStatus.stdout).job.status, "completed");
- assert.equal(result.status, 0, result.stderr);
const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
- assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark");
- assert.equal(fakeState.lastTurnStart.effort, "low");
+ assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access");
+
+ const resultPayload = await waitFor(() => {
+ const result = run("node", [SCRIPT, "result", launchPayload.jobId, "--json"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+ if (result.status !== 0) {
+ return null;
+ }
+ return JSON.parse(result.stdout);
+ });
+
+ assert.equal(resultPayload.storedJob.request.sandbox, "danger-full-access");
+ assert.equal(resultPayload.storedJob.request.write, true);
});
test("task logs reasoning summaries and assistant messages to the job log", () => {
@@ -990,6 +1656,64 @@ test("task --background enqueues a detached worker and exposes per-job status",
assert.match(resultPayload.storedJob.rendered, /Handled the requested task/);
});
+test("task --background preserves an explicit resume thread", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+ const env = buildEnv(binDir);
+
+ assert.equal(run("node", [SCRIPT, "task", "first task"], { cwd: repo, env }).status, 0);
+ assert.equal(run("node", [SCRIPT, "task", "second task"], { cwd: repo, env }).status, 0);
+
+ const launched = run(
+ "node",
+ [SCRIPT, "task", "--background", "--json", "--resume-thread", "thr_1", "follow up"],
+ { cwd: repo, env }
+ );
+ assert.equal(launched.status, 0, launched.stderr);
+ const launchPayload = JSON.parse(launched.stdout);
+ const waited = run(
+ "node",
+ [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"],
+ { cwd: repo, env }
+ );
+ assert.equal(waited.status, 0, waited.stderr);
+ assert.equal(JSON.parse(waited.stdout).job.status, "completed");
+
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
+ assert.equal(fakeState.lastTurnStart.prompt, "follow up");
+});
+
+test("task --background preserves read roots for the detached worker", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const statePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+
+ const launched = run("node", [SCRIPT, "task", "--background", "--json", "--read-root", repo, "inspect"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+ assert.equal(launched.status, 0, launched.stderr);
+ const jobId = JSON.parse(launched.stdout).jobId;
+ const waited = run("node", [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "15000", "--json"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+
+ assert.equal(waited.status, 0, waited.stderr);
+ const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
+ assert.equal(fakeState.lastThreadStart.sandbox, undefined);
+ assert.equal(fakeState.lastThreadStart.config.default_permissions, "claude_companion_scoped");
+});
+
test("review rejects focus text because it is native-review only", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
@@ -4347,6 +5071,52 @@ test("stop hook runs a stop-time review task and blocks on findings when the rev
assert.match(status.stdout, /Codex Stop Gate Review/);
});
+test("stop hook does not re-run the review and does not block on a forced retry (stop_hook_active)", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const fakeStatePath = path.join(binDir, "fake-codex-state.json");
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+
+ const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], {
+ cwd: repo,
+ env: buildEnv(binDir)
+ });
+ assert.equal(setup.status, 0, setup.stderr);
+
+ // `appServerStarts` only increments when the fake Codex binary's
+ // `app-server` subcommand actually runs (i.e. a real review turn was
+ // spawned). Snapshot it now so we can prove below that the forced retry
+ // does not spawn a second review.
+ const appServerStartsBeforeRetry = fs.existsSync(fakeStatePath)
+ ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0
+ : 0;
+
+ const retried = run("node", [STOP_HOOK], {
+ cwd: repo,
+ env: buildEnv(binDir),
+ input: JSON.stringify({
+ cwd: repo,
+ session_id: "sess-stop-review-retry",
+ stop_hook_active: true,
+ last_assistant_message: "I completed the refactor and updated the retry logic."
+ })
+ });
+
+ assert.equal(retried.status, 0, retried.stderr);
+ const payload = JSON.parse(retried.stdout.trim());
+ assert.equal(payload.decision, undefined);
+ assert.match(payload.systemMessage, /skipped/i);
+
+ const appServerStartsAfterRetry = fs.existsSync(fakeStatePath)
+ ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0
+ : 0;
+ assert.equal(appServerStartsAfterRetry, appServerStartsBeforeRetry);
+});
+
test("stop hook logs running tasks to stderr without blocking when the review gate is disabled", () => {
const repo = makeTempDir();
initGitRepo(repo);
@@ -4404,6 +5174,34 @@ test("stop hook logs running tasks to stderr without blocking when the review ga
assert.match(blocked.stderr, /\/codex:cancel task-live/i);
});
+test("review gate enabled under one plugin-data root is enforced by Stop under another", () => {
+ const repo = makeTempDir();
+ const binDir = makeTempDir();
+ const codexHome = makeTempDir();
+ const pluginDataSetup = makeTempDir();
+ const pluginDataStop = makeTempDir();
+ installFakeCodex(binDir);
+ initGitRepo(repo);
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
+ run("git", ["add", "README.md"], { cwd: repo });
+ run("git", ["commit", "-m", "init"], { cwd: repo });
+ const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], {
+ cwd: repo,
+ env: { ...buildEnv(binDir), CODEX_HOME: codexHome, CLAUDE_PLUGIN_DATA: pluginDataSetup }
+ });
+ assert.equal(setup.status, 0, setup.stderr);
+ assert.equal(JSON.parse(setup.stdout).reviewGateEnabled, true);
+ const stopped = run("node", [STOP_HOOK], {
+ cwd: repo,
+ env: { ...buildEnv(binDir), CODEX_HOME: codexHome, CLAUDE_PLUGIN_DATA: pluginDataStop, CODEX_COMPANION_SESSION_ID: "sess-cross-root" },
+ input: JSON.stringify({ cwd: repo, session_id: "sess-cross-root", last_assistant_message: "I completed the change." })
+ });
+ assert.equal(stopped.status, 0, stopped.stderr);
+ const payload = JSON.parse(stopped.stdout);
+ assert.equal(payload.decision, "block");
+ assert.match(payload.reason, /Codex stop-time review found issues/i);
+});
+
test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
@@ -4436,8 +5234,10 @@ test("stop hook does not block when Codex is unavailable even if the review gate
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
+ const codexHome = makeTempDir();
const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], {
- cwd: repo
+ cwd: repo,
+ env: { ...process.env, CODEX_HOME: codexHome }
});
assert.equal(setup.status, 0, setup.stderr);
@@ -4445,6 +5245,7 @@ test("stop hook does not block when Codex is unavailable even if the review gate
cwd: repo,
env: {
...process.env,
+ CODEX_HOME: codexHome,
PATH: ""
},
input: JSON.stringify({ cwd: repo })
diff --git a/tests/state.test.mjs b/tests/state.test.mjs
index fcc1312be..8b1a3903f 100644
--- a/tests/state.test.mjs
+++ b/tests/state.test.mjs
@@ -9,6 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { makeTempDir } from "./helpers.mjs";
import {
+ getConfig,
listJobs,
resolveJobFile,
resolveJobLogFile,
@@ -16,9 +17,10 @@ import {
resolveStateDir,
resolveStateFile,
saveState,
+ setConfig,
writeJobFile
} from "../plugins/codex/scripts/lib/state.mjs";
-import { createJobLogFile } from "../plugins/codex/scripts/lib/tracked-jobs.mjs";
+import { createJobLogFile, runTrackedJob } from "../plugins/codex/scripts/lib/tracked-jobs.mjs";
test("resolveStateDir uses a temp-backed per-workspace directory", () => {
const workspace = makeTempDir();
@@ -70,6 +72,28 @@ test("state, job, and log artifacts are private", { skip: process.platform === "
assert.equal(fs.statSync(logFile).mode & 0o777, 0o600);
});
+test("review-gate config remains authoritative when CLAUDE_PLUGIN_DATA changes", () => {
+ const workspace = makeTempDir();
+ const codexHome = makeTempDir();
+ const pluginDataA = makeTempDir();
+ const pluginDataB = makeTempDir();
+ const previousCodexHome = process.env.CODEX_HOME;
+ const previousPluginData = process.env.CLAUDE_PLUGIN_DATA;
+ try {
+ process.env.CODEX_HOME = codexHome;
+ process.env.CLAUDE_PLUGIN_DATA = pluginDataA;
+ setConfig(workspace, "stopReviewGate", true);
+ process.env.CLAUDE_PLUGIN_DATA = pluginDataB;
+ assert.equal(getConfig(workspace).stopReviewGate, true);
+ setConfig(workspace, "stopReviewGate", false);
+ process.env.CLAUDE_PLUGIN_DATA = pluginDataA;
+ assert.equal(getConfig(workspace).stopReviewGate, false);
+ } finally {
+ if (previousCodexHome == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome;
+ if (previousPluginData == null) delete process.env.CLAUDE_PLUGIN_DATA; else process.env.CLAUDE_PLUGIN_DATA = previousPluginData;
+ }
+});
+
test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => {
const workspace = makeTempDir();
const stateFile = resolveStateFile(workspace);
@@ -283,3 +307,91 @@ test("writeJobFile never exposes a torn record to a concurrent reader", async (t
assert.equal(exitCode, 0, `concurrent reader observed a torn job record: ${readerErr.trim()}`);
assert.deepEqual(readJobFile(jobFile).id, jobId);
});
+
+test("runTrackedJob persists errorMessage on a non-throwing failed execution", async () => {
+ const workspace = makeTempDir();
+ const job = {
+ id: "task-fail-nonthrowing",
+ kind: "task",
+ kindLabel: "Task",
+ title: "Codex Task",
+ workspaceRoot: workspace,
+ jobClass: "task",
+ summary: "",
+ write: false,
+ createdAt: new Date().toISOString()
+ };
+
+ await runTrackedJob(
+ job,
+ async () => ({
+ exitStatus: 400,
+ threadId: "thread-1",
+ turnId: "turn-1",
+ payload: { status: 400, rawOutput: "{\n \"type\": \"error\"\n}", touchedFiles: [] },
+ rendered: "rendered output\n",
+ summary: "Unsupported value: 'x' is not supported.",
+ errorMessage: "Unsupported value: 'x' is not supported with the 'gpt-5.6-terra' model."
+ }),
+ {}
+ );
+
+ const storedJob = JSON.parse(
+ fs.readFileSync(resolveJobFile(workspace, job.id), "utf8")
+ );
+ const state = JSON.parse(fs.readFileSync(resolveStateFile(workspace), "utf8"));
+ const indexed = state.jobs.find((entry) => entry.id === job.id);
+
+ assert.equal(storedJob.status, "failed");
+ assert.equal(storedJob.phase, "failed");
+ assert.equal(
+ storedJob.errorMessage,
+ "Unsupported value: 'x' is not supported with the 'gpt-5.6-terra' model."
+ );
+ assert.equal(indexed.status, "failed");
+ assert.equal(
+ indexed.errorMessage,
+ "Unsupported value: 'x' is not supported with the 'gpt-5.6-terra' model."
+ );
+ assert.equal(indexed.summary, "Unsupported value: 'x' is not supported.");
+});
+
+test("runTrackedJob stores no errorMessage for a completed execution", async () => {
+ const workspace = makeTempDir();
+ const job = {
+ id: "task-ok",
+ kind: "task",
+ kindLabel: "Task",
+ title: "Codex Task",
+ workspaceRoot: workspace,
+ jobClass: "task",
+ summary: "",
+ write: false,
+ createdAt: new Date().toISOString()
+ };
+
+ await runTrackedJob(
+ job,
+ async () => ({
+ exitStatus: 0,
+ threadId: "thread-2",
+ turnId: "turn-2",
+ payload: { status: 0, rawOutput: "OK", touchedFiles: [] },
+ rendered: "OK\n",
+ summary: "OK",
+ errorMessage: null
+ }),
+ {}
+ );
+
+ const storedJob = JSON.parse(
+ fs.readFileSync(resolveJobFile(workspace, job.id), "utf8")
+ );
+ const state = JSON.parse(fs.readFileSync(resolveStateFile(workspace), "utf8"));
+ const indexed = state.jobs.find((entry) => entry.id === job.id);
+
+ assert.equal(storedJob.status, "completed");
+ assert.equal(storedJob.errorMessage, null);
+ assert.equal(indexed.status, "completed");
+ assert.equal(indexed.errorMessage, null);
+});
]