fix(claude): scrub SDK child env and localize pending launch

This commit is contained in:
Merge Sim
2026-09-02 22:48:45 -07:00
parent 16a9f5b924
commit 97486e62ec
7 changed files with 94 additions and 25 deletions
@@ -41,4 +41,23 @@ describe('Claude child process environment', () => {
SAFE_VALUE: 'preserved'
})
})
it('can strip child-session stamps reintroduced by a full SDK launch overlay', () => {
expect(
buildClaudeChildProcessEnv(
{
CLAUDE_CODE_CHILD_SESSION: 'configured-child-session',
CLAUDE_CODE_SESSION_ID: 'configured-session',
CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session'
},
{
scrubConfiguredChildSessionStamps: true,
inheritedEnv: {
CLAUDE_CODE_CHILD_SESSION: 'inherited-child-session',
SAFE_VALUE: 'preserved'
}
}
)
).toEqual({ SAFE_VALUE: 'preserved' })
})
})
@@ -16,9 +16,27 @@ function cloneProcessEnv(source: NodeJS.ProcessEnv): Record<string, string> {
return env
}
function stripClaudeChildSessionStamps(
env: Record<string, string>,
platform: NodeJS.Platform
): Record<string, string> {
for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) {
for (const envKey of Object.keys(env)) {
if (envKey === key || (platform === 'win32' && envKey.toUpperCase() === key)) {
delete env[envKey]
}
}
}
return env
}
export function buildClaudeChildProcessEnv(
configuredEnv: Record<string, string> = {},
options: { inheritedEnv?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
options: {
inheritedEnv?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
scrubConfiguredChildSessionStamps?: boolean
} = {}
): Record<string, string> {
const inheritedEnv = options.inheritedEnv ?? process.env
const platform = options.platform ?? process.platform
@@ -43,12 +61,9 @@ export function buildClaudeChildProcessEnv(
}
}
}
for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) {
for (const inheritedKey of Object.keys(env)) {
if (inheritedKey === key || (platform === 'win32' && inheritedKey.toUpperCase() === key)) {
delete env[inheritedKey]
}
}
if (options.scrubConfiguredChildSessionStamps) {
return stripClaudeChildSessionStamps({ ...env, ...configuredEnv }, platform)
}
return Object.assign(env, configuredEnv)
stripClaudeChildSessionStamps(env, platform)
return { ...env, ...configuredEnv }
}
@@ -150,7 +150,10 @@ describe('Claude stream-json connection', () => {
launchFor(scenario, {
CLAUDE_CONFIG_DIR: '/accounts/managed/home',
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9'
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9',
CLAUDE_CODE_CHILD_SESSION: 'configured-child-session',
CLAUDE_CODE_SESSION_ID: 'configured-session',
CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session'
})
)
@@ -165,6 +168,8 @@ describe('Claude stream-json connection', () => {
expect(env.ORCA_CONNECTION_MARKER).toBe('inherited')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined()
expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined()
// Two SDK mutations of the child env, pinned so a bump cannot change them unseen.
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts')
expect(env.NODE_OPTIONS).toBeUndefined()
@@ -107,7 +107,7 @@ export async function openClaudeStreamJsonConnection(
cwd: launch.cwd,
// Why env is never omitted: the SDK inherits process.env when it is, which is
// exactly the ambient ANTHROPIC_* auth leak this lane already shipped once.
env: buildClaudeChildProcessEnv(launch.env),
env: buildClaudeChildProcessEnv(launch.env, { scrubConfiguredChildSessionStamps: true }),
pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable,
spawnClaudeCodeProcess: spawner.spawn,
...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}),
@@ -9,7 +9,9 @@ const {
openSettingsPageMock,
openSettingsTargetMock,
useDetectedAgentsMock,
useStructuredAgentSessionCreateMock
useStructuredAgentSessionCreateMock,
useStructuredCodexLaunchStatusMock,
translateMock
} = vi.hoisted(() => ({
shortcutLabelMock: vi.fn<() => string | null>(),
storeState: {
@@ -29,7 +31,20 @@ const {
supported: true,
creating: false,
create: vi.fn(async () => true)
}))
})),
useStructuredCodexLaunchStatusMock: vi.fn(() => 'idle' as 'idle' | 'pending' | 'failed'),
translateMock: vi.fn((key: string, fallback: string, values?: Record<string, string>): string => {
const template =
key === 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e'
? 'Launch {{value0}} in a new terminal'
: key === 'auto.components.tab.bar.QuickLaunchButton.startingCodexChat'
? 'Starting Codex chat (localized)…'
: fallback
return Object.entries(values ?? {}).reduce(
(text, [name, value]) => text.replace(`{{${name}}}`, value),
template
)
})
}))
vi.mock('@/hooks/useDetectedAgents', () => ({
@@ -44,6 +59,10 @@ vi.mock('../native-chat/use-structured-agent-session-create', () => ({
useStructuredAgentSessionCreate: useStructuredAgentSessionCreateMock
}))
vi.mock('@/lib/structured-agent-session-launch', () => ({
useStructuredCodexLaunchStatus: useStructuredCodexLaunchStatusMock
}))
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector: (state: typeof storeState) => unknown) => {
@@ -85,13 +104,7 @@ vi.mock('@/components/ui/dropdown-menu', async () => {
}
})
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
Object.entries(values ?? {}).reduce(
(text, [key, value]) => text.replace(`{{${key}}}`, value),
fallback
)
}))
vi.mock('@/i18n/i18n', () => ({ translate: translateMock }))
vi.mock('sonner', () => ({
toast: {
@@ -128,6 +141,9 @@ beforeEach(() => {
shortcutLabelMock.mockReturnValue(null)
useDetectedAgentsMock.mockClear()
useStructuredAgentSessionCreateMock.mockClear()
useStructuredCodexLaunchStatusMock.mockReset()
useStructuredCodexLaunchStatusMock.mockReturnValue('idle')
translateMock.mockClear()
openSettingsPageMock.mockReset()
openSettingsTargetMock.mockReset()
storeState.settings.defaultTuiAgent = 'codex'
@@ -227,6 +243,16 @@ describe('QuickLaunchAgentMenuItems', () => {
expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'claude')
expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'codex')
})
it('localizes pending Codex text and interpolates the existing launch tooltip', () => {
useStructuredCodexLaunchStatusMock.mockReturnValue('pending')
const html = renderAgentMenuItems()
expect(html).toContain('Starting Codex chat (localized)…')
expect(html).toContain('title="Launch Codex in a new terminal"')
expect(html).not.toContain('{{value0}}')
})
})
describe('shouldShowLaunchWatchdogTimeout', () => {
@@ -210,7 +210,12 @@ function QuickLaunchAgentMenuItemsInner({
: null
const isStructuredCodexPending =
agent === 'codex' && structuredCodexLaunchStatus === 'pending'
const menuLabel = isStructuredCodexPending ? 'Starting Codex chat…' : label
const menuLabel = isStructuredCodexPending
? translate(
'auto.components.tab.bar.QuickLaunchButton.startingCodexChat',
'Starting Codex chat…'
)
: label
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
@@ -221,10 +226,8 @@ function QuickLaunchAgentMenuItemsInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
title={translate(
'auto.components.tab.bar.QuickLaunchButton.ec2adf093e',
isStructuredCodexPending
? 'Starting Codex chat…'
: 'Launch {{value0}} in a new terminal',
isStructuredCodexPending ? undefined : { value0: label }
'Launch {{value0}} in a new terminal',
{ value0: label }
)}
>
{isStructuredCodexPending ? (
+2 -1
View File
@@ -3330,7 +3330,8 @@
"8dea9b5cdf": "No enabled agents",
"f378201fbd": "Could not create chat session",
"a847779775": "Start {{value0}} without a terminal",
"0ea6a78efb": "Chat session"
"0ea6a78efb": "Chat session",
"startingCodexChat": "Starting Codex chat…"
},
"RecentTabSwitcher": {
"329638ff6f": "Switch Tab",