Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
10 changes: 10 additions & 0 deletions src/core/per-bot-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,17 @@ const RESERVED_ENV_KEYS = new Set<string>([
'__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
Expand Down
7 changes: 6 additions & 1 deletion src/index-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 62 additions & 3 deletions src/utils/child-env.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -174,7 +228,12 @@ const TMUX_SERVER_GLOBAL_SCRUB_KEYS: ReadonlySet<string> = 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,
]);

/**
Expand Down
8 changes: 7 additions & 1 deletion src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand Down
64 changes: 63 additions & 1 deletion test/child-env.test.ts
Original file line number Diff line number Diff line change
@@ -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"', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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:
Expand All @@ -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)');
});
});
9 changes: 9 additions & 0 deletions test/per-bot-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
]) {
Expand All @@ -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);
}
Expand Down
36 changes: 33 additions & 3 deletions test/tmux-env-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
}
Expand All @@ -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);
}
});
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -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|$)`));
}

Expand All @@ -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', [
Expand All @@ -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');
Expand Down