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

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

Edo771977 pushed a commit to Edo771977/codex-plugin-cc that referenced this pull request Sep 17, 2026
…ted code

The remaining findings from the independent review. None came from this
branch's grafts; all four arrived with imported PRs and are now this fork's.

- state.mjs: the cross-root prune rewrote another root's state.json while
  holding only the primary's lock, so a process whose primary IS that root
  could lose its whole update to the rename. It now takes that root's own lock
  and never waits for it (timeoutMs: 0): two processes each holding the other's
  primary lock would deadlock, and a skipped prune is harmless — the next save
  redoes it.
- state.mjs: loadState() merges booleans across roots with OR, so a stranded
  `stopReviewGate: true` outvoted an explicit disable forever whenever the
  durable config could not be read, because setConfig() only ever wrote the
  primary. It now writes the new config into every existing root (same
  non-blocking lock discipline), keeping the fail-safe OR without making
  "disable" unreachable. The merge also folds candidates in reverse so the
  primary wins for non-boolean keys — the old "first writer wins" branch was
  dead for every key the defaults define, which is all of them.
- session-lifecycle-hook.mjs: setEnv() rewrote the shared CLAUDE_ENV_FILE
  (read, filter, rename), which drops any export another plugin's SessionStart
  hook appended in between and discards the file's mode with the replaced
  file. It appends again, and skips the append when the value the file already
  resolves to is ours — the shell takes the last export for a key, so openai#748's
  point (no growth on every session) survives without the data loss.
- claude-session-transfer.mjs: a process attaching to a staged copy whose
  creator had not yet written the marker took no lease, and the creator's
  release() then deleted the file under it. The lease is now taken
  unconditionally, and cleanup belongs to whoever leaves last (marker present,
  no leases left) rather than to whoever created the copy.

Regression tests for the first three; each fails with only its own fix
reverted. The staging race has no deterministic test — it needs an interleaving
between two processes at a specific point — so it rests on the reasoning above.

Verified: full npm test 310/310; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Edo771977 pushed a commit to Edo771977/codex-plugin-cc that referenced this pull request Sep 17, 2026
…ound of fixes

- The retained orphan kept its dead pid, which made it reapable: another
  session's SessionEnd failed the record without an interrupt and it stopped
  pinning the broker, tearing the runtime down under the turn the retain
  protects. The verdict is persisted instead — pid: null plus workerExited —
  and reconcileJobLiveness() reads that flag, so the record stays truthful in
  /codex:status while a pid-less active record keeps the broker up under the
  existing staleness bound. The test now also ends a second session and
  asserts the job survives it.
- Skipping assertResumedSandbox() for scoped resumes also dropped the
  escalation check. A dedicated one replaces it: a thread running with the
  sandbox disabled cannot be scoped by a permission profile, so --read-root on
  it is refused rather than silently promising a scope.
- saveState() deleted a dropped job's files from every root while the prune of
  another root's state.json is best-effort, so a contended prune left a record
  to be merged back — and rewritten into the primary — with its detail file,
  claim and log already gone. Each root's files now go with its own record.
- setEnv() appended without ensuring the file ends in a newline, so a
  preceding hook's unterminated line and ours would run together and lose both
  exports.
- The append's own comment (and the README's line for openai#748) claimed the file
  no longer grows per session, which is false for values that change every
  session — the session id and transcript path. Both now say what actually
  holds: unchanged values are skipped, changed ones append, and the shell
  takes the last export. Bounded growth is the price of never destroying
  another plugin's export.
- release() took the 5s staging lock even when there was nothing to clean up,
  from a finally, so a busy lock replaced the import error that was unwinding.
  It is wrapped now, with a lock-free unlink of our own lease as the fallback.

Verified: full npm test 310/310; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
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