Skip to content

fix: keep only the latest value per key in CLAUDE_ENV_FILE (SessionStart) - #748

Open
MeGaNeKoS wants to merge 1 commit into
openai:mainfrom
MeGaNeKoS:fix/idempotent-appendenvvar
Open

fix: keep only the latest value per key in CLAUDE_ENV_FILE (SessionStart)#748
MeGaNeKoS wants to merge 1 commit into
openai:mainfrom
MeGaNeKoS:fix/idempotent-appendenvvar

Conversation

@MeGaNeKoS

@MeGaNeKoS MeGaNeKoS commented Sep 7, 2026

Copy link
Copy Markdown

Problem

handleSessionStart calls appendEnvVar for CODEX_COMPANION_SESSION_ID, CODEX_COMPANION_TRANSCRIPT_PATH, and CLAUDE_PLUGIN_DATA on every fire. hooks/hooks.json registers SessionStart with no matcher, so it runs on startup, resume, /clear, and compact. Those three values are constant for the life of a session, so every fire after the first re-appends bytes already in CLAUDE_ENV_FILE. The file grows without bound.

Claude Code inlines that file into the bash -c preamble of every Bash tool call, so past a size threshold every command in the session breaks. On Windows (Git Bash) I saw one env file reach 1248 lines / 130 KB containing only 3 unique lines, with three failure shapes by size:

  • ~8 KB+: preamble truncated mid single-quoted export, giving unexpected EOF while looking for matching '
  • ~32 KB+: command line exceeds the Windows CreateProcess limit, giving ENAMETOOLONG ... uv_spawn

Quote escaping in shellEscape is correct; the bug is purely the unbounded append.

Fix

Replace the unconditional append with set semantics. setEnv removes any existing export <name>= line and writes the current value, so each key appears once with its latest value. This bounds the file and, unlike dedup-by-line, correctly handles a key returning to an earlier value (A -> B -> A ends at A, not a stale B). The write goes through a temp file plus rename so a concurrent SessionStart can't read a half-written file.

Test

Repeated identical fires keep the file at one line per key; a fire with a changed value replaces the line instead of appending.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3046ee550c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread plugins/codex/scripts/session-lifecycle-hook.mjs Outdated
@ahmetgemici332-netizen

ahmetgemici332-netizen commented Sep 7, 2026 via email

Copy link
Copy Markdown

@MeGaNeKoS
MeGaNeKoS force-pushed the fix/idempotent-appendenvvar branch from 3046ee5 to 4ab1fa2 Compare September 7, 2026 12:18
@MeGaNeKoS MeGaNeKoS changed the title fix: make SessionStart appendEnvVar idempotent so CLAUDE_ENV_FILE stops growing fix: keep only the latest value per key in CLAUDE_ENV_FILE (SessionStart) Sep 7, 2026
@MeGaNeKoS

Copy link
Copy Markdown
Author

Switching this from "append, skip if the line already exists" to set semantics, and it's worth saying why, since the append version looks simpler.

The env file is sourced, so the effective value of a key is whatever its last export line says. That makes the right mental model a map of key to latest value, an assignment, not an append log. Append is the wrong primitive for key/value state, and it shows up as a concrete bug:

  • Append-with-dedup is incorrect when a value reverts. Take a key going A, then B, then back to A. Dedup-by-line sees export K='A' already present from the first write and skips it, so the file still ends in export K='B' and the sourced value is a stale B. Set semantics (remove any prior export K= line, then write the current one) ends at A, which is correct.
  • It also cleans up the resume/clear case. When the session_id changes but the same env-file dir is reused (see #40391, #70606), append leaves a file carrying two different session_ids. Set replaces the old line, so each key stays single-valued.
  • Growth is bounded permanently, not just for constant values. Set keeps exactly one line per key no matter how many times SessionStart fires. Dedup-by-line is only bounded by the number of distinct values a key takes, so a changing key still grows.
  • The write is atomic (temp file + rename) so a concurrently firing SessionStart can't observe a half-written file. That's deliberate, given this whole issue family includes a torn-line crash from two hook processes writing at once; a plain in-place rewrite would reopen that window.

Tradeoff, to be upfront: set does a read plus a full rewrite per call instead of one append. That's negligible here, the file is well under 1 KB and SessionStart is an occasional event (startup/resume/clear/compact), not a hot path. On Windows, rename over an open file can rarely throw EPERM in that same narrow double-fire window; happy to add a small retry if you'd prefer.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ab1fa24bd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +53 to +55
const tmp = `${envFile}.${process.pid}.tmp`;
fs.writeFileSync(tmp, lines.join("\n") + "\n", "utf8");
fs.renameSync(tmp, 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 Avoid overwriting concurrent SessionStart hook updates

When another matching SessionStart hook writes to the shared CLAUDE_ENV_FILE after this read, the subsequent whole-file rename silently discards that hook's export. This can occur in installations with additional user or plugin SessionStart hooks, leaving later commands without their persisted environment variables; use an append-based update or otherwise serialize read-modify-write access.

Useful? React with 👍 / 👎.

Comment on lines +53 to +55
const tmp = `${envFile}.${process.pid}.tmp`;
fs.writeFileSync(tmp, lines.join("\n") + "\n", "utf8");
fs.renameSync(tmp, 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.

P2 Badge Preserve restrictive permissions on the environment file

When Claude created the existing environment file with a restrictive mode such as 0600 and its parent directory is traversable, writeFileSync creates the replacement using the default 0666 & umask mode—commonly 0644—and the rename installs those broader permissions. Because this file may contain exports from other hooks, including credentials, replacing it should preserve the original mode or explicitly create the temporary file with equally restrictive permissions.

Useful? React with 👍 / 👎.

@ahmetgemici332-netizen

ahmetgemici332-netizen commented Sep 7, 2026 via email

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants