Skip to content

release/0.2.3: spawned agents persist transcripts (parent-agent env scrub)#12

Merged
isingh merged 3 commits into
mainfrom
release/0.2.3
Jun 9, 2026
Merged

release/0.2.3: spawned agents persist transcripts (parent-agent env scrub)#12
isingh merged 3 commits into
mainfrom
release/0.2.3

Conversation

@isingh

@isingh isingh commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Release 0.2.3

The bug

When claudemux is run from inside another Claude Code session (dev, CI, nested agents), the spawned claude inherited the parent's CLAUDECODE / CLAUDE_CODE_* / AI_AGENT environment variables, tripped claude's nested-session detection, and suppressed its own transcript persistence — writing only an async ai-title record, no user/assistant turns. claudemux drives agents by reading that transcript, so the conversation read back empty: the recurring "0 messages / gate stalls / delivery-unconfirmed" gremlin.

Root cause (A/B-proven, not a version regression)

Verified across claude 2.1.168 and 2.1.169 — behaviour identical, so this was never a claude version regression and never a flush-timing race. The discriminator is purely the inherited env. Unsetting any single var doesn't help; the whole set must be unset, and it must be a true unset at exec time:

  • -e VAR= only blanks the var (still present → heuristic still trips).
  • The shared, persistent tmux server bakes its first process's env server-globally, so mutating process.env before spawn no-ops when the server was first booted from a nested shell.

The fix

Launch the pane under env -u <vars> -- claude <argv>. A neutral unsetEnv: string[] is threaded buildArgv → spawn-boot → backend.spawn → newSession; the claude-specific var list (NESTED_AGENT_ENV) lives only in src/agents/claude.ts (layering-grep T12). cmd/argv are byte-identical — the --session-id two-element security invariant is untouched. The existing env passthrough stays the escape hatch (a deliberate re-set wins; -u is dropped on overlap).

Defense-in-depth (ADR 0008 follow-up)

unsetEnv names are validated at the backend boundary (InvalidEnvVarName, POSIX-identifier allow-list). Not consumer-exploitable today (trusted sole producer, shell-free execFile spawn) — closes the seam against any future untrusted producer.

Design & review trail

Verification

  • typecheck, lint (biome, 118 files), scripts/layering-grep.sh — all green.
  • 351 passed | 3 skipped (non-live suites).
  • The sharp live regression test (boot the shared tmux server under CLAUDECODE=1, then spawn from a clean shell, assert real user/assistant records) is included as a documented it.skip — it needs live-harness scaffolding to set the server-boot env and assert record types (the current live harness asserts only file existence, which the suppressed ai-title-only case would falsely pass). Tracked as follow-up.

🤖 Generated with Claude Code

isingh and others added 3 commits June 9, 2026 17:57
…s transcript

When claudemux runs from inside another Claude Code session, the child `claude`
inherited CLAUDECODE / CLAUDE_CODE_* / AI_AGENT and tripped claude's
nested-session detection, suppressing transcript persistence (only an async
ai-title record, no user/assistant turns). claudemux drives agents by reading
that transcript, so the conversation read back empty — the recurring "0 messages
/ gate stalls / delivery-unconfirmed" gremlin.

Root-caused via A/B across claude 2.1.168 and 2.1.169 (identical — NOT a version
regression): the fix is a true unset at the pane-launch boundary,
`env -u <vars> -- claude <argv>`. `-e VAR=` only blanks (still trips the
heuristic) and the shared persistent tmux server bakes its first process's env
(so process.env mutation no-ops) — the unset must happen at exec.

A neutral `unsetEnv: string[]` is threaded buildArgv -> spawn-boot ->
backend.spawn -> newSession; the claude-specific var list (NESTED_AGENT_ENV)
lives only in src/agents/claude.ts (layering grep T12). The existing `env`
passthrough stays the escape hatch (a deliberate re-set wins; `-u` is dropped on
overlap). cmd/argv are byte-identical — the --session-id two-element security
invariant is untouched.

Defense-in-depth (ADR 0008 follow-up): validate `unsetEnv` names at the backend
boundary (InvalidEnvVarName, POSIX identifier allow-list). Not exploitable today
(trusted sole producer, shell-free execFile spawn) but closes the seam against a
future untrusted producer.

See docs/decisions/0008-scrub-parent-agent-env-for-transcript-persistence.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI `gates` runs `biome check .` (lint + format); the prior commit was verified
with `biome lint` only, so formatter-canonical layout was missed:
- collapse the `it.each([...])` / `toEqual([...])` arrays in validate.test.ts
  and sessions.test.ts to biome's single-line form;
- restore package.json's single-line `keywords`/`files` arrays (the version
  bump's JSON.stringify had expanded them, which biome rejects).

No semantic change. Full `gates` job (check, typecheck, build, safety-grep,
layering-grep, no-tmux-in-public, assert-npm-pack) passes locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds test/session/transcript-persistence.live.test.ts — a maintainer acceptance
test (gated CLAUDEMUX_LIVE_TRANSCRIPT_PERSISTENCE=1, excluded from CI in
vitest.config.ts, Linux-only via /proc) that proves the parent-agent env scrub
works end-to-end against a real claude on a nested-booted shared tmux server.

The test asserts the fix's OBSERVABLE EFFECT, not claude's suppression
behaviour. An expanded A/B (claude 2.1.168/2.1.169/2.1.170) found the
nested-session suppression is environment-fragile: the same binary suppresses or
persists depending on which other env vars accompany CLAUDECODE (a node/npm
launch context flips it). A behavioural "did claude suppress?" assertion is
therefore a non-deterministic guard — a first draft asserted persistence and
gave a false pass (its negative control did not fail). Instead the test boots a
tmux server with all five nested vars in its global env, spawns claude via the
backend, and asserts all five are scrubbed from the live pane's
/proc/<pid>/environ. Negative control verified: passes with the fix, fails (all
five leak) when the `env -u` emission is reverted.

ADR 0008 Evidence updated with this refinement; the scrub decision is unchanged
— only the form of the proof moves from "observe claude persist" to "observe
claudemux scrub."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@isingh isingh merged commit 10ccbac into main Jun 9, 2026
11 checks passed
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.

1 participant