Skip to content

🧹 Stop a hosted Claude session inheriting its launcher session marker - #162

Merged
sdougbrown merged 2 commits into
mainfrom
fix/scrub-parent-claude-session-env
Aug 6, 2026
Merged

🧹 Stop a hosted Claude session inheriting its launcher session marker#162
sdougbrown merged 2 commits into
mainfrom
fix/scrub-parent-claude-session-env

Conversation

@sdougbrown

Copy link
Copy Markdown
Owner

What

A claude backend run launched from inside a Claude Code session hung until its timeout — after completing the turn on screen. The child inherited CLAUDE_CODE_CHILD_SESSION, so Claude Code treated it as a nested session and turned off transcript persistence, saying so in a startup banner:

⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker

The transcript is the only end-of-turn signal that backend has. ScanTranscriptTick returns early forever on a file that is never written, end_turn never arrives, and the run reports nothing at all — not a failure, not a timeout error, nothing after agent.prompt_submitted.

This is the configuration the avenor MCP plugin runs in, and PTY is the default terminal.

The process a launcher starts is an independent session with its own --session-id, so it no longer inherits the launching session's identity: CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID, and CLAUDE_PID. Only the first is known to change behaviour; the other two are dropped so a hosted session cannot be mistaken for its launcher.

Two mechanisms, because tmux is not ours

Launcher Mechanism Why
PTYLauncher filters os.Environ via ScrubParentClaudeEnv it owns the child environment directly
TmuxLauncher prefixes the command via UnsetParentClaudeEnvPrefix a pane inherits the tmux server's environment

The tmux case is the one worth reading twice. avenor does not own the tmux server and cannot filter its environment. A server started earlier from inside a Claude Code session hands the marker to every pane it creates for the rest of its life, and tmux new-session -e can only assign a variable, not remove one. Stripping inside the command works whichever environment the server holds — including that already-poisoned server.

The prefix uses the unset builtin rather than env -u, because the commands it wraps begin with exec, which is also a builtin. env -u X exec claude sends env looking for a binary named exec and fails. unset also adds no process, so #{pane_pid} still reports claude's own PID. TestUnsetParentClaudeEnvPrefixRunsUnderShellWithExec pins that down — it fails against an env -u implementation.

Verification

Test-first: every test here fails before the fix. go build ./..., go vet ./internal/runtime/..., and the full go test ./... (38 packages) pass, as does go test -race -count=1 ./internal/runtime/claudecore/....

End to end with CLAUDE_CODE_CHILD_SESSION set in the parent, against Claude Code v2.1.222, a healthy --agent mule run:

Terminal Before After
PTY (default) 150s+ silent hang, no session.end done / end_turn in 5s
tmux worked by luck — server predated the session done / end_turn in 4s

tmux passing beforehand was an accident of my own tmux server predating the Claude Code session that launched avenor. On a machine where avenor starts the server, tmux fails the same way and then stays broken for that server's lifetime.

The tmux test asserts on the rendered argv rather than launching a pane. t.Setenv cannot reach an already-running tmux server, so an end-to-end assertion there would pass whether or not the scrub exists.

Related

Independent of #161 and mergeable in either order — that PR makes a refused launch report failed instead of done; this one fixes a launch that succeeds and is then never reported at all. #161's body lists this bug under adjacent work.

Still open, found alongside these two and not addressed by either: TranscriptPath does not resolve symlinks, so -dir /tmp reads projects/-tmp/ while Claude Code writes projects/-private-tmp/ — the same class of silent hang, from a different cause.

🤖 Generated with Claude Code

A `claude` backend run launched from inside a Claude Code session hung until
its timeout, having completed the turn on screen. The child inherited
`CLAUDE_CODE_CHILD_SESSION`, so Claude Code treated it as a nested session and
disabled transcript persistence:

    ⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker

The transcript is the only end-of-turn signal that backend has. `ScanTranscriptTick`
returns early forever on a file that is never written, `end_turn` never arrives,
and the run reports nothing at all — not even a failure. This is the
configuration the avenor MCP plugin runs in, and PTY is the default terminal.

