Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 7 additions & 2 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import path from "node:path";
import { resolveWorkspaceRoot } from "./workspace.mjs";

const STATE_VERSION = 1;
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
export const PLUGIN_DATA_ENV = "CODEX_COMPANION_PLUGIN_DATA";
const HOST_PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
const STATE_FILE_NAME = "state.json";
const JOBS_DIR_NAME = "jobs";
Expand Down Expand Up @@ -38,7 +39,11 @@ export function resolveStateDir(cwd) {
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);
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
// CLAUDE_PLUGIN_DATA is scoped to this plugin while its hook runs, but the
// SessionStart env file is shared by every plugin. Persist that scoped value
// under a Codex-owned name so a sibling hook cannot redirect our later jobs.
// Keep the host variable as a fallback for direct and pre-upgrade callers.
const pluginDataDir = process.env[PLUGIN_DATA_ENV] || process.env[HOST_PLUGIN_DATA_ENV];
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
return path.join(stateRoot, `${slug}-${hash}`);
}
Expand Down
6 changes: 3 additions & 3 deletions plugins/codex/scripts/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import {
sendBrokerShutdown,
teardownBrokerSession
} from "./lib/broker-lifecycle.mjs";
import { loadState, resolveStateFile, saveState } from "./lib/state.mjs";
import { loadState, PLUGIN_DATA_ENV, resolveStateFile, saveState } from "./lib/state.mjs";
import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs";
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";

export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
const HOST_PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";

function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
Expand Down Expand Up @@ -77,7 +77,7 @@ function cleanupSessionJobs(cwd, sessionId) {
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]);
appendEnvVar(PLUGIN_DATA_ENV, process.env[HOST_PLUGIN_DATA_ENV]);
}

async function handleSessionEnd(input) {
Expand Down
4 changes: 2 additions & 2 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ test("task --resume-last ignores running tasks from other Claude sessions", () =
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", () => {
test("session start hook exports the Claude session id, transcript path, and a Codex-owned plugin data variable", () => {
const repo = makeTempDir();
const envFile = path.join(makeTempDir(), "claude-env.sh");
fs.writeFileSync(envFile, "", "utf8");
Expand All @@ -694,7 +694,7 @@ test("session start hook exports the Claude session id, transcript path, and plu
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`
`export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CODEX_COMPANION_PLUGIN_DATA='${pluginDataDir}'\n`
);
});

Expand Down
93 changes: 91 additions & 2 deletions tests/state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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 {
resolveJobFile,
resolveJobLogFile,
resolveStateDir,
resolveStateFile,
saveState
} from "../plugins/codex/scripts/lib/state.mjs";

test("resolveStateDir uses a temp-backed per-workspace directory", () => {
const workspace = makeTempDir();
Expand All @@ -16,7 +22,7 @@ test("resolveStateDir uses a temp-backed per-workspace directory", () => {
assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
});

test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => {
test("resolveStateDir falls back to CLAUDE_PLUGIN_DATA when it is provided", () => {
const workspace = makeTempDir();
const pluginDataDir = makeTempDir();
const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA;
Comment thread
weivwang marked this conversation as resolved.
Expand All @@ -40,6 +46,89 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => {
}
});

test("resolveStateDir prefers the Codex plugin data dir over another plugin's host-scoped value", () => {
const workspace = makeTempDir();
const codexPluginDataDir = makeTempDir();
const siblingPluginDataDir = makeTempDir();
const previousCodexPluginDataDir = process.env.CODEX_COMPANION_PLUGIN_DATA;
const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA;
process.env.CODEX_COMPANION_PLUGIN_DATA = codexPluginDataDir;
process.env.CLAUDE_PLUGIN_DATA = siblingPluginDataDir;

try {
const stateDir = resolveStateDir(workspace);

assert.equal(stateDir.startsWith(path.join(codexPluginDataDir, "state")), true);
assert.equal(stateDir.startsWith(path.join(siblingPluginDataDir, "state")), false);
} finally {
if (previousCodexPluginDataDir == null) {
delete process.env.CODEX_COMPANION_PLUGIN_DATA;
} else {
process.env.CODEX_COMPANION_PLUGIN_DATA = previousCodexPluginDataDir;
}
if (previousPluginDataDir == null) {
delete process.env.CLAUDE_PLUGIN_DATA;
} else {
process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir;
}
}
});

test("saveState does not prune another plugin's job when the host-scoped data dir was overwritten", () => {
const workspace = makeTempDir();
const codexPluginDataDir = makeTempDir();
const siblingPluginDataDir = makeTempDir();
const previousCodexPluginDataDir = process.env.CODEX_COMPANION_PLUGIN_DATA;
const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA;
process.env.CODEX_COMPANION_PLUGIN_DATA = siblingPluginDataDir;
process.env.CLAUDE_PLUGIN_DATA = siblingPluginDataDir;

try {
const siblingStateFile = resolveStateFile(workspace);
const siblingJobFile = resolveJobFile(workspace, "sibling-job");
const siblingLogFile = resolveJobLogFile(workspace, "sibling-job");
const siblingState = {
version: 1,
config: { stopReviewGate: false },
jobs: [
{
id: "sibling-job",
status: "completed",
logFile: siblingLogFile,
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z"
}
]
};
fs.writeFileSync(siblingJobFile, '{"owner":"sibling"}\n', "utf8");
fs.writeFileSync(siblingLogFile, "sibling output\n", "utf8");
fs.writeFileSync(siblingStateFile, `${JSON.stringify(siblingState, null, 2)}\n`, "utf8");

process.env.CODEX_COMPANION_PLUGIN_DATA = codexPluginDataDir;
saveState(workspace, {
version: 1,
config: { stopReviewGate: false },
jobs: []
});

assert.deepEqual(JSON.parse(fs.readFileSync(siblingStateFile, "utf8")), siblingState);
assert.equal(fs.readFileSync(siblingJobFile, "utf8"), '{"owner":"sibling"}\n');
assert.equal(fs.readFileSync(siblingLogFile, "utf8"), "sibling output\n");
assert.equal(fs.existsSync(resolveStateFile(workspace)), true);
} finally {
if (previousCodexPluginDataDir == null) {
delete process.env.CODEX_COMPANION_PLUGIN_DATA;
} else {
process.env.CODEX_COMPANION_PLUGIN_DATA = previousCodexPluginDataDir;
}
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);
Expand Down