From e123511d2ec4cab0b056ba343def6114d6d28f2f Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 15:05:18 -0300 Subject: [PATCH 1/2] fix: persist review gate config outside transient state --- plugins/codex/scripts/lib/state.mjs | 49 +++++++++++++++++++++++------ tests/runtime.test.mjs | 28 +++++++++++++++++ tests/state.test.mjs | 24 +++++++++++++- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..4a222172f 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -7,6 +7,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"; @@ -26,7 +28,7 @@ function defaultState() { }; } -export function resolveStateDir(cwd) { +function resolveWorkspaceKey(cwd) { const workspaceRoot = resolveWorkspaceRoot(cwd); let canonicalWorkspaceRoot = workspaceRoot; try { @@ -34,13 +36,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) { @@ -150,17 +160,38 @@ 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); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + const nextConfig = { ...defaultState().config, ...(config ?? {}) }; + fs.writeFileSync(configFile, `${JSON.stringify(nextConfig, null, 2)} +`, "utf8"); + 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/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..6f7c621f0 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2036,6 +2036,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(); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57cea..1312493df 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,7 +5,7 @@ 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 { getConfig, resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState, setConfig } from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); @@ -40,6 +40,28 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { } }); +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); From a81f52f9c2c366d7d5e095179d2473a9f7e12798 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 15:41:49 -0300 Subject: [PATCH 2/2] test: isolate persistent review gate config --- tests/fake-codex-fixture.mjs | 1 + tests/runtime.test.mjs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..8e8107ba6 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -653,6 +653,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/runtime.test.mjs b/tests/runtime.test.mjs index 6f7c621f0..83352e47f 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2096,8 +2096,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); @@ -2105,6 +2107,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 })