From b7a123c19e145cff06873a4dcb4370d4af82d93e Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 27 Jul 2026 22:04:10 +0700 Subject: [PATCH] =?UTF-8?q?fix(env):=20=E9=98=BB=E6=96=AD=20Claude=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=BA=AB=E4=BB=BD=E6=A0=87=E8=AE=B0=E7=BB=8F?= =?UTF-8?q?=20pm2/tmux=20=E6=B3=84=E6=BC=8F=E8=BF=9B=20bot=20CLI=EF=BC=88?= =?UTF-8?q?=E8=BD=AC=E5=86=99=E9=9D=99=E9=BB=98=E5=81=9C=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE_CODE_CHILD_SESSION 等标记是 Claude Code 注入其 Bash 子进程的会话身份, 从 bot 会话里发起的 pm2 delete+start 会把它们快照进 pm2 存档;共享 tmux server 随最后一个会话关闭自动退出后,下一次 spawn 从带毒 daemon fork 出新 server, 标记进入 server 全局环境——此后所有 bot CLI 自认嵌套子会话,转写落盘静默关闭, --resume 连续性(挂掉重启/切角色 respawn)全部失效。2026-07-27 实机事故。 修复(单一规则源 child-env.ts): - 新增 CLAUDE_SESSION_MARKER_ENV_KEYS 并入 REDACTED_CHILD_ENV_KEYS: 贯通 pty redact、pane wrapper unset、tmux client strip 三层 - 并入 TMUX_SERVER_GLOBAL_SCRUB_KEYS:daemon 启动即自愈已带毒的运行中 server - scrubClaudeSessionMarkerEnv 在 pm2Env/daemon boot/worker boot 三边界清洗 - per-bot env 拒绝显式配置身份标记(CLAUDE_EFFORT 仍可配,行为旋钮非身份) Co-Authored-By: Claude Fable 5 --- src/cli.ts | 6 ++- src/core/per-bot-env.ts | 10 +++++ src/index-daemon.ts | 7 +++- src/utils/child-env.ts | 65 +++++++++++++++++++++++++++++++-- src/worker.ts | 8 +++- test/child-env.test.ts | 64 +++++++++++++++++++++++++++++++- test/per-bot-env.test.ts | 9 +++++ test/tmux-env-isolation.test.ts | 36 ++++++++++++++++-- 8 files changed, 195 insertions(+), 10 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 8fbe67ac8..3ad01a8c6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -77,7 +77,7 @@ import { interactiveSelect, pickChoice, pickCliSelection } from './setup/interac import { buildPreset, serializePreset, presetFilename } from './setup/agent-preset.js'; import type { CliId } from './adapters/cli/types.js'; import { logger } from './utils/logger.js'; -import { scrubSessionCliHomeEnv } from './utils/child-env.js'; +import { scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js'; import { scheduleTimeZone } from './utils/timezone.js'; import { expandHomePath, invalidWorkingDirs } from './utils/working-dir.js'; import { firstPositional } from './cli/arg-utils.js'; @@ -243,6 +243,10 @@ function pm2Env(home: string = PM2_HOME): NodeJS.ProcessEnv { // stale dumps (see SESSION_CLI_HOME_ENV_KEYS for the full story, including // why GROK_HOME is exempt and why deleting beats pinning a default). scrubSessionCliHomeEnv(env); + // Claude session markers ride the same pm2 env-persistence vector; baked in + // they eventually flip transcript saving off fleet-wide once the tmux server + // respawns from a poisoned daemon (see CLAUDE_SESSION_MARKER_ENV_KEYS). + scrubClaudeSessionMarkerEnv(env); return env; } diff --git a/src/core/per-bot-env.ts b/src/core/per-bot-env.ts index 6108704d4..d37665ab9 100644 --- a/src/core/per-bot-env.ts +++ b/src/core/per-bot-env.ts @@ -43,7 +43,17 @@ const RESERVED_ENV_KEYS = new Set([ '__OWNER_OPEN_ID', 'SESSION_DATA_DIR', 'IS_SANDBOX', + // Claude session-identity markers (mirrors CLAUDE_SESSION_MARKER_ENV_KEYS in + // utils/child-env.ts): explicitly configuring one would mark the bot's CLI + // as a nested Claude child session (CLAUDE_CODE_CHILD_SESSION silently turns + // transcript persistence off) or pin a foreign session identity. CLAUDE_EFFORT + // stays configurable — it's a behavior knob, not an identity marker. 'CLAUDECODE', + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_ENTRYPOINT', + 'CLAUDE_CODE_EXECPATH', + 'CLAUDE_PID', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', // Grok data root: daemon installs hooks/skills and drains transcripts under diff --git a/src/index-daemon.ts b/src/index-daemon.ts index 96ce4a8dd..9cf578e30 100644 --- a/src/index-daemon.ts +++ b/src/index-daemon.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { existsSync } from 'node:fs'; import { installStdioEpipeGuard } from './utils/stdio-epipe-guard.js'; -import { scrubSessionCliHomeEnv } from './utils/child-env.js'; +import { scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js'; // Under pm2 the daemon's stdout/stderr are pipes to the God daemon. A broken // pipe (log streaming detaches, God daemon restart) would otherwise emit an @@ -36,6 +36,11 @@ for (const k of ['BOTMUX_SESSION_ID', 'BOTMUX_LARK_APP_ID', 'BOTMUX_CHAT_ID', 'B // child read/write the leaking bot's home. Per-session values are recomputed // downstream (worker isolation pins / adapter spawnEnv). scrubSessionCliHomeEnv(process.env); +// Same vector again, Claude session-identity markers: baked into pm2's saved +// env they make the tmux server this daemon may later fork mark every bot CLI +// as a nested child session (transcript saving OFF → --resume continuity +// silently lost). See CLAUDE_SESSION_MARKER_ENV_KEYS. +scrubClaudeSessionMarkerEnv(process.env); async function main() { // Resolve global UI locale from ~/.botmux/config.json BEFORE loading diff --git a/src/utils/child-env.ts b/src/utils/child-env.ts index 15d62f236..016622565 100644 --- a/src/utils/child-env.ts +++ b/src/utils/child-env.ts @@ -1,8 +1,62 @@ +/** + * Session-identity markers Claude Code exports into every Bash child it runs + * (`CLAUDECODE` plus the `CLAUDE_CODE_*`/`CLAUDE_PID` family). Any process + * started from inside a Claude session inherits them, and outside that session + * they are always stale. The destructive one is CLAUDE_CODE_CHILD_SESSION: a + * claude CLI that sees it treats itself as a nested subagent session and + * silently turns OFF transcript persistence — which breaks `--resume`-based + * continuity (respawn after a tmux session dies, role switch) for every bot. + * + * The poisoning path mirrors SESSION_CLI_HOME_ENV_KEYS: pm2 persists the env + * of whichever process ran `botmux start/restart` (or raw `pm2 start`) into + * every managed app, so one self-upgrade issued from a bot session bakes the + * issuing session's markers into all daemons; the next time the shared tmux + * server is (re)born it forks from the poisoned daemon, seeds its global env + * with the markers, and every bot CLI on the machine stops saving transcripts. + * + * CLAUDE_EFFORT is deliberately absent: it is a behavior knob a user or + * per-bot env may legitimately set, not a session-identity marker. + */ +export const CLAUDE_SESSION_MARKER_ENV_KEYS = [ + 'CLAUDECODE', + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_ENTRYPOINT', + 'CLAUDE_CODE_EXECPATH', + 'CLAUDE_PID', +] as const; + +/** Boundary-only companion to the markers. A CLAUDE_EFFORT inherited THROUGH + * pm2 → daemon → worker is indistinguishable from the issuing Claude + * session's own override and would silently pin that session's + * behavior/cost/latency onto every bot, so the boundary scrub drops it. It + * stays OUT of REDACTED_CHILD_ENV_KEYS / the pane unset / the server-global + * scrub, which keeps the supported config channels working: per-bot `env` + * (injected after redact on every backend, PTY included) and — on the + * shell-wrapped backends (tmux/zellij) — the pane shell's own profile. The + * ambient "export it in the shell that runs `botmux start`" channel is + * deliberately sacrificed: at that boundary it cannot be told apart from + * contamination. */ +const BOUNDARY_ONLY_CLAUDE_ENV_KEYS = ['CLAUDE_EFFORT'] as const; + +/** Delete inherited Claude session markers (CLAUDE_SESSION_MARKER_ENV_KEYS, + * plus BOUNDARY_ONLY_CLAUDE_ENV_KEYS) from `env` in place. Called at the same + * botmux-owned process boundaries as scrubSessionCliHomeEnv — pm2 invocation + * env (cli.ts pm2Env), daemon boot (index-daemon.ts), worker boot (worker.ts) + * — so a daemon (re)started from inside a Claude session never carries that + * session's identity into anything it forks (workers, the shared tmux + * server). */ +export function scrubClaudeSessionMarkerEnv(env: NodeJS.ProcessEnv): void { + for (const key of CLAUDE_SESSION_MARKER_ENV_KEYS) delete env[key]; + for (const key of BOUNDARY_ONLY_CLAUDE_ENV_KEYS) delete env[key]; +} + /** * Env vars that must never reach a spawned CLI child. The bot's IM-app creds * (a child CLI's own Lark OAuth reads `process.env.LARK_APP_ID` as the app to * authorize and gets hijacked by the botmux IM app → no docs scopes → 403 - * loop), daemon-side GitHub API tokens, and claude-code's nesting marker. The + * loop), daemon-side GitHub API tokens, and claude-code's session markers + * (CLAUDE_SESSION_MARKER_ENV_KEYS). The * child resolves Lark via the namespaced `BOTMUX_LARK_APP_ID` or via bots.json * on disk (im/lark/client.ts); the worker keeps its own bare creds * (worker-pool.ts forkWorker) for lark-upload — only the *child* is redacted. @@ -18,7 +72,7 @@ export const REDACTED_CHILD_ENV_KEYS = [ 'LARK_APP_SECRET', 'GITHUB_TOKEN', 'GH_TOKEN', - 'CLAUDECODE', + ...CLAUDE_SESSION_MARKER_ENV_KEYS, // Parent-tmux client vars: when the daemon itself was started inside a tmux // session, process.env carries TMUX (server socket path) + TMUX_PANE. Leaking // these into a spawned CLI makes tmux-aware tools (codex integrates with tmux) @@ -174,7 +228,12 @@ const TMUX_SERVER_GLOBAL_SCRUB_KEYS: ReadonlySet = new Set([ ...BOTMUX_INJECTED_ENV_KEYS, 'LARK_APP_ID', 'LARK_APP_SECRET', - 'CLAUDECODE', + // Claude session markers are stale by definition in a server's GLOBAL env + // table (they can only have been seeded by whatever process booted the + // server), so unlike user-wide GitHub tokens they are safe to repair out of + // an already-running server — this is what heals a fleet whose tmux server + // was born from a poisoned daemon without waiting for the server to die. + ...CLAUDE_SESSION_MARKER_ENV_KEYS, ]); /** diff --git a/src/worker.ts b/src/worker.ts index 4c7472627..423053c70 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -227,7 +227,7 @@ import { import { parseWorkerRequestUrl } from './utils/worker-http.js'; import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState, type CliUsageLimitState } from './utils/cli-usage-limit.js'; import { uploadImageBuffer } from './utils/lark-upload.js'; -import { redactChildEnv, scrubSessionCliHomeEnv } from './utils/child-env.js'; +import { redactChildEnv, scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js'; import { decideSubmitConfirmationAction, type SubmitActivityEvidence } from './services/submit-confirmation.js'; import { config, resolveChatBotDiscoveryConfig } from './config.js'; import * as sessionStore from './services/session-store.js'; @@ -268,6 +268,12 @@ import { CodexRpcEngine } from './codex-rpc-engine.js'; // default (~/.claude relocates Claude's state file → onboarding rerun) and // why GROK_HOME is exempt. scrubSessionCliHomeEnv(process.env); +// Claude session-identity markers ride the same restart-from-a-session vector +// and this process's env seeds childEnv AND the tmux client env — a marker +// that survives to the first `tmux new-session` gets copied into the shared +// server's global env and flips transcript saving off for every pane (see +// CLAUDE_SESSION_MARKER_ENV_KEYS). +scrubClaudeSessionMarkerEnv(process.env); // ─── State ─────────────────────────────────────────────────────────────────── diff --git a/test/child-env.test.ts b/test/child-env.test.ts index a12dc02b9..970bd38e5 100644 --- a/test/child-env.test.ts +++ b/test/child-env.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; -import { redactChildEnv, scrubSessionCliHomeEnv, SESSION_CLI_HOME_ENV_KEYS } from '../src/utils/child-env.js'; +import { + CLAUDE_SESSION_MARKER_ENV_KEYS, + redactChildEnv, + scrubClaudeSessionMarkerEnv, + scrubSessionCliHomeEnv, + SESSION_CLI_HOME_ENV_KEYS, +} from '../src/utils/child-env.js'; describe('redactChildEnv()', () => { it('truly removes leaked keys — absent, not present-with-"undefined"', () => { @@ -31,6 +37,21 @@ describe('redactChildEnv()', () => { expect(base.CLAUDECODE).toBe('1'); }); + it('removes every Claude session marker from child env', () => { + // CLAUDE_CODE_CHILD_SESSION is the destructive one — an inherited marker + // makes the CLI treat itself as a nested subagent session and stop saving + // transcripts, silently breaking --resume continuity. The rest are the + // dead parent session's identity and must not reach a fresh CLI either. + const base = Object.fromEntries(CLAUDE_SESSION_MARKER_ENV_KEYS.map((k) => [k, 'leaked'])); + const out = redactChildEnv({ ...base, CLAUDE_EFFORT: 'high', KEEP: 'v' }); + for (const key of CLAUDE_SESSION_MARKER_ENV_KEYS) { + expect(key in out, key).toBe(false); + } + // Behavior knob, not an identity marker — must survive. + expect(out.CLAUDE_EFFORT).toBe('high'); + expect(out.KEEP).toBe('v'); + }); + it('removes GitHub tokens from child env', () => { const out = redactChildEnv({ GITHUB_TOKEN: 'ghp_secret', @@ -100,6 +121,36 @@ describe('scrubSessionCliHomeEnv()', () => { }); }); +describe('scrubClaudeSessionMarkerEnv()', () => { + it('deletes every inherited Claude session marker in place, keys absent not undefined', () => { + const env: NodeJS.ProcessEnv = { + ...Object.fromEntries(CLAUDE_SESSION_MARKER_ENV_KEYS.map((k) => [k, 'stale'])), + KEEP: 'v', + PATH: '/usr/bin', + }; + scrubClaudeSessionMarkerEnv(env); + for (const key of CLAUDE_SESSION_MARKER_ENV_KEYS) { + expect(key in env, key).toBe(false); + } + expect(env.KEEP).toBe('v'); + expect(env.PATH).toBe('/usr/bin'); + }); + + it('also drops CLAUDE_EFFORT at boundaries — inherited it can only be the issuing session\'s', () => { + // An effort override that rode pm2 → daemon → worker would silently pin the + // issuing Claude session's effort onto every bot (behavior/cost/latency). + // The supported channels land AFTER this boundary scrub and keep working: + // per-bot env injection (all backends, PTY included) and the pane shell's + // profile (shell-wrapped backends) — hence the key is NOT in + // CLAUDE_SESSION_MARKER_ENV_KEYS (no pane unset, no server scrub, and + // redactChildEnv keeps it, as the marker test above pins). + const env: NodeJS.ProcessEnv = { CLAUDE_EFFORT: 'high' }; + scrubClaudeSessionMarkerEnv(env); + expect('CLAUDE_EFFORT' in env).toBe(false); + expect(CLAUDE_SESSION_MARKER_ENV_KEYS).not.toContain('CLAUDE_EFFORT'); + }); +}); + describe('session CLI home scrub call sites', () => { // The scrub only works if every process boundary actually invokes it. These // source-level pins keep a refactor from silently dropping a boundary: @@ -122,4 +173,15 @@ describe('session CLI home scrub call sites', () => { it('worker.ts scrubs process.env at boot', () => { expect(read('worker.ts')).toContain('scrubSessionCliHomeEnv(process.env)'); }); + + it('all three boundaries also scrub Claude session markers', () => { + // Same rationale, same boundaries: a marker that survives pm2's persisted + // env or a stale dump.pm2 reaches the daemon → the tmux server it forks → + // every pane on the machine. + const cli = read('cli.ts'); + const fn = cli.slice(cli.indexOf('function pm2Env(')); + expect(fn.slice(0, fn.indexOf('\n}'))).toContain('scrubClaudeSessionMarkerEnv('); + expect(read('index-daemon.ts')).toContain('scrubClaudeSessionMarkerEnv(process.env)'); + expect(read('worker.ts')).toContain('scrubClaudeSessionMarkerEnv(process.env)'); + }); }); diff --git a/test/per-bot-env.test.ts b/test/per-bot-env.test.ts index c6d1a7c1e..659b1a299 100644 --- a/test/per-bot-env.test.ts +++ b/test/per-bot-env.test.ts @@ -46,6 +46,11 @@ describe('sanitizePerBotEnv()', () => { LARK_APP_ID: 'cli_x', LARK_APP_SECRET: 's', CLAUDECODE: '1', + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: 'foreign-session', + CLAUDE_CODE_ENTRYPOINT: 'cli', + CLAUDE_CODE_EXECPATH: '/tmp/evil-claude', + CLAUDE_PID: '1234', CLAUDE_CONFIG_DIR: '/tmp/evil', CODEX_HOME: '/tmp/evil-codex', GROK_HOME: '/tmp/evil-grok', @@ -75,6 +80,8 @@ describe('isReservedPerBotEnvKey()', () => { 'BOTMUX', 'BOTMUX_SESSION_ID', 'BOTMUX_ANYTHING', 'LARK_APP_ID', 'LARK_APP_SECRET', 'CLAUDECODE', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_RESUME_TOKEN_THRESHOLD', + 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CODEX_HOME', 'GROK_HOME', 'CJADK_INTERACTIVE', 'IS_SANDBOX', 'SESSION_DATA_DIR', '__OWNER_OPEN_ID', ]) { @@ -86,6 +93,8 @@ describe('isReservedPerBotEnvKey()', () => { for (const k of [ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'MY_FLAG', + // Behavior knob, not a session-identity marker — per-bot env may set it. + 'CLAUDE_EFFORT', ]) { expect(isReservedPerBotEnvKey(k), k).toBe(false); } diff --git a/test/tmux-env-isolation.test.ts b/test/tmux-env-isolation.test.ts index 2e48600bd..a7f3306f6 100644 --- a/test/tmux-env-isolation.test.ts +++ b/test/tmux-env-isolation.test.ts @@ -82,6 +82,13 @@ describe('tmuxEnv()', () => { LARK_APP_ID: 'cli_bot', // bare creds must not seed the global either LARK_APP_SECRET: 'secret', CLAUDECODE: '1', + // Claude session markers: seeded into the server global they flip + // transcript saving OFF in every pane's CLI (CLAUDE_CODE_CHILD_SESSION). + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: 'e48f97c3-dead-dead-dead-parent-session', + CLAUDE_CODE_ENTRYPOINT: 'cli', + CLAUDE_CODE_EXECPATH: '/stale/claude.exe', + CLAUDE_PID: '41028', // Legit passthrough — the tmux client still needs these. PATH: '/usr/bin', HOME: '/root', @@ -97,6 +104,8 @@ describe('tmuxEnv()', () => { 'HERMES_BOTMUX_PROFILES_ROOT', 'CLAUDE_CODE_RESUME_TOKEN_THRESHOLD', 'CJADK_INTERACTIVE', 'LARK_APP_ID', 'LARK_APP_SECRET', 'CLAUDECODE', + 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', ]) { expect(stripped[leaked]).toBeUndefined(); } @@ -123,7 +132,14 @@ describe('tmuxEnv()', () => { it('does not classify GitHub tokens as botmux-owned tmux server-global keys', () => { expect(isBotmuxManagedTmuxServerGlobalEnvKey('GITHUB_TOKEN')).toBe(false); expect(isBotmuxManagedTmuxServerGlobalEnvKey('GH_TOKEN')).toBe(false); - for (const key of ['BOTMUX_SESSION_ID', 'LARK_APP_ID', 'LARK_APP_SECRET', 'CLAUDECODE']) { + for (const key of [ + 'BOTMUX_SESSION_ID', 'LARK_APP_ID', 'LARK_APP_SECRET', 'CLAUDECODE', + // Claude session markers ARE repairable out of a running server: in a + // GLOBAL env table they can only be stale (seeded by whatever process + // booted the server), unlike a user's general-purpose tokens. + 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', + ]) { expect(isBotmuxManagedTmuxServerGlobalEnvKey(key), key).toBe(true); } }); @@ -290,6 +306,12 @@ describe('tmux subcommand with stale $TMUX', () => { LARK_APP_ID: 'stale-app', GITHUB_TOKEN: 'ghp_server_global', GH_TOKEN: 'ghs_server_global', + // The 2026-07 incident shape: a tmux server born from a daemon that was + // pm2-restarted inside a Claude session carries the issuing session's + // markers in its global env, and every pane's CLI stops saving + // transcripts (CLAUDE_CODE_CHILD_SESSION → nested-child mode). + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: 'stale-claude-session', HOME: '/safe/home', }; delete pollutedEnv.TMUX; @@ -309,6 +331,7 @@ describe('tmux subcommand with stale $TMUX', () => { expect(scrub.failed).toEqual([]); expect(scrub.removed).toEqual(expect.arrayContaining([ 'BOTMUX_SESSION_ID', 'BOTMUX_CHAT_ID', 'CODEX_HOME', 'HERMES_HOME', 'LARK_APP_ID', + 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', ])); expect(scrub.removed).not.toContain('GITHUB_TOKEN'); expect(scrub.removed).not.toContain('GH_TOKEN'); @@ -320,7 +343,10 @@ describe('tmux subcommand with stale $TMUX', () => { expect(globalEnv.stdout).toContain('HOME=/safe/home'); expect(globalEnv.stdout).toContain('GITHUB_TOKEN=ghp_server_global'); expect(globalEnv.stdout).toContain('GH_TOKEN=ghs_server_global'); - for (const key of ['BOTMUX_SESSION_ID', 'BOTMUX_CHAT_ID', 'CODEX_HOME', 'HERMES_HOME', 'LARK_APP_ID']) { + for (const key of [ + 'BOTMUX_SESSION_ID', 'BOTMUX_CHAT_ID', 'CODEX_HOME', 'HERMES_HOME', 'LARK_APP_ID', + 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', + ]) { expect(globalEnv.stdout).not.toMatch(new RegExp(`(?:^|\\n)-?${key}(?:=|\\n|$)`)); } @@ -339,6 +365,7 @@ describe('tmux subcommand with stale $TMUX', () => { const existingEnv = readFileSync(existingOut, 'utf-8').split('\n'); expect(existingEnv).toContain('BOTMUX_SESSION_ID=stale-session'); expect(existingEnv).toContain('CODEX_HOME=/stale/codex'); + expect(existingEnv).toContain('CLAUDE_CODE_CHILD_SESSION=1'); // A raw pane created after the scrub no longer inherits any stale key. const fresh = spawnSync('tmux', [ @@ -352,7 +379,10 @@ describe('tmux subcommand with stale $TMUX', () => { env: tmuxEnv(), timeout: 5000, }).status).toBe(0); const freshEnv = readFileSync(freshOut, 'utf-8').split('\n'); - for (const key of ['BOTMUX_SESSION_ID', 'BOTMUX_CHAT_ID', 'CODEX_HOME', 'HERMES_HOME', 'LARK_APP_ID']) { + for (const key of [ + 'BOTMUX_SESSION_ID', 'BOTMUX_CHAT_ID', 'CODEX_HOME', 'HERMES_HOME', 'LARK_APP_ID', + 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', + ]) { expect(freshEnv.some(line => line.startsWith(`${key}=`)), key).toBe(false); } expect(freshEnv).toContain('HOME=/safe/home');