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
54 changes: 48 additions & 6 deletions plugins/codex/scripts/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,51 @@ function shellEscape(value) {
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
}

function appendEnvVar(name, value) {
if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
const MANAGED_ENV_VARS = [SESSION_ID_ENV, TRANSCRIPT_PATH_ENV, PLUGIN_DATA_ENV];

function isManagedExport(line) {
return MANAGED_ENV_VARS.some((name) => line.startsWith(`export ${name}=`));
}

// Rewrite this plugin's exports instead of appending them. SessionStart fires on
// startup, on resume and on every compaction, so appending grew CLAUDE_ENV_FILE by
// three lines every time and nothing ever pruned it. Only the last assignment of a
// name takes effect, so every earlier copy did nothing but grow the file. Claude Code
// inlines the whole file into the single `bash -c <script>` argument, so a long-running
// session eventually pushed that argument past the operating system's limit on the
// length of one argument, and no shell could be started at all.
function writeManagedEnvVars(entries) {
const envFile = process.env.CLAUDE_ENV_FILE;
if (!envFile) {
return;
}
fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");

let existing = "";
try {
existing = fs.readFileSync(envFile, "utf8");
} catch (error) {
if (error?.code !== "ENOENT") {
throw error;
}
}

// Keep every line another plugin wrote, verbatim; drop only our own.
const lines = existing.split(/\r?\n/).filter((line) => line !== "" && !isManagedExport(line));
for (const [name, value] of entries) {
if (value != null && value !== "") {
lines.push(`export ${name}=${shellEscape(value)}`);
}
}

const next = lines.length > 0 ? `${lines.join("\n")}\n` : "";
if (next === existing) {
return;
}

// Write and rename, so that a reader never sees a half-written file.
const tmpFile = `${envFile}.${process.pid}.tmp`;
fs.writeFileSync(tmpFile, next, "utf8");
fs.renameSync(tmpFile, envFile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Lock the shared env file before replacing it

When another SessionStart hook writes to the same CLAUDE_ENV_FILE concurrently, this read-modify-rename sequence can silently discard that hook's exports: if it appends after this process reads the file but before this rename, its write lands on the old inode or is overwritten by the stale snapshot. Claude Code may run matching hooks concurrently, so the rewrite needs cross-process synchronization or another update strategy that cannot lose concurrent additions.

Useful? React with 👍 / 👎.

Comment on lines +78 to +79

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 Preserve the existing env file's access mode

When CLAUDE_ENV_FILE was created with restrictive permissions such as 0600, this temporary file is created with Node's default 0666 & umask mode (typically 0644) and then replaces the original. In a traversable directory, exports written by any hook—including credentials—therefore become readable by other local users after SessionStart; create the temporary file using the original file's mode before renaming it.

Useful? React with 👍 / 👎.

}

function cleanupSessionJobs(cwd, sessionId) {
Expand Down Expand Up @@ -75,9 +115,11 @@ 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]);
writeManagedEnvVars([
[SESSION_ID_ENV, input.session_id],
[TRANSCRIPT_PATH_ENV, input.transcript_path],
[PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]]
]);
}

async function handleSessionEnd(input) {
Expand Down
120 changes: 120 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,126 @@ test("session start hook exports the Claude session id, transcript path, and plu
);
});

function runSessionStartHook({ repo, envFile, pluginDataDir, sessionId, transcriptPath }) {
return 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: sessionId,
transcript_path: transcriptPath,
cwd: repo
})
});
}

function managedExports({ sessionId, transcriptPath, pluginDataDir }) {
return (
`export CODEX_COMPANION_SESSION_ID='${sessionId}'\n` +
`export CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\n` +
`export CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
);
}

test("session start hook does not accumulate exports when it fires repeatedly", () => {
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 expected = managedExports({ sessionId: "sess-current", transcriptPath, pluginDataDir });

// SessionStart fires again on every resume and every compaction of the same session.
for (let firing = 1; firing <= 5; firing += 1) {
const result = runSessionStartHook({
repo,
envFile,
pluginDataDir,
sessionId: "sess-current",
transcriptPath
});
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(envFile, "utf8"), expected, `after firing ${firing}`);
}
});

test("session start hook prunes exports an earlier version left behind", () => {
const repo = makeTempDir();
const envFile = path.join(makeTempDir(), "claude-env.sh");
const pluginDataDir = makeTempDir();
const transcriptPath = path.join(repo, "session.jsonl");
const expected = managedExports({ sessionId: "sess-current", transcriptPath, pluginDataDir });

// The state a session is already in after a version that appended on every firing:
// hundreds of copies, of which only the last has any effect.
fs.writeFileSync(envFile, expected.repeat(500), "utf8");

const result = runSessionStartHook({
repo,
envFile,
pluginDataDir,
sessionId: "sess-current",
transcriptPath
});

assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(envFile, "utf8"), expected);
});

test("session start hook keeps env file lines written by anything else", () => {
const repo = makeTempDir();
const envFile = path.join(makeTempDir(), "claude-env.sh");
const pluginDataDir = makeTempDir();
const transcriptPath = path.join(repo, "session.jsonl");
const expected = managedExports({ sessionId: "sess-current", transcriptPath, pluginDataDir });
const other = "export OTHER_PLUGIN_VALUE='keep-me'\n";

fs.writeFileSync(envFile, `${other}${expected.repeat(3)}`, "utf8");

for (let firing = 1; firing <= 2; firing += 1) {
const result = runSessionStartHook({
repo,
envFile,
pluginDataDir,
sessionId: "sess-current",
transcriptPath
});
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(envFile, "utf8"), `${other}${expected}`, `after firing ${firing}`);
}
});

test("session start hook replaces exports that hold a stale value", () => {
const repo = makeTempDir();
const envFile = path.join(makeTempDir(), "claude-env.sh");
const pluginDataDir = makeTempDir();
const transcriptPath = path.join(repo, "session.jsonl");

fs.writeFileSync(
envFile,
managedExports({ sessionId: "sess-previous", transcriptPath, pluginDataDir }),
"utf8"
);

const result = runSessionStartHook({
repo,
envFile,
pluginDataDir,
sessionId: "sess-current",
transcriptPath
});

assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.readFileSync(envFile, "utf8"),
managedExports({ sessionId: "sess-current", transcriptPath, pluginDataDir })
);
});

test("write task output focuses on the Codex result without generic follow-up hints", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
Expand Down