Skip to content

fix: rewrite session env exports instead of appending them - #657

Open
lukehutch wants to merge 1 commit into
openai:mainfrom
lukehutch:fix/prune-session-env-exports
Open

fix: rewrite session env exports instead of appending them#657
lukehutch wants to merge 1 commit into
openai:mainfrom
lukehutch:fix/prune-session-env-exports

Conversation

@lukehutch

Copy link
Copy Markdown

Fixes #528. #322 reported the same defect earlier and was closed without the fix landing, so this adds regression tests as well.

The defect

handleSessionStart appends three export lines to CLAUDE_ENV_FILE every time it runs, and SessionStart fires on startup, on resume, and on every compaction. Nothing prunes. Only the last assignment of a name has any effect, so every earlier copy is dead weight.

Claude Code inlines the whole env file into the single bash -c <script> argument it spawns. On Linux a single argv element is capped at MAX_ARG_STRLEN = 32 pages = 131072 bytes — a separate limit from ARG_MAX, and ulimit doesn't move it. Once the accumulated prelude crosses it, execve fails with E2BIG and no shell starts at all: every Bash tool call fails regardless of how short the command is.

Error: Could not start /bin/bash: the command line plus environment exceed the OS exec argument limit (E2BIG).
At spawn: command line 133.3KB across 3 args (largest single arg 133.3KB); environment 4.5KB across 81 vars

The env file involved is ~/.claude/session-env/<session-id>/sessionstart-hook-0.sh, one per session; the one that broke my session had reached 136770 bytes. On Windows the same growth truncates the command line at 8191 characters, which is how #528 was originally reported.

The change

Rewrite this plugin's exports instead of appending them: drop lines that assign one of the three names this plugin owns, keep every other line verbatim, append the current values, and skip the write entirely when the result is byte-identical to what's already there. The write goes to a temp file and is renamed, so a reader never sees a half-written file.

Why a rewrite rather than a skip-if-duplicate append

This is the part that matters for anyone already affected. Both #558 and #386 keep the append and return early when the last export <name>= line already matches. That stops future growth on a fresh file, but it never removes anything — and because the env file is per-session, the last assignment of each name in an already-bloated file is byte-identical to what the hook would write. So the early return fires, nothing is pruned, and a session that is already over the limit stays broken until the user deletes the file by hand.

Measured by firing the real hook against a seeded env file (one unrelated co-tenant line, same session_id every time, since the file is keyed by session id in its own path):

after N firings, starting empty current main #558 / #386 this PR
1 4 lines / 302 B 4 / 302 4 / 302
2 7 / 568 4 / 302 4 / 302
3 10 / 834 4 / 302 4 / 302
5 16 / 1366 4 / 302 4 / 302
after N firings on a file already grown to 151 lines / 13336 B #558 / #386 this PR
1 151 lines / 13336 B 4 / 302
3 151 / 13336 4 / 302

Trade-off worth knowing about

This is a read-modify-write on a file other plugins may also append to, so a sibling SessionStart hook appending between the read and the rename would lose that write. The current append is atomic and has no such window. The exposure is small — the read-filter-write-rename is microseconds, it happens at most once per session-start event, and the skip-if-unchanged check means the steady state performs no write at all — but it is real, and it is presumably why the other two PRs stayed append-only.

If that window is unacceptable, the two approaches can be combined: prune the plugin's own lines once, then keep the values in a plugin-owned file under CLAUDE_PLUGIN_DATA and leave a single constant [ -f … ] && . … line in the shared file. Steady state then never writes to the shared file, and a changed value is picked up by rewriting the owned file, which nobody else touches. I did not do that here because it makes the shared file depend on being executed as shell rather than parsed, which is a stronger assumption about the host than the current plain export lines need. Happy to switch if you'd prefer it.

Tests

Four tests added to tests/runtime.test.mjs, all of which fail on main and pass with this change:

  • repeated firings don't accumulate exports
  • an env file left bloated by an earlier version is pruned back to one copy
  • lines written by anything else are preserved verbatim
  • a stale value is replaced rather than shadowed

The existing session start hook exports the Claude session id, transcript path, and plugin data dir test is unchanged and still passes — the output for a fresh env file is byte-identical to before.

Local npm test: 87 pass / 4 fail out of 91 on main, and 91 pass / 4 fail out of 95 with this change — four new tests, four new passes, and the same four failures before and after (status shows phases…, status preserves adversarial review kind labels, result returns the stored output…, resolveStateDir uses a temp-backed per-workspace directory). Those four are unrelated to this change and look like they need the real Codex CLI, which CI installs and my machine doesn't have.

handleSessionStart appended three export lines to CLAUDE_ENV_FILE on
every firing, and SessionStart fires on startup, on resume and on every
compaction. Nothing ever pruned the file, and only the last assignment
of a name has any effect, so every earlier copy was dead weight.

Claude Code inlines the whole env file into the single `bash -c <script>`
argument it spawns, so a long-running session eventually pushed that one
argument past the operating system's limit on the length of a single
argument (MAX_ARG_STRLEN, 131072 bytes on Linux). At that point execve
fails with E2BIG and no shell can be started at all: every Bash tool call
fails regardless of how short the command itself is. The same growth
truncates the command line at 8191 characters on Windows.

Drop this plugin's own export lines, keep every other line verbatim,
append the current values, and skip the write entirely when the result is
unchanged. Write to a temp file and rename so a reader never sees a
half-written file.

Rewriting, rather than only skipping duplicate appends, also repairs a
file that has already grown - which is the state every affected session
is already in, and where a skip-if-unchanged check never fires.

Fixes openai#528. openai#322 reported the same defect earlier.

@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: 22a79e972c

ℹ️ 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".

// 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
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.

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 👍 / 👎.

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

Labels

None yet

Projects

None yet

1 participant