diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts index ac5444566d9..ba2daf90c30 100644 --- a/src/cli/handlers/core.test.ts +++ b/src/cli/handlers/core.test.ts @@ -130,4 +130,50 @@ describe('orca claude-teams CLI handler', () => { expect(spawnEnv.PATH).toBe('/shim:/usr/bin') } ) + + it.skipIf(isWindows)('removes managed auth variables before spawning Claude', async () => { + const previousApiKey = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = 'sk-ant-inherited' + callMock.mockResolvedValueOnce({ + result: { + launch: { + env: { + CLAUDE_CONFIG_DIR: '/managed/claude', + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' + }, + envToDelete: ['ANTHROPIC_API_KEY'] + } + } + }) + try { + await runClaudeTeams() + } finally { + if (previousApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = previousApiKey + } + } + + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ANTHROPIC_API_KEY).toBeUndefined() + expect(spawnEnv.CLAUDE_CONFIG_DIR).toBe('/managed/claude') + }) + + it.skipIf(isWindows)('preserves API-key auth when no managed deletion is requested', async () => { + const previousApiKey = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = 'sk-ant-system' + try { + await runClaudeTeams() + } finally { + if (previousApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = previousApiKey + } + } + + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ANTHROPIC_API_KEY).toBe('sk-ant-system') + }) }) diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index d4979ff2ae9..6a1b7ab3918 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -73,16 +73,20 @@ export const CORE_HANDLERS: Record = { 'orca claude-teams must be run inside an Orca terminal.' ) } - const response = await client.call<{ launch: { env: Record } }>( - 'agentTeams.prepareLaunch', - { - paneKey, - env: envRecord() - } - ) + const inheritedEnv = envRecord() + const response = await client.call<{ + launch: { env: Record; envToDelete?: string[] } + }>('agentTeams.prepareLaunch', { + paneKey, + env: inheritedEnv, + prepareAuth: true + }) + for (const key of response.result.launch.envToDelete ?? []) { + delete inheritedEnv[key] + } process.exitCode = await runClaudeAgentTeams( { - ...envRecord(), + ...inheritedEnv, ...response.result.launch.env }, rawArgs ?? [] diff --git a/src/cli/index-worktree-selector-resolution.test.ts b/src/cli/index-worktree-selector-resolution.test.ts index 70c5ca57ad5..c3f3d7dbd60 100644 --- a/src/cli/index-worktree-selector-resolution.test.ts +++ b/src/cli/index-worktree-selector-resolution.test.ts @@ -153,6 +153,7 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('agentTeams.prepareLaunch', { paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + prepareAuth: true, env: expect.objectContaining({ ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111' }) diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 4723e186039..49d55d96c17 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -14,6 +14,7 @@ import { ensureClaudeAgentTeamsShimDir, resolveClaudeAgentTeamsShimBin } from './claude-agent-teams-shim-env' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPtyBackedTerminal { protected resolveTerminalSplitSourceAuthority( @@ -109,6 +110,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt async prepareClaudeAgentTeamsLeader(args: { paneKey: string baseEnv?: Record + prepareAuth?: boolean }): Promise<{ env: Record }> { const handle = this.getTerminalHandleForPaneKey(args.paneKey) if (!handle) { @@ -116,26 +118,38 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt } return await this.prepareClaudeAgentTeamsLeaderForHandle({ handle, - baseEnv: args.baseEnv + baseEnv: args.baseEnv, + prepareAuth: args.prepareAuth }) } async prepareClaudeAgentTeamsLeaderForHandle(args: { handle: string baseEnv?: Record - }): Promise<{ env: Record }> { + prepareAuth?: boolean + }): Promise<{ env: Record; envToDelete?: string[] }> { const baseEnv = { ...process.env, ...args.baseEnv } + const inheritedEnvKeys = new Set(Object.keys(baseEnv)) + const auth = args.prepareAuth && this.prepareClaudeAuth ? await this.prepareClaudeAuth() : null + if (auth) { + applyClaudeEnvPatch(baseEnv, auth.envPatch, { stripAuthEnv: auth.stripAuthEnv }) + } + const envToDelete = auth?.stripAuthEnv + ? [...inheritedEnvKeys].filter((key) => !(key in baseEnv)) + : undefined const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) - return this.claudeAgentTeams.createLaunchEnv({ + const launch = this.claudeAgentTeams.createLaunchEnv({ leaderHandle: args.handle, baseEnv, shimDir, shimBin }) + const env = auth ? { ...auth.envPatch, ...launch.env } : launch.env + return envToDelete ? { env, envToDelete } : { env } } // Why: a leader handle that never binds to a PTY (lost pane race) has no exit diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 781f6075be1..a28ead15724 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands' import type { RuntimeStore } from './runtime-store-contract' import type { StatsCollector } from '../stats/collector' import type { IPtyProvider } from '../providers/types' +import type { PrepareClaudeAuth } from '../ipc/pty/host-env/types' import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' @@ -41,12 +42,15 @@ import { registerConptyDa1OverrideInstaller } from './terminal-model-query-autho import { registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { + protected readonly prepareClaudeAuth?: PrepareClaudeAuth + constructor( store: RuntimeStore | null = null, stats?: StatsCollector, deps?: { getLocalProvider?: () => IPtyProvider getSshProvider?: (connectionId: string) => IPtyProvider | undefined + prepareClaudeAuth?: PrepareClaudeAuth onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void @@ -103,6 +107,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { ) { super() this.store = store + this.prepareClaudeAuth = deps?.prepareClaudeAuth store?.onSettingsChanged?.((updates) => { if ('experimentalStructuredNativeChat' in updates) { this.notifyMobileSessionTabsChanged() diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 2fcdc2bc92c..58e1d5d825c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -189,7 +189,8 @@ export const TERMINAL_LIFECYCLE_METHODS = [ handler: async (params, { runtime }) => ({ launch: await runtime.prepareClaudeAgentTeamsLeader({ paneKey: params.paneKey, - baseEnv: params.env + baseEnv: params.env, + prepareAuth: params.prepareAuth }) }) }) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 62716bbcdec..35d008d1b40 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -72,6 +72,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // `orca serve`, which never opens one, and the fleet path runs there too. const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const runtime = new OrcaRuntimeService(store, stats, { + prepareClaudeAuth: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), agentSessionClaimSigner: loadAgentSessionClaimSigner( getProfileUserDataPath(), getProfileUserDataPath() diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index 9b735952096..ae34af7ebd9 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -234,5 +234,6 @@ export const AgentTeamsTmuxCompat = z.object({ export const AgentTeamsPrepareLaunch = z.object({ paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() + env: z.record(z.string(), z.string()).optional(), + prepareAuth: z.boolean().optional() })