The process a launcher starts is an independent session with its own
`--session-id`, so it no longer inherits the launching session's identity:
`CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`, and `CLAUDE_PID`.

Each launcher needs a different mechanism. `PTYLauncher` owns the child
environment directly, so it filters `os.Environ` through `ScrubParentClaudeEnv`.
A tmux pane inherits the tmux *server's* environment, which avenor does not own
and cannot filter — a server started earlier from inside a Claude Code session
hands the marker to every pane for the rest of its life. `tmuxNewSessionArgs`
therefore strips the variables inside the command, where the server's
environment no longer matters.

The prefix uses the `unset` builtin rather than `env -u` because the commands it
wraps begin with `exec`, which is also a builtin: `env -u X exec claude` sends
env looking for a binary named "exec". `unset` also adds no process, so
`#{pane_pid}` still reports claude's own PID.

Verified end to end with the marker set in the parent, against Claude Code
v2.1.222: a healthy `--agent mule` run went from a 150s+ silent hang to
`session.end` `done` / `end_turn` in 5s under PTY and 4s under tmux.

AI-Generated-By: Claude (Opus 5)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@umpire-bot umpire-bot 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.

This PR is marked... FOUL BALL. 👉

Comment thread internal/runtime/claudecore/terminal/env_launcher_test.go
Comment thread internal/runtime/claudecore/terminal/env_launcher_test.go
Comment thread internal/runtime/claudecore/terminal/env_test.go
- Drive the tmux scrub assertion through TmuxLauncher.Start via a fake
  tmux binary injected into PATH (covers the actual new-session wiring)
- Defer captureUntil's deadline to the polling context so the timeout is
  not a flakiness vector under load
- Add empty-input boundary case for ScrubParentClaudeEnv
- Scrub PTY env after the final append so a caller-provided Env entry
  cannot reintroduce a scrubbed session identity; regression test covers
  all three identity variables via both inherited and Env-sourced paths
- Extend live-shell composition and PTY tests to assert all three scrub
  targets, not just CLAUDE_CODE_CHILD_SESSION
@sdougbrown

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Addressed 3 review comments across 3 files (commit 1b05179, pushed):

  • Fixed (3):
    • 3731871826 env_launcher_test.go — tmux scrub check now driven through TmuxLauncher.Start via a fake tmux binary in PATH, so a regression where Start stops applying UnsetParentClaudeEnvPrefix fails the test
    • 3731871853 env_launcher_test.go — captureUntil deadline is now context-driven (10s poll), removing the independent 4s wall-clock timeout as a flakiness vector
    • 3731871892 env_test.go — added ScrubParentClaudeEnv([]string{}) empty-input boundary test

Review loop: 2 iterations. Iteration 2 was run against the working tree after fixes:

  • Fixed from /review: pty.go now scrubs the combined env after the append so a caller-supplied Env entry cannot reintroduce a scrubbed session identity (verified via mutation check — the new test fails if the order regresses). Extended PTY e2e and live-shell composition tests to assert all three identity vars, not just CLAUDE_CODE_CHILD_SESSION.
  • Declined (noted): tmux prefix double-space when Command starts with whitespace (commands are internally generated; composition contract owns this); mutable-slice parentClaudeSessionEnv (read-only in practice); fake-tmux log parser index-walking (verified correct — printf "%s " "$@" emits one argv element per line).
  • Pre-existing, outside the diff (informational): TmuxLauncher.Start pane-PID loop doesn't check ctx.Err() during its 2s poll; TmuxSession.Alive() drops the has-session error.
  • Second-pass must-fix claims checked and refuted: the fake-tmux test passes (0.20s) and the ordering test genuinely catches the pre-fix bug (mutation-verified).

go build ./..., go vet, and the full go test ./... (38 packages) all pass; go test -race -count=1 ./internal/runtime/claudecore/terminal/... is clean.

@sdougbrown
sdougbrown merged commit dbb35aa into main Aug 6, 2026
@sdougbrown
sdougbrown deleted the fix/scrub-parent-claude-session-env branch August 6, 2026 22:01
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