Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 96 additions & 8 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getConfig,
listJobs,
setConfig,
setStateDirOverride,
upsertJob,
writeJobFile
} from "./lib/state.mjs";
Expand Down Expand Up @@ -74,13 +75,33 @@ function printUsage() {
console.log(
[
"Usage:",
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]",
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
" node scripts/codex-companion.mjs [--state-dir <abs-path>] setup [--enable-review-gate|--disable-review-gate] [--json]",
" node scripts/codex-companion.mjs [--state-dir <abs-path>] review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs [--state-dir <abs-path>] adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs [--state-dir <abs-path>] task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs [--state-dir <abs-path>] status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs [--state-dir <abs-path>] result [job-id] [--json]",
" node scripts/codex-companion.mjs [--state-dir <abs-path>] cancel [job-id] [--json]",
"",
"Global flag:",
" --state-dir <abs-path> Override the per-workspace state directory",
" (state.json, jobs/, broker.json). The value MUST",
" be an absolute path; relative paths, whitespace-",
" containing values, and option-looking tokens are",
" left as positionals. Accepted anywhere in argv",
" when shell-tokenized into separate elements.",
" Equivalent to exporting",
" CODEX_COMPANION_STATE_DIR=<abs-path>. Child",
" processes (background workers, lifecycle hooks)",
" inherit the override via the env var.",
"",
" Note: the plugin's /codex:review,",
" /codex:adversarial-review, and /codex:task slash",
" commands pass user arguments as a QUOTED single",
" string, so the CLI flag form is NOT extracted",
" from those invocations. For those, set the",
" env var instead (before launching Claude Code,",
" so lifecycle hooks inherit it too)."
].join("\n")
);
}
Expand Down Expand Up @@ -978,8 +999,75 @@ async function handleCancel(argv) {
outputCommandResult(payload, renderCancelReport(nextJob), options.json);
}

// Scan `tokens` for `--state-dir <abs-path>` and `--state-dir=<abs-path>`
// pairs; when value passes `path.isAbsolute()` AND is whitespace-free, set
// the override (via env var, see lib/state.mjs) and strip the pair from the
// returned tokens. Non-absolute / option-looking / whitespace-containing
// values are left as positionals. Stops at `--` (passthrough). Pure
// function except for the setStateDirOverride side effect.
function extractStateDirFlag(tokens) {
const filtered = [];
let afterPassthrough = false;
for (let i = 0; i < tokens.length; i += 1) {
const tok = tokens[i];
if (afterPassthrough) {
filtered.push(tok);
continue;
}
if (tok === "--") {
afterPassthrough = true;
filtered.push(tok);
continue;
}
if (tok === "--state-dir") {
const value = tokens[i + 1];
// Separate-token form: next argv element is the value. Already split
// by the shell, so whitespace cannot appear in a single token unless
// the user explicitly quoted it on the command line (rare; treated
// as a positional via the !/\s/ check).
if (value && path.isAbsolute(value) && !/\s/.test(value)) {
setStateDirOverride(value);
i += 1;
continue;
}
filtered.push(tok);
continue;
}
if (tok.startsWith("--state-dir=")) {
const value = tok.slice("--state-dir=".length);
// Inline-equals form: value is in the same argv element. Reject
// whitespace-containing values defensively — `path.isAbsolute()`
// returns true for paths like `/tmp/foo extra-text`, but consuming
// such a token would silently truncate the user's actual prompt.
if (value && path.isAbsolute(value) && !/\s/.test(value)) {
setStateDirOverride(value);
continue;
}
filtered.push(tok);
continue;
}
filtered.push(tok);
}
return filtered;
}

