Skip to content

SessionStart hook leaks per-plugin CLAUDE_PLUGIN_DATA into the shared session env file #562

Description

@GeoffreyDjof

Summary

The SessionStart hook writes this plugin's own CLAUDE_PLUGIN_DATA into $CLAUDE_ENV_FILE, which is shared by every plugin in the session. Because that file is sourced by all subsequent Bash tool calls, the last SessionStart hook to run wins the variable for the whole session — for every consumer, not just its owner.

When two plugins that both do this are installed (e.g. this one and another Claude Code companion), background jobs launched from Bash land in the other engine's state directory. At SessionEnd, that other engine's cleanupSessionJobs finds them (same session_id), calls terminateProcessTree() and prunes them from its state — so the job dies with no error surfaced, and a later status/result call answers No job found for "<id>".

Where

plugins/<name>/scripts/session-lifecycle-hook.mjs

function appendEnvVar(name, value) {
  if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") return;
  fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");
}
...
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);   // <-- leaks to every plugin

SESSION_ID_ENV and TRANSCRIPT_PATH_ENV are namespaced (<PLUGIN>_COMPANION_SESSION_ID), so they do not collide. CLAUDE_PLUGIN_DATA is the only generic name being exported into the shared file.

Why it matters

Claude Code already provides the correct per-plugin value to each hook invocation. The leak only affects Bash-launched child processes, which is precisely where the companion CLI runs. Consequences observed:

  • Jobs are written to a foreign state directory (resolveStateDir() derives it from $CLAUDE_PLUGIN_DATA).
  • The foreign plugin's SessionEnd terminates and prunes them, since both filter on job.sessionId === sessionId.
  • The failure is silent and non-deterministic — it only bites if some session ends while a long-running job is still polling, which makes it hard to attribute. In a setup with several concurrent sessions this happens regularly.
  • Symmetric: whichever plugin wins the variable, the other one's jobs are the casualties.

Reproduction

  1. Install two Claude Code plugins that both export CLAUDE_PLUGIN_DATA from their SessionStart hook.
  2. In a session, from a Bash tool call: echo "$CLAUDE_PLUGIN_DATA" → it points at whichever plugin's hook ran last, not necessarily the one you are about to invoke.
  3. Launch a background job through this plugin's companion from Bash, then read its logFile via status --json → the path is under the other plugin's data directory.
  4. End another session in the same workspace while the job is queued/running → the job is terminated and pruned; result <jobId> then answers No job found.

Step 3 alone is enough to confirm the leak; step 4 reproduces the resulting job loss.

Notes for whoever picks this up

Two obvious fixes both have a catch, so I am reporting the defect rather than proposing a patch:

  • Simply dropping the appendEnvVar(PLUGIN_DATA_ENV, …) line changes behaviour: with the variable unset, resolveStateDir() falls back to FALLBACK_STATE_ROOT_DIR (os.tmpdir()/<plugin>), so jobs move to the temp directory instead of the plugin data directory.
  • Renaming to a namespaced variable (e.g. <PLUGIN>_PLUGIN_DATA) is not purely local: at least one other published plugin reads the generic CLAUDE_PLUGIN_DATA and throws when it is missing, so a rename by the writers without a matching fallback in the readers would break it.

A direction that avoids both traps is to pass the value explicitly to the child process at spawn time (env of the spawned command) instead of publishing it into the shared session env file — the value is already known to the plugin at that point.

Happy to test a patch against a multi-plugin setup if that helps.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions