diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..574268f2 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -29,6 +29,7 @@ import { getConfig, listJobs, setConfig, + setStateDirOverride, upsertJob, writeJobFile } from "./lib/state.mjs"; @@ -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 ] [--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 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 ] setup [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/codex-companion.mjs [--state-dir ] review [--wait|--background] [--base ] [--scope ]", + " node scripts/codex-companion.mjs [--state-dir ] adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", + " node scripts/codex-companion.mjs [--state-dir ] task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs [--state-dir ] status [job-id] [--all] [--json]", + " node scripts/codex-companion.mjs [--state-dir ] result [job-id] [--json]", + " node scripts/codex-companion.mjs [--state-dir ] cancel [job-id] [--json]", + "", + "Global flag:", + " --state-dir 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=. 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") ); } @@ -978,8 +999,75 @@ async function handleCancel(argv) { outputCommandResult(payload, renderCancelReport(nextJob), options.json); } +// Scan `tokens` for `--state-dir ` and `--state-dir=` +// 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 ` 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=` before invoking + // Claude Code. + const filteredArgv = extractStateDirFlag(process.argv.slice(2)); + const [subcommand, ...argv] = filteredArgv; if (!subcommand || subcommand === "help" || subcommand === "--help") { printUsage(); return; diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498..29718b92 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -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 ` 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 { diff --git a/tests/state-dir-cli.test.mjs b/tests/state-dir-cli.test.mjs new file mode 100644 index 00000000..05f6e99c --- /dev/null +++ b/tests/state-dir-cli.test.mjs @@ -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 ` 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 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 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= 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= 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= 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" + ); +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57ce..1f68ae61 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,101 +5,222 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { + getStateDirOverride, + resolveJobFile, + resolveJobLogFile, + resolveJobsDir, + resolveStateDir, + resolveStateFile, + saveState, + setStateDirOverride +} from "../plugins/codex/scripts/lib/state.mjs"; + +const STATE_DIR_ENV = "CODEX_COMPANION_STATE_DIR"; + +function withCleanStateDirEnv(fn) { + const previous = process.env[STATE_DIR_ENV]; + delete process.env[STATE_DIR_ENV]; + try { + fn(); + } finally { + if (previous == null) { + delete process.env[STATE_DIR_ENV]; + } else { + process.env[STATE_DIR_ENV] = previous; + } + } +} test("resolveStateDir uses a temp-backed per-workspace directory", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); + withCleanStateDirEnv(() => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); - assert.equal(stateDir.startsWith(os.tmpdir()), true); - assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); - assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + assert.equal(stateDir.startsWith(os.tmpdir()), true); + assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); + assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + }); }); test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { - const workspace = makeTempDir(); - const pluginDataDir = makeTempDir(); - const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; - process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + withCleanStateDirEnv(() => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; - try { - const stateDir = resolveStateDir(workspace); + try { + const stateDir = resolveStateDir(workspace); - assert.equal(stateDir.startsWith(path.join(pluginDataDir, "state")), true); - assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); - assert.match( - stateDir, - new RegExp(`^${path.join(pluginDataDir, "state").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) - ); - } finally { - if (previousPluginDataDir == null) { - delete process.env.CLAUDE_PLUGIN_DATA; - } else { - process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + assert.equal(stateDir.startsWith(path.join(pluginDataDir, "state")), true); + assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); + assert.match( + stateDir, + new RegExp(`^${path.join(pluginDataDir, "state").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) + ); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } } - } + }); }); test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => { - const workspace = makeTempDir(); - const stateFile = resolveStateFile(workspace); - fs.mkdirSync(path.dirname(stateFile), { recursive: true }); - - const jobs = Array.from({ length: 51 }, (_, index) => { - const jobId = `job-${index}`; - const updatedAt = new Date(Date.UTC(2026, 0, 1, 0, index, 0)).toISOString(); - const logFile = resolveJobLogFile(workspace, jobId); - const jobFile = resolveJobFile(workspace, jobId); - fs.writeFileSync(logFile, `log ${jobId}\n`, "utf8"); - fs.writeFileSync(jobFile, JSON.stringify({ id: jobId, status: "completed" }, null, 2), "utf8"); - return { - id: jobId, - status: "completed", - logFile, - updatedAt, - createdAt: updatedAt - }; + withCleanStateDirEnv(() => { + const workspace = makeTempDir(); + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + + const jobs = Array.from({ length: 51 }, (_, index) => { + const jobId = `job-${index}`; + const updatedAt = new Date(Date.UTC(2026, 0, 1, 0, index, 0)).toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + const jobFile = resolveJobFile(workspace, jobId); + fs.writeFileSync(logFile, `log ${jobId}\n`, "utf8"); + fs.writeFileSync(jobFile, JSON.stringify({ id: jobId, status: "completed" }, null, 2), "utf8"); + return { + id: jobId, + status: "completed", + logFile, + updatedAt, + createdAt: updatedAt + }; + }); + + fs.writeFileSync( + stateFile, + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs + }, + null, + 2 + )}\n`, + "utf8" + ); + + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs + }); + + const prunedJobFile = resolveJobFile(workspace, "job-0"); + const prunedLogFile = resolveJobLogFile(workspace, "job-0"); + const retainedJobFile = resolveJobFile(workspace, "job-50"); + const retainedLogFile = resolveJobLogFile(workspace, "job-50"); + const jobsDir = path.dirname(prunedJobFile); + + assert.equal(fs.existsSync(retainedJobFile), true); + assert.equal(fs.existsSync(retainedLogFile), true); + + const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8")); + assert.equal(savedState.jobs.length, 50); + assert.deepEqual( + savedState.jobs.map((job) => job.id), + Array.from({ length: 50 }, (_, index) => `job-${50 - index}`) + ); + assert.deepEqual( + fs.readdirSync(jobsDir).sort(), + Array.from({ length: 50 }, (_, index) => `job-${index + 1}`) + .flatMap((jobId) => [`${jobId}.json`, `${jobId}.log`]) + .sort() + ); }); +}); + +// --- CODEX_COMPANION_STATE_DIR override tests ------------------------------- +// These tests cover the optional `--state-dir ` global flag on +// `codex-companion.mjs main()` and the equivalent `CODEX_COMPANION_STATE_DIR` +// env var that backs it. See lib/state.mjs documentation. + +test("setStateDirOverride: subsequent resolveStateDir returns the override", () => { + withCleanStateDirEnv(() => { + const override = makeTempDir(); + setStateDirOverride(override); + assert.equal(resolveStateDir(makeTempDir()), override); + assert.equal(resolveStateDir("/any/other/cwd"), override); + assert.equal(getStateDirOverride(), override); + }); +}); + +test("setStateDirOverride(null): clears override; workspace hashing resumes", () => { + withCleanStateDirEnv(() => { + const override = makeTempDir(); + const workspace = makeTempDir(); + setStateDirOverride(override); + assert.equal(resolveStateDir(workspace), override); + + setStateDirOverride(null); + const stateDir = resolveStateDir(workspace); + assert.notEqual(stateDir, override); + assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); + assert.equal(getStateDirOverride(), null); + }); +}); - fs.writeFileSync( - stateFile, - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs - }, - null, - 2 - )}\n`, - "utf8" - ); - - saveState(workspace, { - version: 1, - config: { stopReviewGate: false }, - jobs +test("setStateDirOverride: relative path is canonicalized to absolute against process.cwd()", () => { + withCleanStateDirEnv(() => { + setStateDirOverride("relative/state-dir"); + const expected = path.resolve(process.cwd(), "relative/state-dir"); + assert.equal(resolveStateDir(makeTempDir()), expected); + assert.equal(process.env[STATE_DIR_ENV], expected); }); +}); - const prunedJobFile = resolveJobFile(workspace, "job-0"); - const prunedLogFile = resolveJobLogFile(workspace, "job-0"); - const retainedJobFile = resolveJobFile(workspace, "job-50"); - const retainedLogFile = resolveJobLogFile(workspace, "job-50"); - const jobsDir = path.dirname(prunedJobFile); - - assert.equal(fs.existsSync(retainedJobFile), true); - assert.equal(fs.existsSync(retainedLogFile), true); - - const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8")); - assert.equal(savedState.jobs.length, 50); - assert.deepEqual( - savedState.jobs.map((job) => job.id), - Array.from({ length: 50 }, (_, index) => `job-${50 - index}`) - ); - assert.deepEqual( - fs.readdirSync(jobsDir).sort(), - Array.from({ length: 50 }, (_, index) => `job-${index + 1}`) - .flatMap((jobId) => [`${jobId}.json`, `${jobId}.log`]) - .sort() - ); +test("setStateDirOverride: resolveStateFile and resolveJobsDir route through override", () => { + withCleanStateDirEnv(() => { + const override = makeTempDir(); + setStateDirOverride(override); + assert.equal(resolveStateFile(makeTempDir()), path.join(override, "state.json")); + assert.equal(resolveJobsDir(makeTempDir()), path.join(override, "jobs")); + }); +}); + +test("setStateDirOverride: empty string clears override (treated as null)", () => { + withCleanStateDirEnv(() => { + const override = makeTempDir(); + setStateDirOverride(override); + assert.equal(getStateDirOverride(), override); + + setStateDirOverride(""); + assert.equal(getStateDirOverride(), null); + }); +}); + +test("setStateDirOverride: undefined clears override (treated as null)", () => { + withCleanStateDirEnv(() => { + const override = makeTempDir(); + setStateDirOverride(override); + assert.equal(getStateDirOverride(), override); + + setStateDirOverride(undefined); + assert.equal(getStateDirOverride(), null); + }); +}); + +test("CODEX_COMPANION_STATE_DIR env var directly: resolveStateDir honors absolute values", () => { + withCleanStateDirEnv(() => { + const override = makeTempDir(); + process.env[STATE_DIR_ENV] = override; + assert.equal(resolveStateDir(makeTempDir()), override); + assert.equal(getStateDirOverride(), override); + }); +}); + +test("CODEX_COMPANION_STATE_DIR env var: relative value is canonicalized AND written back to env on first read", () => { + withCleanStateDirEnv(() => { + process.env[STATE_DIR_ENV] = "rel/dir"; + const expected = path.resolve(process.cwd(), "rel/dir"); + assert.equal(getStateDirOverride(), expected); + // ...and persists the absolute form back to env so child processes inherit it: + assert.equal(process.env[STATE_DIR_ENV], expected); + assert.equal(resolveStateDir(makeTempDir()), expected); + }); });