async function main() {
const [subcommand, ...argv] = process.argv.slice(2);
// Extract the `--state-dir <abs-path>` global flag from anywhere in argv
// (before OR after the subcommand). Only ABSOLUTE-PATH, whitespace-free
// values are extracted; everything else is left as a positional for the
// subcommand handler.
//
// This works for direct CLI invocation and for the plugin's UNQUOTED
// `$ARGUMENTS` slash commands (setup, status, result, cancel) where bash
// word-splits user args into separate argv elements.
//
// The QUOTED `$ARGUMENTS` slash commands (review, adversarial-review,
// task) place all user args as a SINGLE argv element after the subcommand;
// the flag is NOT extracted in that case. For those, use the env-var form
// instead: `export CODEX_COMPANION_STATE_DIR=<abs-path>` before invoking
// Claude Code.
const filteredArgv = extractStateDirFlag(process.argv.slice(2));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support quoted status arguments for state-dir extraction

This extraction runs on the raw process.argv before the per-command normalizeArgv splitting, so quoted slash-command arguments remain a single token and --state-dir is never recognized. I checked the slash command definitions and plugins/codex/commands/status.md, result.md, and cancel.md all invoke the companion as ... <subcommand> "$ARGUMENTS"; therefore /codex:status --state-dir /tmp/foo leaves the override unset and treats the flag as a job id/default-state lookup instead of reading the requested state directory. Either split/extract for these non-prompt commands or document that their slash-command form also requires the env var.

Useful? React with 👍 / 👎.

const [subcommand, ...argv] = filteredArgv;
if (!subcommand || subcommand === "help" || subcommand === "--help") {
printUsage();
return;
Expand Down
49 changes: 49 additions & 0 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,56 @@ function defaultState() {
};
}

// Optional process-wide override for the state directory. Backed by the
// `CODEX_COMPANION_STATE_DIR` environment variable so it propagates to child
// processes (background task workers and session lifecycle hooks read the
// same value). When set, `resolveStateDir()` returns the override verbatim,
// bypassing the workspace-root hashing.
//
// The value MUST be (or be canonicalizable to) an absolute path. Relative
// values are resolved against the importing process's cwd on first read and
// the canonical absolute path is written back to `process.env` so child
// processes inherit a stable value. This prevents the same env var from
// resolving to different absolute paths in parent vs child processes.
//
// Set programmatically via `setStateDirOverride(dir)` or directly by exporting
// `CODEX_COMPANION_STATE_DIR=/absolute/path` in the calling shell before
// invoking Claude Code (necessary if you want session lifecycle hooks to
// honor the override too). The `--state-dir <abs-path>` CLI flag on
// `codex-companion.mjs main()` calls `setStateDirOverride()` for you.
const STATE_DIR_OVERRIDE_ENV = "CODEX_COMPANION_STATE_DIR";

export function setStateDirOverride(dir) {
if (dir == null || dir === "") {
delete process.env[STATE_DIR_OVERRIDE_ENV];
return;
}
// path.resolve() canonicalizes relative paths against process.cwd().
process.env[STATE_DIR_OVERRIDE_ENV] = path.resolve(dir);
}

export function getStateDirOverride() {
const raw = process.env[STATE_DIR_OVERRIDE_ENV];
if (!raw) {
return null;
}
if (path.isAbsolute(raw)) {
return raw;
}
// Canonicalize-once-and-persist: resolve against current cwd and write back
// to process.env so child processes inherit the canonical value. This
// prevents drift between parent/child processes whose cwds may differ.
const resolved = path.resolve(raw);
process.env[STATE_DIR_OVERRIDE_ENV] = resolved;
return resolved;
}

export function resolveStateDir(cwd) {
const override = getStateDirOverride();
if (override) {
return override;
}

const workspaceRoot = resolveWorkspaceRoot(cwd);
let canonicalWorkspaceRoot = workspaceRoot;
try {
Expand Down
173 changes: 173 additions & 0 deletions tests/state-dir-cli.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import assert from "node:assert/strict";

import { makeTempDir } from "./helpers.mjs";

const COMPANION_SCRIPT = path.resolve(import.meta.dirname, "../plugins/codex/scripts/codex-companion.mjs");

function runCompanion(args, opts = {}) {
const env = { ...process.env, ...(opts.env || {}) };
delete env.CODEX_COMPANION_STATE_DIR;
if (opts.env && "CODEX_COMPANION_STATE_DIR" in opts.env) {
env.CODEX_COMPANION_STATE_DIR = opts.env.CODEX_COMPANION_STATE_DIR;
}
return spawnSync(process.execPath, [COMPANION_SCRIPT, ...args], {
env,
encoding: "utf8",
windowsHide: true
});
}

// CLI tests for the `--state-dir <abs-path>` global flag and the equivalent
// CODEX_COMPANION_STATE_DIR env var. Behavior-observing where possible:
// uses `setup --enable-review-gate --json` which writes state.json so each
// assertion can prove the override actually changed state-resolution.

test("CLI: --state-dir <abs-path> BEFORE subcommand routes state.json to override", () => {
const override = makeTempDir();
const result = runCompanion(["--state-dir", override, "setup", "--enable-review-gate", "--json"]);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
const stateFile = path.join(override, "state.json");
assert.equal(fs.existsSync(stateFile), true, `expected state.json at ${stateFile}`);
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
assert.equal(parsed.config.stopReviewGate, true);
});

test("CLI: --state-dir <abs-path> AFTER subcommand routes state.json to override (unquoted-$ARGUMENTS slash form)", () => {
// Slash commands like /codex:setup, /codex:status, /codex:result, /codex:cancel
// pass user args UNQUOTED via $ARGUMENTS, so bash word-splits them into
// separate argv elements. The global parser must accept the flag at any
// position when shell-tokenized.
const override = makeTempDir();
const result = runCompanion(["setup", "--enable-review-gate", "--state-dir", override, "--json"]);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
const stateFile = path.join(override, "state.json");
assert.equal(fs.existsSync(stateFile), true, `expected state.json at ${stateFile}`);
});

test("CLI: --state-dir=<abs-path> inline-equals form (any position)", () => {
const override = makeTempDir();
const result = runCompanion(["setup", "--enable-review-gate", `--state-dir=${override}`, "--json"]);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
assert.equal(fs.existsSync(path.join(override, "state.json")), true);
});

test("CLI: env CODEX_COMPANION_STATE_DIR (absolute) routes state.json to override (no flag)", () => {
// Env-var form. Required when invoking the QUOTED-$ARGUMENTS slash commands
// (/codex:review, /codex:adversarial-review, /codex:task) where the flag
// form is NOT extracted.
const override = makeTempDir();
const result = runCompanion(["setup", "--enable-review-gate", "--json"], {
env: { CODEX_COMPANION_STATE_DIR: override }
});
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
assert.equal(fs.existsSync(path.join(override, "state.json")), true);
});

test("CLI: --state-dir with relative value is left as positional (NOT extracted)", () => {
// Only absolute paths are extracted. Relative values would cause cross-
// process drift (parent and child resolve against different cwds), so
// they are deliberately rejected at the parser layer.
const override = makeTempDir();
const result = runCompanion(["--state-dir", "relative/path", "setup", "--enable-review-gate", "--json"]);
// setup will run with whatever default state location it picks (NOT the
// relative override). We assert: NO state.json was written under our
// temp override (since we didn't pass an absolute path here).
assert.equal(fs.existsSync(path.join(override, "state.json")), false);
// setup may itself error because "relative/path" gets passed through as
// a positional argv element and setup doesn't recognize it. We don't
// assert the exit code here; only the parser-level behavior (no extraction).
});

test("CLI: --state-dir followed by option-looking value is NOT extracted", () => {
// path.isAbsolute("--json") -> false. Parser must leave --state-dir as a
// positional rather than greedily consuming the next flag as its value.
const result = runCompanion(["--state-dir", "--json", "setup", "--enable-review-gate"]);
// setup will see ["--state-dir", "--json", "--enable-review-gate"] which is
// unexpected; assert only that the parser did NOT crash with a TypeError.
assert.doesNotMatch(result.stderr, /TypeError|ReferenceError/);
});

test("CLI: --state-dir=<path-with-whitespace> is NOT extracted (defense against quoted-$ARGUMENTS misparse)", () => {
// path.isAbsolute("/tmp/foo extra-text") returns true (spaces don't break
// absolute detection), but consuming such a token would silently truncate
// the user's actual prompt. Parser defensively rejects whitespace values.
const override = makeTempDir();
// Simulate the bug shape: --state-dir=<path> <extra-text> all in one element.
const result = runCompanion([`--state-dir=${override} extra-prompt-text`, "setup", "--enable-review-gate", "--json"]);
// Override should NOT have been set (whitespace check rejects):
assert.equal(fs.existsSync(path.join(override, "state.json")), false);
});

test("CLI: subcommand prompt text mentioning --state-dir is NOT consumed when whitespace-attached", () => {
// Per parser comment: when the flag value contains whitespace (e.g., a
// prompt that legitimately mentions --state-dir followed by free-form
// text), the parser leaves the token alone. For prompts in QUOTED form
// ($ARGUMENTS), the entire prompt is one argv element so the prefix-check
// would match — but the whitespace-rejection prevents the bug.
const override = makeTempDir();
const result = runCompanion([
"adversarial-review",
"--scope",
"auto",
"--state-dir",
"fake-value-inside-prompt"
]);
// No --state-dir/jobs/ should appear at the temp dir; the relative value
// "fake-value-inside-prompt" wouldn't pass path.isAbsolute anyway.
assert.equal(fs.existsSync(path.join(override, "state.json")), false);
});

test("CLI: -- explicitly stops global parsing (passthrough)", () => {
// Even an absolute path AFTER `--` must NOT be extracted. Lets users
// disambiguate any prompt text containing the flag.
const override = makeTempDir();
const result = runCompanion(["setup", "--enable-review-gate", "--json", "--", "--state-dir", override]);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
assert.equal(
fs.existsSync(path.join(override, "state.json")),
false,
"override extraction must be suppressed after `--` passthrough"
);
});

test("CLI: duplicate --state-dir flags — last absolute value wins", () => {
const first = makeTempDir();
const second = makeTempDir();
const result = runCompanion([
"--state-dir",
first,
"--state-dir",
second,
"setup",
"--enable-review-gate",
"--json"
]);
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
// Loop is left-to-right; final setStateDirOverride call wins.
assert.equal(fs.existsSync(path.join(second, "state.json")), true);
assert.equal(fs.existsSync(path.join(first, "state.json")), false);
});

test("CLI: quoted-$ARGUMENTS form is documented limitation (use env var instead)", () => {
// The plugin's review/adversarial-review/task slash commands pass user
// args as a single quoted string ($ARGUMENTS becomes one argv element).
// The flag form does NOT work in that case — users must use the env var.
// This test documents the limitation: passing the flag inside a single
// argv element does NOT set the override.
const override = makeTempDir();
// Single-element argv after subcommand, like quoted "$ARGUMENTS":
const quotedSingleString = `--state-dir ${override} my prompt text`;
const result = runCompanion(["task", quotedSingleString]);
// Override should NOT have been set (parser doesn't tokenize within argv
// elements). Users invoking review/adversarial-review/task must use the
// env var form instead — see printUsage() output.
assert.equal(
fs.existsSync(path.join(override, "state.json")),
false,
"documented limitation: flag inside quoted $ARGUMENTS NOT extracted; use env var"
);
});
Loading