diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 101be8995dd..21d19774968 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,3 +1,5 @@ +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' + export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', @@ -41,6 +43,33 @@ export function applyClaudeEnvPatch( return baseEnv } +/** One string for every transport, so a terminal launch and a structured launch + * cannot drift into telling the user two different things about one refusal. */ +export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + +export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE = + 'A Claude account switch is in progress. Try again after it finishes.' + +/** + * Whether a launch on the host runtime must drop inherited Anthropic auth. + * + * Only a pinned host-managed account owns the credential, so only it may strip: + * with no managed account the user's own `ANTHROPIC_*` is their sign-in, and + * removing it signs them out of a CLI that would otherwise have worked. + */ +export function shouldStripClaudeAuthEnvForAccount( + accounts: readonly ClaudeManagedAccount[] | undefined, + activeAccountId: string | null | undefined +): boolean { + if (!activeAccountId) { + return false + } + return ( + (accounts ?? []).find((account) => account.id === activeAccountId)?.managedAuthRuntime !== 'wsl' + ) +} + export function hasClaudeAuthEnvConflict(env: Record | undefined): boolean { if (!env) { return false diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts index ae79c4c7bbb..dabcd9d472f 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { shouldStripClaudeAuthEnvForAccount } from '../environment' import { getDefaultWslDistro, getWslHome } from '../../wsl' import { getSelectedClaudeAccountIdForTarget, @@ -69,7 +70,10 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh wslDistro: null, wslLinuxConfigDir: null, envPatch: paths.envPatch, - stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + activeAccountId + ), managedRefreshDeferredByLivePty: Boolean( activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' && diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts new file mode 100644 index 00000000000..1b633553e89 --- /dev/null +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -0,0 +1,43 @@ +import { + closeClaudeSession, + claudeAcquisitionCleanupError +} from './claude-structured-session-close' +import type { + ClaudeAcquisitionRegistry, + ClaudeSession, + ClaudeSessionExit, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' + +/** + * Cleanup for an acquisition the host could not commit or prove. A session that + * a first-hand exit already removed is not an absence to report as proven: the + * ladder on its connection still answers, and that answer is classified exactly + * as a start-time failure would be. + */ +export async function releaseClaudeAcquisition(input: { + sessionId: string + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise + persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] + onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent'] +}): Promise { + const exit = input.exits.get(input.sessionId) + if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) { + return closeClaudeSession(input) + } + const firstProof = exit.closePromise ? await exit.closePromise : false + // A failed exit-path proof is retained as evidence, not as a terminal result; + // a release retry must drive a fresh tree verification on the same connection. + const retriedProof = firstProof || (await exit.connection.close()) + if (retriedProof) { + await input.onExitProven?.(input.sessionId, exit) + // Keep the first-hand exit evidence indexed until the tree proof succeeds; + // a failed close must be retryable and cannot look like an absent session. + input.exits.delete(input.sessionId) + return true + } + throw claudeAcquisitionCleanupError(exit.connection, exit.error) +} diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts new file mode 100644 index 00000000000..27444953636 --- /dev/null +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE +} from '../claude-accounts/environment' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' +import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' + +const SESSION_ID = 'orca-session-auth' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +function record(): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [] + } as unknown as AgentSessionRecord +} + +function resolverFor(options: { + stripAuthEnv?: boolean + overlay?: Record +}): ReturnType { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + ...(options.stripAuthEnv === undefined + ? {} + : { resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv === true }) }), + ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) + }) +} + +function withAmbientAuth(value: string, run: () => Promise): Promise { + const restore = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = value + return run().finally(() => { + if (restore === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = restore + } + }) +} + +describe('claude structured auth parity with the terminal preflight', () => { + afterEach(() => { + endClaudeAuthSwitch() + }) + + // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and + // runtime/spawn-preflight.ts:139; the structured path used to let the override win. + it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { + await expect( + resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ + identity: IDENTITY + }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) + + it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { + await expect( + resolverFor({ + stripAuthEnv: true, + overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } + })({ identity: IDENTITY }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) + + it('still admits a non-auth env overlay under a managed account', async () => { + const launch = await resolverFor({ + stripAuthEnv: true, + overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test') + }) + + // Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a + // system-auth user's own shell key is their sign-in and must survive. + it('passes an ambient Anthropic key through when no managed account is active', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + }) + }) + + it('lets an explicit overlay override the ambient key when no managed account is active', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ + stripAuthEnv: false, + overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + }) + }) + + it('still strips the ambient Anthropic key when a managed account is pinned', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + }) + }) + + // Task 3 — the terminal preflight guards this at four sites; the structured path had none. + it('refuses launch resolution while a Claude account switch is in progress', async () => { + beginClaudeAuthSwitch() + + await expect(resolverFor({ stripAuthEnv: true })({ identity: IDENTITY })).rejects.toThrow( + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE + ) + }) + + it('refuses an acquire before it tears the previous session down', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + beginClaudeAuthSwitch() + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + // Nothing was spawned, so the refusal must not have opened a connection. + expect(claude.connections).toHaveLength(0) + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 653c2518407..7d922fe7eb7 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -49,11 +49,16 @@ function makeExecutable(path: string): void { } } -function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record) { +function resolverFor( + value: AgentSessionRecord | null, + resolveEnv?: () => Record, + stripAuthEnv = false +) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv }), ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -200,7 +205,10 @@ describe('claude structured launch resolution', () => { expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') }) - it('strips ambient Anthropic auth from the inherited env but keeps the rest of it', async () => { + // Stripping is the managed-account rule the terminal preflight computes at + // runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers + // the system-auth half, where the user's own key has to survive. + it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => { const restore = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, @@ -212,7 +220,7 @@ describe('claude structured launch resolution', () => { process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK' process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited' try { - const launch = await resolverFor(record())({ identity: IDENTITY }) + const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY }) expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() @@ -231,7 +239,7 @@ describe('claude structured launch resolution', () => { } }) - it('lets an explicit Claude env overlay override the stripped ambient auth', async () => { + it('lets an explicit Claude env overlay override ambient auth under system auth', async () => { const restore = process.env.ANTHROPIC_API_KEY process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' try { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 41db7a403ed..3565e0222f2 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -4,7 +4,13 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + applyClaudeEnvPatch, + hasClaudeAuthEnvConflict +} from '../claude-accounts/environment' +import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { resolveClaudeCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' @@ -110,6 +116,16 @@ export type ClaudeStructuredLaunch = { resumed: boolean } +/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result: + * the one field a launch resolution needs from the managed-account state. */ +export type ClaudeStructuredAuthPolicy = { + stripAuthEnv: boolean +} + +/** No policy resolver wired means no managed-account service to answer for, which + * is what the terminal preflight computes when no account is selected. */ +const SYSTEM_AUTH_POLICY: ClaudeStructuredAuthPolicy = { stripAuthEnv: false } + export type ClaudeStructuredLaunchResolverDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise @@ -118,6 +134,13 @@ export type ClaudeStructuredLaunchResolverDeps = { | Promise | undefined> | Record | undefined + resolveAuthPolicy?: () => Promise | ClaudeStructuredAuthPolicy +} + +export function assertClaudeAuthSwitchIdle(): void { + if (isClaudeAuthSwitchInProgress()) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } } export function claudeSessionIdForOrcaSession(sessionId: string): string { @@ -132,6 +155,7 @@ export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { return async ({ identity }) => { + assertClaudeAuthSwitchIdle() const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -165,11 +189,21 @@ export function createClaudeStructuredLaunchResolver( : claudeSessionIdForOrcaSession(identity.sessionId) const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? []) const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const auth = (await deps.resolveAuthPolicy?.()) ?? SYSTEM_AUTH_POLICY const overlay = await deps.resolveEnv?.() + // A switch can begin while the policy and overlay resolve, exactly as it can + // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. + assertClaudeAuthSwitchIdle() + // Under a managed account the pinned credential is the only auth this launch may + // use, so an explicit override is refused rather than silently beating the pin. + if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + } // Why the overlay merges onto the inherited env rather than replacing it: the child // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath // derives PATH from what it is handed. Ambient Anthropic auth is stripped from the - // inherited half only, so an explicit agentDefaultEnv override still wins. + // inherited half only when a managed account owns the credential; a system-auth + // user's own key is their sign-in and must reach the child. const env = withCliRuntimeOnPath( command, { @@ -177,7 +211,7 @@ export function createClaudeStructuredLaunchResolver( cloneDefinedEnv(process.env), {}, { - stripAuthEnv: true, + stripAuthEnv: auth.stripAuthEnv, platform: process.platform } ), diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 8724f3f61e8..b3a3d46562c 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -2,10 +2,11 @@ import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join, relative } from 'node:path' import { describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { getSpawnArgsForWindows } from '../win32-utils' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' import { @@ -66,6 +67,24 @@ function identity(providerSessionId: string): AgentSessionJournalIdentity { } } +/** The CLI flushes its transcript on its own schedule; poll rather than race it. */ +async function waitForResolvedTranscript( + providerSessionId: string, + claudeProjectsDir: string, + timeoutMs = 15_000 +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const resolved = await resolveSessionFilePath('claude', providerSessionId, { + claudeProjectsDir + }) + if (resolved || Date.now() >= deadline) { + return resolved + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } +} + describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => { it.skipIf(!realClaudeAuthenticated)( 'proves a pre-minted session before the first user message', @@ -100,6 +119,46 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () 10_000 ) + // Mobile native chat never reads the structured journal — it reads the CLI's own + // transcript through native-chat/session-file-resolver.ts, which is also how + // structured-claude-runtime-adapter.ts recovers a leaf. So the SDK-spawned CLI has + // to keep writing that transcript under the pinned account home; if it ever moved, + // mobile chat and TUI resume would both go dark with no wire-level error. + // The turn is what creates the file: an init-only handshake writes nothing. + it.skipIf(!realClaudeAuthenticated)( + 'writes its transcript where the mobile session-file resolver looks for it', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const adapter = realAdapter(providerSessionId, claudeConfigDir) + const projectsDir = join(claudeConfigDir, 'projects') + + let transcriptPath: string | null = null + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-transcript' + }) + await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-transcript-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + transcriptPath = await waitForResolvedTranscript(providerSessionId, projectsDir) + } finally { + await adapter.closeAll() + } + + expect(transcriptPath).not.toBeNull() + expect(basename(transcriptPath ?? '')).toBe(`${providerSessionId}.jsonl`) + // `/projects//.jsonl` + expect(relative(projectsDir, transcriptPath ?? '').split(/[\\/]/)).toHaveLength(2) + }, + 45_000 + ) + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index a3d8a0a38c5..e15a866fa41 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -6,7 +6,8 @@ import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' +import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayWaiter } from './claude-structured-dispatch' @@ -34,32 +35,19 @@ import { cancelClaudeAcquisitionAttempt, mintClaudeAcquisitionGeneration, type ClaudeAcquisitionRegistry, - type ClaudeAcquisitionAttempt, type ClaudeSession, type ClaudeSessionExit, type ClaudeStructuredSessionAdapterDeps, - type ClaudeStructuredSessionEvent + type ClaudeAcquireCallbacks } from './claude-structured-session-state' import { closeClaudePublishedSessionForDeps, - closeClaudeSession, claudeAcquisitionCleanupError } from './claude-structured-session-close' import { readClaudeTranscriptEntryUuid } from './claude-tui-exit' export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 -type AcquireCallbacks = { - deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void - emit: ( - session: ClaudeSession | null, - events: StructuredAgentSessionEventSink | undefined, - event: ClaudeStructuredSessionEvent - ) => void - handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void - settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise -} - export async function acquireClaudeSession({ input, deps, @@ -73,8 +61,13 @@ export async function acquireClaudeSession({ sessions: Map acquisitions: ClaudeAcquisitionRegistry exits: Map - callbacks: AcquireCallbacks + callbacks: ClaudeAcquireCallbacks }): Promise { + // A managed-account switch is mid-swap of the pinned credential home; refuse here, + // before this acquisition cancels the previous attempt and closes the live session. + if (isClaudeAuthSwitchInProgress()) { + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const translator = createClaudeSessionJournalTranslator( @@ -294,36 +287,3 @@ export async function acquireClaudeSession({ attempt.finish() } } - -/** - * Cleanup for an acquisition the host could not commit or prove. A session that - * a first-hand exit already removed is not an absence to report as proven: the - * ladder on its connection still answers, and that answer is classified exactly - * as a start-time failure would be. - */ -export async function releaseClaudeAcquisition(input: { - sessionId: string - sessions: Map - acquisitions: ClaudeAcquisitionRegistry - exits: Map - onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise - persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] - onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent'] -}): Promise { - const exit = input.exits.get(input.sessionId) - if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) { - return closeClaudeSession(input) - } - const firstProof = exit.closePromise ? await exit.closePromise : false - // A failed exit-path proof is retained as evidence, not as a terminal result; - // a release retry must drive a fresh tree verification on the same connection. - const retriedProof = firstProof || (await exit.connection.close()) - if (retriedProof) { - await input.onExitProven?.(input.sessionId, exit) - // Keep the first-hand exit evidence indexed until the tree proof succeeds; - // a failed close must be retryable and cannot look like an absent session. - input.exits.delete(input.sessionId) - return true - } - throw claudeAcquisitionCleanupError(exit.connection, exit.error) -} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 316433d929c..f28b6e37f8f 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -6,10 +6,8 @@ import type { import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { - acquireClaudeSession, - releaseClaudeAcquisition -} from './claude-structured-session-acquisition' +import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' +import { acquireClaudeSession } from './claude-structured-session-acquisition' export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredOption } from './claude-structured-options' diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 0d2d2036dcd..91129e5825c 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -254,3 +254,16 @@ export async function cancelClaudeAcquisitionAttempt( finished: attempt.finished }) } + +/** What an acquisition hands back to the adapter that owns the session map: + * event delivery ordered against publication, and the two exit settlements. */ +export type ClaudeAcquireCallbacks = { + deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void + emit: ( + session: ClaudeSession | null, + events: StructuredAgentSessionEventSink | undefined, + event: ClaudeStructuredSessionEvent + ) => void + handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise +} diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index af5da3858bd..94f1acf363e 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -7,7 +7,11 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' import { isOpaqueRemintedPaneKey } from '../../../../shared/pane-key-alias' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + hasClaudeAuthEnvConflict +} from '../../../claude-accounts/environment' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' @@ -20,12 +24,10 @@ import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index f40b46e5dd5..f885d6e2f6f 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,6 +4,7 @@ import { } from '../../../../shared/local-windows-terminal-runtime' import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -193,7 +194,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index aed89b44df8..43fd2778119 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,7 +23,11 @@ import { import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + hasClaudeAuthEnvConflict +} from '../../../claude-accounts/environment' import { isSafePtySessionId, mintPtySessionId, @@ -65,7 +69,7 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. ctx.terminalRuntimeOptions = @@ -134,12 +138,10 @@ export async function prepareRuntimePtySpawn( ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 62c2d140ff1..a20349f5e08 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -25,6 +25,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' +import type { ClaudeStructuredAuthPolicy } from '../claude/claude-structured-launch-resolution' import { RpcDispatcher } from './rpc/dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' import { @@ -240,6 +241,9 @@ let dispatcher: RpcDispatcher let cleanups: Map void> let tuiOwner: StructuredTuiOwner | null let transcriptPath: string +/** Managed-account state and configured overlay this host installs, per test. */ +let claudeAuthPolicy: ClaudeStructuredAuthPolicy +let claudeLaunchEnv: Record async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] @@ -303,6 +307,11 @@ function textOf(item: AgentJournalRenderItem): string { beforeEach(async () => { operations = 0 + claudeAuthPolicy = { stripAuthEnv: false } + claudeLaunchEnv = { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + } root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`) await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true }) @@ -390,10 +399,8 @@ beforeEach(async () => { resolveCodexCommand: () => '/usr/local/bin/codex', resolveClaudeCommand: () => '/usr/local/bin/claude', readProcessStartTime: async (pid: number) => pid * 10, - resolveClaudeLaunchEnv: () => ({ - ANTHROPIC_AUTH_TOKEN: 'configured-token', - ANTHROPIC_BASE_URL: 'https://gateway.example.test' - }), + resolveClaudeLaunchEnv: () => claudeLaunchEnv, + resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, handoffTransport }).then(() => undefined), @@ -414,6 +421,34 @@ afterEach(async () => { }) describe('a structured Claude session over agentSession.*', () => { + it('strips ambient Anthropic auth from the child once a managed account is pinned', async () => { + claudeAuthPolicy = { stripAuthEnv: true } + claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + vi.stubEnv('ANTHROPIC_AUTH_TOKEN', 'tok-SHELL-LEAK') + + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + + const env = claude.live().launch.env + expect(env).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') + expect(env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: join(root, 'claude-home') + }) + }) + + it('refuses a create whose configured env overrides the pinned managed account auth', async () => { + claudeAuthPolicy = { stripAuthEnv: true } + // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path + // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. + const refused = await call('agentSession.create', createIntentParams()) + + expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + // Refused before spawn: no provider child was ever opened. + expect(claude.connections).toHaveLength(0) + }) + it('durably returns actionable sign-in guidance when initialization has no credentials', async () => { claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) const params = createIntentParams() @@ -503,8 +538,9 @@ describe('a structured Claude session over agentSession.*', () => { CLAUDE_CONFIG_DIR: join(root, 'claude-home'), [CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String) }) - // The child inherits the shell env for PATH, but never the ambient Anthropic auth. - expect(claude.live().launch.env).not.toHaveProperty('ANTHROPIC_API_KEY') + // System auth: the user's own shell key is their sign-in, exactly as on the + // terminal path, and the configured overlay still wins over it. + expect(claude.live().launch.env).toMatchObject({ ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' }) expect(claude.live().launch.env?.PATH ?? claude.live().launch.env?.Path).toBeTruthy() const history = await call('agentSession.history', { sessionId: SESSION, diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 21109f0b05b..4359e46a976 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -25,6 +25,8 @@ import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-ag import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { hostname } from 'node:os' +import { shouldStripClaudeAuthEnvForAccount } from '../claude-accounts/environment' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' @@ -156,6 +158,17 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), + // Structured Claude always spawns a native local-host child (the launch resolver + // refuses any WSL or remote record), so the host selection owns its auth. + resolveClaudeAuthPolicy: () => { + const settings = this.requireStore().getSettings() + return { + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + ) + } + }, handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b0cd548371e..b27c1fd8584 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,6 +34,7 @@ import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-re import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import type { ClaudeStructuredAuthPolicy } from '../claude/claude-structured-launch-resolution' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' /** Sibling of the journal tree rather than inside it: one file adjudicates every @@ -68,6 +69,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record resolveClaudeLaunchEnv?: () => Promise> | Record + resolveClaudeAuthPolicy?: () => Promise | ClaudeStructuredAuthPolicy resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void @@ -185,6 +187,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { recoveryChain = recoveryChain.then(async () => { try { diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 38994be84d9..ebff975d940 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,7 +1,10 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { join } from 'node:path' import { resolveClaudeCommand } from '../codex-cli/command' -import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { + createClaudeStructuredLaunchResolver, + type ClaudeStructuredAuthPolicy +} from '../claude/claude-structured-launch-resolution' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionAdapterDeps @@ -20,6 +23,8 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveClaudeCommand?: () => string resolveClaudeLaunchEnv?: () => Promise> | Record + /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. */ + resolveClaudeAuthPolicy?: () => Promise | ClaudeStructuredAuthPolicy openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void @@ -34,7 +39,8 @@ export function createStructuredClaudeRuntimeAdapter( store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, - ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), + ...(deps.resolveClaudeAuthPolicy ? { resolveAuthPolicy: deps.resolveClaudeAuthPolicy } : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence