diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index eb721668..d9cee15c 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -34,6 +34,39 @@ function resolveClaudeBin(): string { const CLAUDE_BIN = resolveClaudeBin(); console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`); +/** + * Variables the `claude` CLI injects to describe ITS OWN session, which must never reach a `claude` we + * spawn ourselves. + * + * They arrive here by an ordinary accident: PM2 inherits the environment of whoever ran `pm2 start`, so + * restarting this sidecar from inside a Claude Code terminal — which is how it is restarted most of the + * time — bakes that terminal's session into the daemon. On 2026-08-14 this process was carrying + * `CLAUDE_CODE_MESSAGING_SOCKET` for an unrelated PID that had been alive for an hour and a half. + * + * Only three of these were stripped before (`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SSE_PORT`); + * the rest are newer and arrived with 2.x. Stripping them is hygiene rather than a fix — a spawn was + * verified to succeed with the whole set present — but the failure it prevents is a child attaching to a + * stranger's IPC socket, which would be extremely hard to recognise from the symptom. + */ +const NESTED_SESSION_ENV = [ + 'CLAUDECODE', + 'CLAUDE_CODE_ENTRYPOINT', + 'CLAUDE_CODE_SSE_PORT', + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_MESSAGING_SOCKET', + 'CLAUDE_CODE_MESSAGING_TOKEN', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_EXECPATH', + 'CLAUDE_PID', +] as const; + +/** `process.env` minus the parent session's fingerprint. */ +function envWithoutParentSession(): Record { + const out: Record = { ...process.env } as Record; + for (const name of NESTED_SESSION_ENV) delete out[name]; + return out; +} + // Capture original HOME before user-instance overrides it const HOST_HOME = process.env.HOME!; import { DATA_PATH } from '../../data-path'; @@ -404,7 +437,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat }; // Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean). - const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env; + const cleanEnv = envWithoutParentSession(); const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId; const subModel = params.model?.split('/')[1];