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:
parent
ab60045371
commit
2a640abfbe
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'] : []
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue