fix(pty): strip inherited Claude child-session stamps at spawn (#9961)

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.
This commit is contained in:
David Anderson 2026-07-27 15:55:31 -07:00 committed by GitHub
parent ab60045371
commit 2a640abfbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 178 additions and 21 deletions

View File

@ -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<string, string>
}): 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<string, string>
}): 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<string, string>
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: {

View File

@ -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<string, string> | 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<string, string>,
@ -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'] : []

View File

@ -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')
})