From 2a640abfbe0ca7db3ae00c3204aa90fdd96c8211 Mon Sep 17 00:00:00 2001 From: David Anderson <215816+akapug@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:55:31 -0700 Subject: [PATCH] fix(pty): strip inherited Claude child-session stamps at spawn (#9961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Orca GUI or daemon launched from inside a Claude Code session inherits CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID. Every spawn path spreads the host's process.env, so each terminal Orca opens is marked a nested Claude child and Claude silently disables transcript persistence — real sessions stop writing on-disk history with no visible error. Older-protocol daemons are deliberately preserved across upgrades and the auto-updater relaunch inherits the previous app's env, so one contaminated launch propagates through subsequent updates. Orca never sets these variables, so an inherited value is always poison. Add a deny constant plus an inherited-only filter merged into envToDelete at both pty spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the agent-hook keys this is not gated on isDaemonHostSpawn, because the local provider and the relay host spread their own process.env too. Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a `string[] | undefined` intermediate into a `readonly string[]` parameter and did not typecheck); coverage extends to the runtime-controller spawn path, the local provider, and the SSH route, whose exact spawn-options assertion had to be updated because envToDelete is no longer ever undefined. --- src/main/ipc/pty.test.ts | 133 +++++++++++++++++++ src/main/ipc/pty.ts | 46 ++++--- src/main/providers/provider-dispatch.test.ts | 20 ++- 3 files changed, 178 insertions(+), 21 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 299e7b5c7..763085c06 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1683,6 +1683,28 @@ describe('registerPtyHandlers', () => { expect(env.LANG).toBe('fr_FR.UTF-8') }) + it('strips inherited Claude child-session stamps from a local spawn env', async () => { + // Why: the local provider spreads main's process.env, so a GUI launched from + // inside a Claude session would stamp every pane as a nested child and Claude + // would silently disable transcript persistence. Not gated on isDaemonHostSpawn. + const env = await spawnAndGetEnv(undefined, { + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'session_01UCkWN5nDXNyD1V7cfamCxa' + }) + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() + }) + + it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { + const env = await spawnAndGetEnv( + { CLAUDE_CODE_CHILD_SESSION: '1' }, + { CLAUDE_CODE_CHILD_SESSION: '1' } + ) + expect(env.CLAUDE_CODE_CHILD_SESSION).toBe('1') + }) + it('always sets TERM and COLORTERM regardless of env', async () => { const env = await spawnAndGetEnv() expect(env.TERM).toBe('xterm-256color') @@ -2902,6 +2924,39 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete ?? []).not.toEqual(expect.arrayContaining(['CODEX_HOME'])) }) + it('strips inherited Claude child-session stamps from daemon spawns', async () => { + // Why: a daemon forked from inside a Claude Code session inherits these + // stamps and would mark every terminal as a nested Claude child, which + // silently disables transcript persistence for real user sessions. + const spawnOptions = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'session_01UCkWN5nDXNyD1V7cfamCxa' + }) + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ]) + ) + }) + + it('preserves an explicitly requested Claude child-session stamp', async () => { + // Why: only inherited values are poison; a caller deliberately spawning a + // nested Claude child passes the stamp in args.env and must keep it. + const spawnOptions = await daemonSpawnAndGetOptions( + { CLAUDE_CODE_CHILD_SESSION: '1' }, + undefined, + undefined, + { CLAUDE_CODE_CHILD_SESSION: '1' } + ) + expect(spawnOptions.envToDelete ?? []).not.toEqual( + expect.arrayContaining(['CLAUDE_CODE_CHILD_SESSION']) + ) + expect(spawnOptions.env.CLAUDE_CODE_CHILD_SESSION).toBe('1') + }) + it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { @@ -2983,6 +3038,84 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => { + // Why: the runtime controller is the `orca` CLI / automation spawn path and + // assembles envToDelete separately from the renderer's pty:spawn handler; + // without its own case the two paths can silently drift apart. + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ]) + ) + }) + + it('strips inherited Claude child-session stamps from a local runtime-created PTY', async () => { + // Why: the runtime strip is deliberately not gated on isDaemonHostSpawn, so + // the local provider — which spreads main's own process.env — needs its own + // case; a daemon-only test would still pass if someone added that gate. + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'handle-runtime-local') + } + const saved = process.env.CLAUDE_CODE_CHILD_SESSION + process.env.CLAUDE_CODE_CHILD_SESSION = '1' + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, env: {} }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + } finally { + if (saved === undefined) { + delete process.env.CLAUDE_CODE_CHILD_SESSION + } else { + process.env.CLAUDE_CODE_CHILD_SESSION = saved + } + } + }) + it('threads the validated pane identity into registerPty for a runtime-created daemon PTY (#7587)', async () => { type RuntimeSpawnController = { spawn(args: { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5e81dbba6..b1a592724 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -238,6 +238,13 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' ] as const +// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const + export function getPtyIdForPaneKey(paneKey: string): string | undefined { return paneKeyPtyId.get(paneKey) } @@ -902,14 +909,15 @@ function exposePiManagedExtensionEnv( } } +// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. function mergePtyEnvDeletions( existingKeys: string[] | undefined, - additionalKeys: readonly string[] + ...additionalKeyGroups: readonly (readonly string[])[] ): string[] | undefined { - if (!existingKeys && additionalKeys.length === 0) { + if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { return undefined } - return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) } function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { @@ -926,6 +934,15 @@ function getInheritedAgentHookEnvKeysToDelete( return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) } +function getInheritedClaudeSessionStampEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: strip only values inherited from the pty host; a caller that explicitly + // provides a stamp (deliberately spawning a nested Claude child) keeps it. + return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) +} + // Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. function restoreOrStripOverlayEnv( baseEnv: Record, @@ -3328,8 +3345,11 @@ export function registerPtyHandlers( args.onPtySpawnCommitted?.() } spawnOptions.envToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] + authEnvToDelete, + args.envToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], + // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. + getInheritedClaudeSessionStampEnvKeysToDelete(env) ) if (skipCodexHomeEnv) { spawnOptions.envToDelete = mergePtyEnvDeletions( @@ -4473,16 +4493,12 @@ export function registerPtyHandlers( ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined let combinedEnvToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), - agentTeamsEnvToDelete ?? [] - ), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] - ), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] - ), + envToDelete, + args.envToDelete ?? [], + agentTeamsEnvToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; // main cannot safely decide ownership for a process it may not parent. stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index be45aa04f..57d7e3f81 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -141,12 +141,20 @@ describe('PTY provider dispatch', () => { })) as { id: string } expect(result.id).toBe('ssh-pty-1') - expect(mockSshProvider.spawn).toHaveBeenCalledWith({ - cols: 80, - rows: 24, - cwd: undefined, - env: undefined - }) + // Why: the relay host can be launched from a Claude session too, so the stamps are + // stripped on the SSH path as well. Compared as a set — envToDelete is consumed by + // membership only, so a reordering of the merge sources must not fail this. + const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] + expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( + [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ].sort() + ) + expect(mockSshProvider.spawn).toHaveBeenCalledWith( + expect.objectContaining({ cols: 80, rows: 24, cwd: undefined, env: undefined }) + ) unregisterSshPtyProvider('conn-123') })