From 5d7b0f1c6dc3f289a781c3c064d43513ee74958d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:35:36 -0400 Subject: [PATCH 01/69] Start AI commit messages from the same shell environment chats use (#26808) * Start AI commit messages from the login-shell environment chats use A Dock-launched Orca only takes PATH from the user's shell, so variables exported in the shell rc (proxy base URL, auth token) never reached the commit-message agent even though chats and terminals saw them. Local host generation now starts from the same cached login-shell base env as chats, with account overrides applied on top; WSL and SSH paths are unchanged. * Prefer the login shell's config directory over Orca's inherited one for commit messages (cherry picked from commit 65b2fcf89a61b6148b46347d6bff213550aecb1b) --- ...lesystem-commit-message-generation.test.ts | 51 ++++++- .../structured-agent-launch-support.ts | 17 +-- .../structured-agent-shell-environment.ts | 14 ++ .../startup/main-process-runtime-service.ts | 4 +- src/main/startup/main-window-core-services.ts | 4 +- .../commit-message-agent-environment.test.ts | 125 +++++++++++++++++- .../commit-message-agent-environment.ts | 60 ++++++--- 7 files changed, 241 insertions(+), 34 deletions(-) diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 0a7547619b38..d8b836c91a15 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -439,6 +439,52 @@ describe('registerFilesystemHandlers', () => { } }) + it('starts local generation from the login-shell environment', async () => { + const previousBaseUrl = process.env.ANTHROPIC_BASE_URL + delete process.env.ANTHROPIC_BASE_URL + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'claude', model: 'haiku' } + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Update README' + }) + + try { + registerFilesystemHandlers(store as never, { + resolveBaseEnvironment: async () => ({ ANTHROPIC_BASE_URL: 'https://proxy.example' }), + prepareForClaudeLaunch: async () => ({ + configDir: '/home/me/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + }) + }) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'local', + env: { ANTHROPIC_BASE_URL: 'https://proxy.example' } + }) + ) + } finally { + if (previousBaseUrl !== undefined) { + process.env.ANTHROPIC_BASE_URL = previousBaseUrl + } + } + }) + it('generates an SSH commit message using remote staged context and relay execution', async () => { const context = { branch: 'main', @@ -459,9 +505,11 @@ describe('registerFilesystemHandlers', () => { message: 'Add remote file' }) + const resolveBaseEnvironment = vi.fn(async () => ({})) registerFilesystemHandlers(store as never, { prepareForCodexLaunch, - prepareForClaudeLaunch + prepareForClaudeLaunch, + resolveBaseEnvironment }) await expect( @@ -495,6 +543,7 @@ describe('registerFilesystemHandlers', () => { ) expect(prepareForCodexLaunch).not.toHaveBeenCalled() expect(prepareForClaudeLaunch).not.toHaveBeenCalled() + expect(resolveBaseEnvironment).not.toHaveBeenCalled() }) it('routes SSH generation cancellations to separate provider operations', async () => { diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 14a60f024d13..d6d1feddb58a 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -1,7 +1,6 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { GlobalSettings } from '../../shared/global-settings-types' -import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -10,9 +9,8 @@ import { warnStructuredAgentSessionCreateUnsupported, type StructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' -import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' -import { structuredAgentBaseEnvironment } from './structured-agent-shell-environment' +import { resolveHostAgentBaseEnvironment } from './structured-agent-shell-environment' type LaunchEnvironmentSettings = Pick< GlobalSettings, @@ -20,17 +18,6 @@ type LaunchEnvironmentSettings = Pick< > & Partial> -/** The environment every agent's launch on this host starts from, for a check made before the - * session host is built. */ -async function resolveHostStructuredAgentBaseEnvironment( - settings: LaunchEnvironmentSettings -): Promise> { - return structuredAgentBaseEnvironment({ - shellEnv: await resolveLoginShellEnvironment(), - policy: nativeChatShellEnvironmentPolicy(settings) - }) -} - /** What the check reads from the runtime asking it. */ type LaunchSupportRuntime = { requireStore(): { getSettings(): LaunchEnvironmentSettings } @@ -51,7 +38,7 @@ export async function structuredAgentSupportsLaunch( } const settings = runtime.requireStore().getSettings() const env = { - ...(await resolveHostStructuredAgentBaseEnvironment(settings)), + ...(await resolveHostAgentBaseEnvironment(settings)), ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) } const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index ed0d628d3d11..12021f3615a0 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,4 +1,5 @@ import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy, type NativeChatShellEnvironmentPolicy @@ -73,6 +74,19 @@ export function structuredAgentBaseEnvironment(input: { ) } +/** The base env every local agent launch on this host starts from, read from current settings. */ +export async function resolveHostAgentBaseEnvironment( + settings: Pick< + GlobalSettings, + 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' + > +): Promise> { + return structuredAgentBaseEnvironment({ + shellEnv: await resolveLoginShellEnvironment(), + policy: nativeChatShellEnvironmentPolicy(settings) + }) +} + export type StructuredAgentEnvironmentSources = { resolveEnvironment?: () => Promise resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index ffaf17c45a68..61e36137688f 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -29,6 +29,7 @@ import { codexStructuredLaunchHomeResolvers, prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { resolveHostAgentBaseEnvironment } from '../runtime/structured-agent-shell-environment' import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' import { SkillCloudService } from '../skills/skill-cloud-service' @@ -205,6 +206,7 @@ export function configureRuntimeServices(runtime: OrcaRuntimeService): void { runtime.setCommitMessageAgentEnvironmentResolvers({ // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target) + prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + resolveBaseEnvironment: () => resolveHostAgentBaseEnvironment(store.getSettings()) }) } diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f1cac48de6e..186d73d3a617 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -13,6 +13,7 @@ import { handlePtyExit } from './main-process-pty-startup' import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { resolveHostAgentBaseEnvironment } from '../runtime/structured-agent-shell-environment' import { prepareCodexPinnedLaunchHome, prepareCodexSessionResumeForLaunch @@ -74,7 +75,8 @@ export function attachMainWindowCoreServices( automations, { prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target) + prepareForClaudeLaunch: (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + resolveBaseEnvironment: () => resolveHostAgentBaseEnvironment(store.getSettings()) }, state.agentAwakeService ?? undefined, state.crashReports ?? undefined, diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index 33b66e1a4fe1..3ebb66c36327 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' @@ -214,4 +214,127 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true }) }) + + describe('login-shell base environment', () => { + const envOf = ( + result: Awaited> + ): NodeJS.ProcessEnv | undefined => (result.ok ? result.env : undefined) + const shellBase = { + PATH: '/shell/bin', + ANTHROPIC_BASE_URL: 'https://proxy.example', + ANTHROPIC_AUTH_TOKEN: 'shell-token' + } + + it('gives Claude the shell-exported variables Orca was launched without', async () => { + delete process.env.ANTHROPIC_BASE_URL + delete process.env.ANTHROPIC_AUTH_TOKEN + + const result = await prepareLocalCommitMessageAgentEnv('claude', { + resolveBaseEnvironment: async () => shellBase, + prepareForClaudeLaunch: async () => ({ + configDir: '/home/me/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + }) + }) + + expect(result).toEqual({ ok: true, env: shellBase }) + }) + + it('lets the selected Claude account override the shell environment', async () => { + const result = await prepareLocalCommitMessageAgentEnv('claude', { + resolveBaseEnvironment: async () => shellBase, + prepareForClaudeLaunch: async () => ({ + configDir: '/managed/claude', + envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' }, + stripAuthEnv: true, + provenance: 'managed:account-1' + }) + }) + + const env = envOf(result) + expect(env?.CLAUDE_CONFIG_DIR).toBe('/managed/claude') + expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env?.PATH).toBe('/shell/bin') + }) + + it('lets the managed Codex home override a shell CODEX_HOME', async () => { + const result = await prepareLocalCommitMessageAgentEnv('codex', { + resolveBaseEnvironment: async () => ({ ...shellBase, CODEX_HOME: '/shell/codex' }), + prepareForCodexLaunch: () => '/managed/codex-home' + }) + + expect(result).toEqual({ + ok: true, + env: { ...shellBase, CODEX_HOME: '/managed/codex-home' } + }) + }) + + it('gives agents without an account resolver the shell environment', async () => { + makeHome() + delete process.env.OPENCODE_CONFIG_DIR + + await expect( + prepareLocalCommitMessageAgentEnv('cursor', { + resolveBaseEnvironment: async () => shellBase + }) + ).resolves.toEqual({ ok: true, env: shellBase }) + await expect( + prepareLocalCommitMessageAgentEnv('opencode', { + resolveBaseEnvironment: async () => shellBase + }) + ).resolves.toEqual({ ok: true, env: shellBase }) + }) + + it("prefers the login shell's config dir over Orca's inherited one", async () => { + makeHome() + process.env.OPENCODE_CONFIG_DIR = '/old/opencode' + delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + + const result = await prepareLocalCommitMessageAgentEnv('opencode', { + resolveBaseEnvironment: async () => ({ ...shellBase, OPENCODE_CONFIG_DIR: '/new/opencode' }) + }) + + expect(envOf(result)?.OPENCODE_CONFIG_DIR).toBe('/new/opencode') + }) + + it('keeps Orca env for WSL-local generation instead of the host shell', async () => { + delete process.env.ANTHROPIC_BASE_URL + const resolveBaseEnvironment = vi.fn(async () => shellBase) + + const codex = await prepareLocalCommitMessageAgentEnv( + 'codex', + { resolveBaseEnvironment, prepareForCodexLaunch: () => null }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + const cursor = await prepareLocalCommitMessageAgentEnv( + 'cursor', + { resolveBaseEnvironment }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + + expect(resolveBaseEnvironment).not.toHaveBeenCalled() + expect(envOf(codex)?.ANTHROPIC_BASE_URL).toBeUndefined() + expect(cursor).toEqual({ ok: true }) + }) + + it('falls back to Orca env when the shell environment cannot be read', async () => { + process.env.ORCA_COMMIT_ENV_MARKER = 'process' + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + resolveBaseEnvironment: async () => { + throw new Error('settings unavailable') + }, + prepareForCodexLaunch: () => '/managed/codex-home' + }) + + expect(envOf(result)).toEqual( + expect.objectContaining({ + ORCA_COMMIT_ENV_MARKER: 'process', + CODEX_HOME: '/managed/codex-home' + }) + ) + }) + }) }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 588a56058e13..bafd590e8fba 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -10,6 +10,8 @@ export type CommitMessageAgentEnvironmentResolvers = { prepareForClaudeLaunch?: ( target?: CommitMessageAgentRuntimeTarget ) => Promise + /** The login-shell base env chats start from; a host-only source, never used for WSL. */ + resolveBaseEnvironment?: () => Promise> } export type CommitMessageAgentRuntimeTarget = { @@ -32,8 +34,8 @@ function cloneProcessEnv(): Record { // Orca terminal it can inherit an Orca-owned CODEX_HOME override; strip only // that (CODEX_HOME matching the private ORCA_CODEX_HOME marker), preserving a // user-set CODEX_HOME. -function cloneProcessEnvWithoutOrcaCodexHomeOverride(): Record { - const env = cloneProcessEnv() +function withoutOrcaCodexHomeOverride(base: Record): Record { + const env = { ...base } if (env.ORCA_CODEX_HOME && env.CODEX_HOME === env.ORCA_CODEX_HOME) { delete env.CODEX_HOME } @@ -41,15 +43,24 @@ function cloneProcessEnvWithoutOrcaCodexHomeOverride(): Record { return env } -function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | undefined { +function readInheritedOrShellEnvVar( + name: string, + sourceName: string | undefined, + base: Record | undefined +): string | undefined { + // Why: the login-shell base is what chats use, so it beats Orca's own inherited value. return ( (sourceName ? process.env[sourceName] : undefined) ?? + base?.[name] ?? process.env[name] ?? readShellStartupEnvVar(name, process.env.HOME, process.env.SHELL) ) } -function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null { +function prepareShellConfigDirEnv( + agentId: string, + base: Record | undefined +): { ok: true; env?: NodeJS.ProcessEnv } | null { const configVar = agentId === 'opencode' || agentId === 'opencode2' ? 'OPENCODE_CONFIG_DIR' @@ -74,15 +85,29 @@ function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.Pro ? 'ORCA_OMP_SOURCE_AGENT_DIR' : undefined - const value = readInheritedOrShellEnvVar(configVar, sourceVar) + const value = readInheritedOrShellEnvVar(configVar, sourceVar, base) if (!value) { - return { ok: true } + return base ? { ok: true, env: base } : { ok: true } } // Why: GUI-launched Orca may not inherit shell startup exports, but these // vars point the headless CLI at the user's auth/config root. Nested Orca // launches inherit PTY overlays, so prefer ORCA_*_SOURCE_* when present. - return { ok: true, env: { ...cloneProcessEnv(), [configVar]: value } } + return { ok: true, env: { ...(base ?? cloneProcessEnv()), [configVar]: value } } +} + +async function resolveHostBaseEnv( + resolvers: CommitMessageAgentEnvironmentResolvers | undefined +): Promise | undefined> { + if (!resolvers?.resolveBaseEnvironment) { + return undefined + } + try { + return { ...(await resolvers.resolveBaseEnvironment()) } + } catch (error) { + console.error('[commit-message] Failed to resolve the login-shell environment:', error) + return undefined + } } export async function prepareLocalCommitMessageAgentEnv( @@ -90,15 +115,20 @@ export async function prepareLocalCommitMessageAgentEnv( resolvers: CommitMessageAgentEnvironmentResolvers | undefined, target?: CommitMessageAgentRuntimeTarget ): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> { + // Why: a Dock-launched Orca lacks the user's shell exports (proxy URLs, tokens); WSL + // resolves env in the guest, so it keeps Orca's own env. + const hostBase = target?.runtime === 'wsl' ? undefined : await resolveHostBaseEnv(resolvers) // Why: a non-null result short-circuits the resolvers below, so any agent added // to prepareShellConfigDirEnv must not also need a Codex/Claude-style resolver. - const shellConfigEnv = target?.runtime === 'wsl' ? null : prepareShellConfigDirEnv(agentId) + const shellConfigEnv = + target?.runtime === 'wsl' ? null : prepareShellConfigDirEnv(agentId, hostBase) if (shellConfigEnv) { return shellConfigEnv } if (!resolvers) { return { ok: true } } + const base = hostBase ?? cloneProcessEnv() try { if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { @@ -111,26 +141,26 @@ export async function prepareLocalCommitMessageAgentEnv( return { ok: true, env: codexHomeForTarget - ? { ...cloneProcessEnvWithoutOrcaCodexHomeOverride(), CODEX_HOME: codexHomeForTarget } - : cloneProcessEnvWithoutOrcaCodexHomeOverride() + ? { ...withoutOrcaCodexHomeOverride(base), CODEX_HOME: codexHomeForTarget } + : withoutOrcaCodexHomeOverride(base) } } if (codexHomePath && wslCodexHome) { // Why: this local generation path spawns the host Codex binary. A WSL // managed home is only valid when the process is routed through wsl.exe. - return { ok: true } + return hostBase ? { ok: true, env: hostBase } : { ok: true } } return { ok: true, env: codexHomePath - ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } - : cloneProcessEnvWithoutOrcaCodexHomeOverride() + ? { ...base, CODEX_HOME: codexHomePath } + : withoutOrcaCodexHomeOverride(base) } } if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { const preparation = await resolvers.prepareForClaudeLaunch(target) - const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, { + const env = applyClaudeEnvPatch({ ...base }, preparation.envPatch, { stripAuthEnv: preparation.stripAuthEnv }) return { ok: true, env } @@ -143,5 +173,5 @@ export async function prepareLocalCommitMessageAgentEnv( } } - return { ok: true } + return hostBase ? { ok: true, env: hostBase } : { ok: true } } From 98996ff46a445d748ddd91592bb955ae13b943a7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:57:47 -0400 Subject: [PATCH 02/69] Re-land Claude per-account folders (#24434, #26405) with the upgrade and proxy fixes (#26801) * Revert "Revert Claude per-account folders (#24434, #26405) before release (#26638)" This reverts commit 9ca9b4e36b9f61012d490a0642c52b980f3634f2. * Move the terminal daemon protocol to 44 for the re-landed claude function v43 daemons (the revert) lack the claude account function, so new terminals must not reuse them. v42 and v43 stay attachable. * Move the local build compatibility contract to daemon protocol 44 * Point the old-terminal test at daemon protocol 44 * Claude accounts re-land: never sign anyone out, keep proxies working, accounts mirror ~/.claude (#26769) * Run a Claude account on System default until it has its own login An account saved before per-account folders has no login in its folder. While System default (~/.claude, or the user's own CLAUDE_CONFIG_DIR) is signed in to the same email, the router sends every launch there and writes that choice into the pointer file, so nobody is logged out by the update. Once the account signs in to its own folder, the next launch uses it. The WSL router follows the same rule with the guest's ~/.claude. * Refresh each Claude account folder from ~/.claude before every launch ~/.claude (or the user's own CLAUDE_CONFIG_DIR) is now the master copy. Each account folder gets its whole settings.json, including the proxy address and its key, and every .claude.json key except the login, Claude's account caches and install ids, so first-run questions are not asked again. Every other top-level entry is linked (folders) or copied (files), except Claude's per-folder daemon, jobs, live sessions, login files and throwaways. sessions is no longer shared, and an earlier link is undone. The refresh runs before every Claude Orca starts, for each new terminal and on account switch. Orca never writes back to ~/.claude. The ledger that tracked selective sharing is gone. * Route chat auth and unselected-account usage through the Claude router Chats decided whether to drop inherited Anthropic auth from the selection alone, so an account running on System default lost the user's own key. They now ask the router, as terminals, AI commit messages, automations and usage already do. Usage for an unselected account is read where its launches would run. A test reads every entry point through the fallback and fails if a launch path outside the router reads the Claude selection or builds an account folder. The router's two launch errors move to their own file to keep it under the line limit. * Show Claude account sign-in prompts only when an account cannot run With the fallback, an account System default is signed in to already works, so: - The one-time "Finish setting up your Claude accounts" toast is gone. - Accounts System default covers no longer need a sign-in: no Sign in in the status bar menu or Settings, and they can be selected. - The old-terminal banner shows only when claude in that terminal would run a different account than the selected one, and asks again after a switch. Its wording is unchanged. The retired toast's saved flag stays in the paired-client schema so an older client's write is still accepted. The email comparison moves beside the login reader to keep the router under the line limit. * Keep the user's shell Anthropic auth on Claude account launches A signed-in account's launches used to drop ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN from the inherited environment while ANTHROPIC_BASE_URL passed through, so a proxy set in the shell got the subscription login and answered 401. Host launches on an account now keep the shell's auth with its address, as System default does. Chats still name the account kind in a failed sign-in, now from the router rather than from whether auth was dropped. * Share from ~/.claude only what Claude itself would seed into a new folder The deny list missed much of Claude's runtime state, so tokens, locks and live files such as .session_ingress_token, server-sessions.json and remote-control were copied or linked over each account's own every launch. The list now mirrors the one Claude uses when it seeds a fresh config folder from ~/.claude: its runtime entries, every hidden entry, daemon files and agent memory. teams, ide, downloads and scratch are on it, so they stay per folder. * Merge trusted folders and MCP servers into an account instead of replacing them Each refresh replaced the account's projects and mcpServers with ~/.claude's, so folder trust and allowed tools recorded only in the account were lost, and a refresh landing between Orca's folder-trust write and Claude's start brought the trust dialog back. Both are now merged per folder path or server name: ~/.claude wins for entries it has, the account keeps the rest, and a folder trusted in the account stays trusted. Both writers take Claude's lock on the file, so the order no longer matters. * Require the same organization before System default stands in for an account The fallback compared emails only, so one email saved for two organizations made both accounts run on whichever organization System default was signed in to. When the saved account and System default's login both name an organization, it must now match too, on the host and in WSL, and the old-terminal banner uses the same comparison. The saved-account lookup the three checks repeated is now one helper. * Treat v42 terminals as having the claude account function v42 daemons shipped the same claude function and pointer path as v44, so claude in those terminals already follows the selected account. The old-terminal banner now shows only for daemons before v42 and for v43, the revert, which dropped the function. * Fail the routing test when a new module resolves the claude binary The census only caught code that read the Claude selection, not a new launcher that runs claude without asking the router. A second check now lists every module that resolves the claude binary or loads the Agent SDK, each with why it is routed, and fails on any new one, or on a listed one that no longer matches. * Skip the account refresh on launches that run on System default A launch that falls back to System default never uses the account folder, so starting a setup for it on every launch was wasted work. Account switches still set the folder up for a later sign-in. * Give a terminal opened before the login shell's env only the account pointer At startup, a pane could be routed before the login shell reported the user's own CLAUDE_CONFIG_DIR, so the fallback compared against ~/.claude.json instead. Until that env arrives a pane gets only the pointer, and the pointer is rewritten once it does, so claude typed there reads the right folder. The recent claude --version answer moves beside the version probe to keep the router under the line limit. * Drop the chat-only Anthropic auth stripping and override refusal Chats no longer drop the shell's Anthropic auth on any account, so the chat launch's override refusal and the policy's stripAuthEnv could only be reached from test fixtures. The structured auth policy is now just which login a failed sign-in names. Terminals keep their refusal, which still guards WSL launches; the failure reason stays in the shared vocabulary for older peers. * Publish a new account state file whole The first .claude.json write went straight to the target, so a crash mid-write could leave Claude a truncated state file. It is now written to a staged file and linked into place without replacing one Claude created meanwhile. * Read an unselected WSL account's usage where the WSL router would run it Usage for an unselected WSL account read its saved folder directly, so an account the guest's ~/.claude covers showed no usage, and the read bypassed the router. It now asks the WSL router, after the same check that the distro is already running. * Keep the shell's Anthropic auth when the claude function runs an account The claude shell function (POSIX, fish and PowerShell) unset ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK and auth-like ANTHROPIC_CUSTOM_HEADERS before running Claude in an account folder, so a proxy set in the shell lost its key while keeping its address. It now only sets CLAUDE_CONFIG_DIR and Orca's marker, as on System default. Protocol 44 is unreleased, so no new bump; the shell-wrapper snapshots are regenerated. * Never drop the user's Anthropic auth from a Claude launch Orca's launches stripped ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK and auth-like ANTHROPIC_CUSTOM_HEADERS for WSL terminals, terminal splits and through the preparation's stripAuthEnv flag, and refused a launch whose agent env set them. If the user's shell overrides the login, that is their setup: every launch now keeps it, on accounts, System default and WSL alike. The flag, the strip paths and the override refusal are gone; the refusal's reason stays in the shared vocabulary so older peers still decode it. Orca's own process env and the claude --version probe still drop these variables: they are Orca's, not the user's launch. * Leave out of an account's state everything Claude resets when it signs out The .claude.json copy left out the login and keys matching cache patterns, but still carried account state such as additionalModelOptionsAnsweredAt, artifactRosterDenied, lastSeenOrgDefaultUpdatedAt and the subscription notices. The copy now leaves out the exact keys Claude resets on logout, beside the install ids, Console API key and first-token date; the cache patterns stay for account caches logout does not reset. Onboarding is still copied. * Judge an old terminal's Claude banner by the pane's own runtime The banner compared every old pane with the host selection and showed whenever only a WSL account was selected, so old host panes showed it with nothing to say and old WSL panes were judged by the host's account. The pane now names its runtime: a host pane is compared with the host selection, a WSL pane with its distro's selection through the WSL router, and a pane whose runtime is unknown, or with no account selected for it, stays hidden. * Leave an account's settings file alone when only Orca's hooks differ from the default home's * Reread a WSL account's Claude state only when the file changes * Move tests merged from main onto the account-only Claude auth policy (cherry picked from commit 8b2a5e45f110eb6103884279ea6dca8f14e1985a) --- .../claude-account-windows-spawn-repro.mjs | 342 --------- ...de-pty-windows-pty-teardown-patch.test.mjs | 4 +- src/cli/handlers/account-list-format.ts | 7 +- src/cli/handlers/account.test.ts | 238 +++--- src/cli/handlers/account.ts | 120 ++- .../daemon-bash-rcfile.txt | 24 + .../daemon-fish-init.txt | 28 +- .../daemon-zsh-zshenv.txt | 24 + .../local-bash-rcfile.txt | 24 + .../local-fish-init.txt | 28 +- .../local-zsh-zshenv.txt | 24 + .../relay-zsh-zshenv.txt | 24 + .../agent-auth-restart-preservation.test.ts | 100 +-- src/main/agent-auth-restart-preservation.ts | 20 +- src/main/agent-workspace-trust.test.ts | 1 - .../claude-account-folder.test.ts | 137 ++++ .../claude-accounts/claude-account-folder.ts | 199 +++++ .../claude-account-registration.ts | 330 +++----- .../claude-account-selection.ts | 276 ++++--- ...-account-service-account-selection.test.ts | 488 ------------ ...claude-account-service-add-account.test.ts | 373 --------- .../claude-account-service-api-parity.test.ts | 90 --- ...account-service-config-dir-capture.test.ts | 250 ------ ...account-service-credential-capture.test.ts | 150 ---- ...aude-account-service-login-process.test.ts | 684 ----------------- ...de-account-service-reauth-rollback.test.ts | 420 ----------- .../claude-account-service-test-harness.ts | 41 - .../claude-account-service.test.ts | 360 +++++++++ .../claude-accounts/claude-auth-capture.ts | 176 ----- .../claude-command-process.test.ts | 75 ++ .../claude-accounts/claude-command-process.ts | 38 +- ...ude-credential-persistence-ratchet.test.ts | 286 +++++++ .../claude-launch-routing.test.ts | 235 ++++++ .../claude-login-completion.oracle.test.ts | 192 ----- .../claude-accounts/claude-login-session.ts | 121 +-- .../claude-managed-auth-storage.ts | 285 ------- .../claude-profile-history.test.ts | 17 + .../claude-accounts/claude-profile-history.ts | 33 +- .../claude-profile-installed-router.ts | 18 +- .../claude-profile-launch-errors.ts | 18 + .../claude-profile-paths.test.ts | 21 +- .../claude-accounts/claude-profile-paths.ts | 40 +- .../claude-profile-provisioning.test.ts | 355 +++++---- .../claude-profile-provisioning.ts | 405 ++++++---- .../claude-accounts/claude-profile-report.ts | 18 +- .../claude-profile-router.test.ts | 369 ++++++++- .../claude-accounts/claude-profile-router.ts | 267 ++++++- .../claude-profile-setup.test.ts | 25 +- .../claude-accounts/claude-profile-setup.ts | 44 +- .../claude-accounts/claude-profile-sharing.ts | 123 +-- .../claude-profile-shell-composition.test.ts | 42 +- .../claude-profile-wsl-entry.ts | 2 +- .../claude-profile-wsl-paths.ts | 9 + .../claude-profile-wsl-router.test.ts | 180 ++++- .../claude-profile-wsl-router.ts | 135 +++- .../claude-structured-auth-policy.test.ts | 119 +-- .../claude-structured-auth-policy.ts | 25 +- .../claude-windows-interactive-login.test.ts | 148 ---- src/main/claude-accounts/environment.ts | 75 +- src/main/claude-accounts/keychain.test.ts | 288 +------ src/main/claude-accounts/keychain.ts | 107 +-- .../claude-accounts/live-pty-gate.test.ts | 142 ---- src/main/claude-accounts/live-pty-gate.ts | 174 ----- src/main/claude-accounts/managed-auth-path.ts | 112 --- .../claude-accounts/oauth-refresh.test.ts | 186 ----- src/main/claude-accounts/oauth-refresh.ts | 170 ----- .../runtime-auth-path-materialization.test.ts | 58 -- ...ime-auth-service-account-switching.test.ts | 509 ------------- ...time-auth-service-deselect-restore.test.ts | 611 --------------- ...me-auth-service-keychain-snapshots.test.ts | 712 ------------------ ...untime-auth-service-launch-refresh.test.ts | 248 ------ ...ntime-auth-service-materialization.test.ts | 490 ------------ ...ime-auth-service-readback-identity.test.ts | 607 --------------- ...service-shared-credential-failures.test.ts | 85 --- ...me-auth-service-shared-credentials.test.ts | 292 ------- ...e-auth-service-snapshot-validation.test.ts | 550 -------------- .../runtime-auth-service-test-harness.ts | 277 ------- .../runtime-auth-service-wsl-runtime.test.ts | 312 -------- .../runtime-auth-service.test.ts | 99 +++ .../claude-accounts/runtime-auth-service.ts | 233 +++--- ...runtime-auth-shared-credentials-fixture.ts | 51 -- .../runtime-auth-credential-identity.ts | 209 ----- .../runtime-auth-credential-matching.ts | 152 ---- .../runtime-auth/runtime-auth-file-storage.ts | 82 -- .../runtime-auth-keychain-snapshots.ts | 135 ---- .../runtime-auth-managed-credentials.ts | 152 ---- .../runtime-auth/runtime-auth-preparation.ts | 119 --- .../runtime-auth/runtime-auth-readback.ts | 163 ---- .../runtime-auth-runtime-state.ts | 238 ------ .../runtime-auth-snapshot-capture.ts | 119 --- .../runtime-auth-snapshot-restore.ts | 239 ------ .../runtime-auth/runtime-auth-state.ts | 19 - .../runtime-auth/runtime-auth-sync.ts | 289 ------- .../runtime-auth/runtime-auth-types.ts | 47 +- .../claude-accounts/runtime-paths.test.ts | 6 - src/main/claude-accounts/runtime-paths.ts | 2 - src/main/claude-accounts/runtime-selection.ts | 7 + src/main/claude-accounts/service.ts | 212 ++---- .../shared-credential-fields.test.ts | 137 ---- .../shared-credential-fields.ts | 166 ---- .../shared-credential-reconciliation.test.ts | 129 ---- .../claude-agent-sdk-contract-pins.test.ts | 2 +- .../claude/claude-folder-trust-file.test.ts | 1 - src/main/claude/claude-hook-event-versions.ts | 14 + .../claude/claude-model-catalog-probe.test.ts | 4 +- src/main/claude/claude-model-catalog-probe.ts | 1 - src/main/claude/claude-profile-hooks.test.ts | 22 +- .../claude-stream-json-connection.test.ts | 83 -- .../claude/claude-stream-json-connection.ts | 20 - .../claude-structured-auth-parity.test.ts | 223 +----- .../claude/claude-structured-child-env.ts | 14 +- ...-structured-effort-default-at-rest.test.ts | 2 + ...-structured-launch-account-routing.test.ts | 18 +- .../claude/claude-structured-launch-home.ts | 2 +- ...laude-structured-launch-resolution.test.ts | 194 +---- .../claude-structured-launch-resolution.ts | 77 +- .../claude-structured-launch-visuals.test.ts | 2 +- ...claude-structured-real-cli-visuals.test.ts | 2 +- .../claude-structured-session-acquisition.ts | 16 +- ...laude-structured-turn-resume-point.test.ts | 2 +- src/main/claude/hook-service.ts | 2 +- src/main/codex-accounts/fs-utils.ts | 2 +- ...n-foreground-confirmation-protocol.test.ts | 2 +- .../daemon-init-provider-installation.test.ts | 20 - src/main/daemon/daemon-protocol-version.ts | 13 +- src/main/daemon/daemon-provider-init.ts | 27 +- src/main/daemon/daemon-provider-state.ts | 18 +- .../pty-subprocess/shell-launch-plan.ts | 8 +- src/main/ipc/claude-accounts.ts | 14 +- ...lesystem-commit-message-generation.test.ts | 7 +- ...tem-commit-message-model-discovery.test.ts | 1 - .../pty-listener-teardown-and-orphans.test.ts | 5 - .../ipc/pty-pane-materialization-race.test.ts | 3 - .../ipc/pty-spawn-env-terminal-basics.test.ts | 48 +- .../ipc/pty/ipc/claude-old-terminal.test.ts | 92 +++ src/main/ipc/pty/ipc/claude-old-terminal.ts | 60 ++ src/main/ipc/pty/ipc/spawn-commit.ts | 4 - src/main/ipc/pty/ipc/spawn-env.ts | 12 - src/main/ipc/pty/ipc/spawn-options.ts | 5 - src/main/ipc/pty/ipc/spawn-preflight.ts | 5 - src/main/ipc/pty/provider/bind-listeners.ts | 2 - src/main/ipc/pty/provider/liveness.ts | 2 - src/main/ipc/pty/provider/local-configure.ts | 2 - src/main/ipc/pty/provider/state-cleanup.ts | 3 - src/main/ipc/pty/register-handlers.ts | 3 + src/main/ipc/pty/runtime/spawn-commit.ts | 4 - src/main/ipc/pty/runtime/spawn-options.ts | 5 - .../spawn-preflight-requested-shell.test.ts | 1 + src/main/ipc/pty/runtime/spawn-preflight.ts | 15 - .../macos-keychain/generic-password.test.ts | 24 +- src/main/macos-keychain/generic-password.ts | 39 - .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-failure-text.ts | 2 + ...ent-session-pre-spawn-first-answer.test.ts | 16 + ...structured-managed-account-support.test.ts | 136 ---- ...aude-structured-managed-account-support.ts | 68 -- ...native-chat-inline-visuals-setting.test.ts | 2 +- ...tured-agent-session-create-support.test.ts | 80 +- ...structured-agent-session-create-support.ts | 33 +- ...tence-worktree-lineage-and-backups.test.ts | 61 -- .../leasing-ssh-ptys/ssh-target-state.ts | 37 - .../normalize-loaded-profile-state.ts | 2 - .../profile-state-direct-flush.test.ts | 10 - .../loading-store/ssh-profile-operations.ts | 33 +- .../loading-store/store-domain-composition.ts | 2 +- .../pane-alias-normalization.ts | 24 - .../local-pty-windows-spawn-environment.ts | 8 +- src/main/providers/windows-shell-args.test.ts | 16 +- src/main/providers/windows-shell-args.ts | 14 +- .../rate-limits/claude-active-usage-fetch.ts | 286 ++----- .../rate-limits/claude-cli-usage-fetch.ts | 117 --- .../claude-fetcher-cli-fallback.test.ts | 490 ------------ .../claude-fetcher-fable-usage.test.ts | 362 --------- ...laude-fetcher-keychain-credentials.test.ts | 491 ------------ ...aude-fetcher-managed-account-usage.test.ts | 425 ----------- .../claude-fetcher-test-harness.ts | 67 -- src/main/rate-limits/claude-fetcher.ts | 6 +- .../claude-managed-account-credentials.ts | 127 ---- .../claude-managed-account-usage.ts | 134 ++-- .../rate-limits/claude-managed-usage-panel.ts | 113 --- .../rate-limits/claude-oauth-credentials.ts | 175 ++--- src/main/rate-limits/claude-oauth-recovery.ts | 172 ----- ...=> claude-oauth-usage-extra-usage.test.ts} | 108 +-- .../rate-limits/claude-profile-usage.test.ts | 278 +++++++ .../rate-limits/claude-pty-reset-parser.ts | 307 -------- .../rate-limits/claude-pty-stop-markers.ts | 14 - .../rate-limits/claude-pty-usage-parser.ts | 166 ---- src/main/rate-limits/claude-pty.test.ts | 554 -------------- src/main/rate-limits/claude-pty.ts | 354 --------- .../claude-usage-error-classification.test.ts | 40 +- .../claude-usage-error-classification.ts | 17 - .../claude-usage-refresh-plan.test.ts | 32 - .../rate-limits/claude-usage-refresh-plan.ts | 47 -- src/main/rate-limits/claude-usage-result.ts | 114 +-- .../rate-limits/hidden-pty-cleanup.test.ts | 135 ---- src/main/rate-limits/hidden-pty-cleanup.ts | 58 -- .../service-account-target-selection.test.ts | 11 +- .../service-inactive-account-previews.test.ts | 18 +- .../service-live-claude-usage.test.ts | 106 +-- .../service/service-account-refresh.ts | 11 - .../service/service-fetch-policy.ts | 9 +- .../agent-session-record-store-slot.test.ts | 2 +- ...ude-structured-session-integration.test.ts | 39 +- ...structured-agent-session-create-support.ts | 3 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 - ...resolve-terminal-split-source-authority.ts | 8 +- ...ime-structured-claude-account-gate.test.ts | 112 --- ...time-structured-claude-gate-wiring.test.ts | 64 -- src/main/runtime/rpc/methods/accounts.test.ts | 57 +- src/main/runtime/rpc/methods/accounts.ts | 60 +- .../runtime/runtime-account-controller.ts | 21 +- .../runtime-service-command-surface.ts | 8 +- .../runtime/structured-agent-account-home.ts | 2 +- ...-agent-create-support-opencode-rig.test.ts | 3 +- ...tructured-agent-launch-args-wiring.test.ts | 2 +- .../structured-agent-launch-support.ts | 10 +- ...ured-agent-model-catalog-discovery.test.ts | 2 +- .../structured-agent-runtime-registrations.ts | 7 - ...gent-session-codex-interrupt-order.test.ts | 2 +- ...nt-session-codex-opening-send-hold.test.ts | 2 +- ...ent-session-codex-settlement-order.test.ts | 2 +- ...t-session-codex-stopped-send-order.test.ts | 2 +- ...-session-codex-turn-end-settlement.test.ts | 2 +- ...ured-agent-session-failure-logging.test.ts | 4 +- ...ructured-agent-session-held-signal.test.ts | 2 +- ...uctured-agent-session-host-refusal.test.ts | 2 +- ...ured-agent-session-install-failure.test.ts | 2 +- ...ructured-agent-session-integration.test.ts | 2 +- ...uctured-agent-session-runtime-exit.test.ts | 10 +- ...ion-runtime-model-catalog-lifetime.test.ts | 2 +- .../structured-agent-session-runtime.test.ts | 4 +- .../structured-agent-session-runtime.ts | 3 - ...nt-session-spawn-token-descendants.test.ts | 2 +- ...ed-agent-session-startup-reconcile.test.ts | 2 +- ...-agent-session-startup-tab-restore.test.ts | 2 +- ...-chat-coordinator-mail-rig.test-fixture.ts | 2 +- .../structured-child-records-switch.test.ts | 2 +- ...structured-claude-attachment-grant.test.ts | 2 +- ...ructured-claude-auth-policy-wiring.test.ts | 7 +- .../structured-claude-pending-rewind.test.ts | 2 +- .../structured-claude-runtime-adapter.ts | 5 - ...ed-claude-scripted-runtime-test-support.ts | 2 +- ...tructured-codex-child-work-runtime.test.ts | 2 +- ...ructured-codex-session-rpc-test-harness.ts | 2 +- ...tructured-foreground-command-strip.test.ts | 2 +- ...ll-wrapper-generated-file-snapshot.test.ts | 2 + .../startup/main-process-account-services.ts | 1 - .../startup/main-process-ready-foundation.ts | 20 - .../main-process-ready-identity-write.test.ts | 9 - .../startup/main-window-core-services.test.ts | 1 - src/main/startup/main-window-core-services.ts | 8 +- .../commit-message-agent-environment.test.ts | 6 +- .../commit-message-agent-environment.ts | 4 +- src/preload/api/pty-api.ts | 6 + .../pty-bridge-stream-and-serialization.ts | 4 + .../native-chat/NativeChatNoticeRow.tsx | 18 + .../NativeChatStructuredSession.tsx | 62 +- .../agent-session-failure-words-text.ts | 10 + .../native-chat-claude-sign-in.test.tsx | 116 +++ .../native-chat/native-chat-claude-sign-in.ts | 77 ++ .../native-chat-structured-session-notices.ts | 30 +- .../settings/accounts-pane-account-actions.ts | 31 +- .../accounts-pane-claude-section.test.tsx | 156 ++++ .../settings/accounts-pane-claude-section.tsx | 65 +- .../accounts-pane-removal-dialogs.tsx | 4 +- .../status-bar/ClaudeSwitcherMenu.tsx | 98 ++- .../status-bar/InlineProviderUsage.tsx | 8 +- .../status-bar/StatusBarProviderSegment.tsx | 7 +- .../status-bar/StatusBarSurface.tsx | 4 +- .../status-bar/claude-status-sign-in.test.tsx | 242 ++++++ .../status-bar/inline-usage-bars.test.tsx | 16 + .../status-bar/status-bar-claude-accounts.ts | 22 +- .../status-bar-runtime-groups.test.ts | 46 ++ .../status-bar/status-bar-runtime-targets.ts | 5 + .../src/components/status-bar/tooltip.test.ts | 41 +- .../components/status-bar/usage-error-copy.ts | 35 +- .../usage-provider-settings-target.test.ts | 36 +- .../usage-provider-settings-target.ts | 18 + .../status-bar/usage-roster-row-state.test.ts | 2 +- .../status-bar/use-status-bar-controller.ts | 8 +- .../terminal-pane/ClaudeOldTerminalBanner.tsx | 172 +++++ .../terminal-pane/CodexOldTerminalDialog.tsx | 54 -- .../terminal-pane/CodexSharedServerBanner.tsx | 150 +--- .../terminal-pane/OldTerminalDialog.tsx | 45 ++ .../terminal-pane/PaneTopWarningBanner.tsx | 96 +++ .../TerminalPaneRuntimePortals.tsx | 7 + .../claude-old-terminal-banner.test.tsx | 178 +++++ .../terminal-pane/open-terminal-beside-tab.ts | 29 + .../src/i18n/en-runtime-required.json | 4 +- src/renderer/src/i18n/locales/en.json | 57 +- src/renderer/src/i18n/locales/es.json | 17 +- src/renderer/src/i18n/locales/fr.json | 17 +- src/renderer/src/i18n/locales/ja.json | 17 +- src/renderer/src/i18n/locales/ko.json | 17 +- src/renderer/src/i18n/locales/zh.json | 17 +- .../src/lib/claude-account-sign-in.ts | 29 + .../src/web/preload-api/web-terminal-api.ts | 1 + src/shared/agent-session-failure-copy.ts | 7 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 2 + src/shared/agent-session-refusal-details.ts | 4 + .../agent-session-refusal-reason-words.ts | 2 + ...claude-profile-activation-contract.test.ts | 18 + src/shared/claude-profile-routing.ts | 17 +- src/shared/claude-shell-function.test.ts | 131 +++- src/shared/claude-shell-function.ts | 73 +- src/shared/constants.ts | 1 - src/shared/global-settings-types.ts | 2 + .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/managed-account-types.ts | 21 + src/shared/persisted-state-types.ts | 2 - src/shared/persisted-ui-state-types.ts | 2 + src/shared/protocol-version.ts | 3 + src/shared/rate-limit-types.ts | 1 - src/shared/rpc-contract/accounts-params.ts | 13 + src/shared/rpc-contract/client-ui-params.ts | 1 + .../rpc-params-catalog.generated.ts | 5 + ...ctured-agent-session-dispatch-rejection.ts | 4 + src/shared/wsl-login-shell-command.ts | 5 + 320 files changed, 6831 insertions(+), 20769 deletions(-) delete mode 100644 config/scripts/claude-account-windows-spawn-repro.mjs create mode 100644 src/main/claude-accounts/claude-account-folder.test.ts create mode 100644 src/main/claude-accounts/claude-account-folder.ts delete mode 100644 src/main/claude-accounts/claude-account-service-account-selection.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-add-account.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-api-parity.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-credential-capture.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-login-process.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts delete mode 100644 src/main/claude-accounts/claude-account-service-test-harness.ts create mode 100644 src/main/claude-accounts/claude-account-service.test.ts delete mode 100644 src/main/claude-accounts/claude-auth-capture.ts create mode 100644 src/main/claude-accounts/claude-command-process.test.ts create mode 100644 src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts create mode 100644 src/main/claude-accounts/claude-launch-routing.test.ts delete mode 100644 src/main/claude-accounts/claude-login-completion.oracle.test.ts delete mode 100644 src/main/claude-accounts/claude-managed-auth-storage.ts create mode 100644 src/main/claude-accounts/claude-profile-launch-errors.ts delete mode 100644 src/main/claude-accounts/claude-windows-interactive-login.test.ts delete mode 100644 src/main/claude-accounts/live-pty-gate.test.ts delete mode 100644 src/main/claude-accounts/live-pty-gate.ts delete mode 100644 src/main/claude-accounts/managed-auth-path.ts delete mode 100644 src/main/claude-accounts/oauth-refresh.test.ts delete mode 100644 src/main/claude-accounts/oauth-refresh.ts delete mode 100644 src/main/claude-accounts/runtime-auth-path-materialization.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-account-switching.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-materialization.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-test-harness.ts delete mode 100644 src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service.test.ts delete mode 100644 src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-state.ts delete mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts delete mode 100644 src/main/claude-accounts/shared-credential-fields.test.ts delete mode 100644 src/main/claude-accounts/shared-credential-fields.ts delete mode 100644 src/main/claude-accounts/shared-credential-reconciliation.test.ts create mode 100644 src/main/ipc/pty/ipc/claude-old-terminal.test.ts create mode 100644 src/main/ipc/pty/ipc/claude-old-terminal.ts delete mode 100644 src/main/native-chat/claude-structured-managed-account-support.test.ts delete mode 100644 src/main/native-chat/claude-structured-managed-account-support.ts delete mode 100644 src/main/rate-limits/claude-cli-usage-fetch.ts delete mode 100644 src/main/rate-limits/claude-fetcher-cli-fallback.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-fable-usage.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts delete mode 100644 src/main/rate-limits/claude-fetcher-test-harness.ts delete mode 100644 src/main/rate-limits/claude-managed-account-credentials.ts delete mode 100644 src/main/rate-limits/claude-managed-usage-panel.ts delete mode 100644 src/main/rate-limits/claude-oauth-recovery.ts rename src/main/rate-limits/{claude-fetcher-extra-usage.test.ts => claude-oauth-usage-extra-usage.test.ts} (66%) create mode 100644 src/main/rate-limits/claude-profile-usage.test.ts delete mode 100644 src/main/rate-limits/claude-pty-reset-parser.ts delete mode 100644 src/main/rate-limits/claude-pty-stop-markers.ts delete mode 100644 src/main/rate-limits/claude-pty-usage-parser.ts delete mode 100644 src/main/rate-limits/claude-pty.test.ts delete mode 100644 src/main/rate-limits/claude-pty.ts delete mode 100644 src/main/rate-limits/claude-usage-refresh-plan.test.ts delete mode 100644 src/main/rate-limits/claude-usage-refresh-plan.ts delete mode 100644 src/main/rate-limits/hidden-pty-cleanup.test.ts delete mode 100644 src/main/rate-limits/hidden-pty-cleanup.ts delete mode 100644 src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts delete mode 100644 src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-claude-sign-in.ts create mode 100644 src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx create mode 100644 src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx delete mode 100644 src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx create mode 100644 src/renderer/src/components/terminal-pane/OldTerminalDialog.tsx create mode 100644 src/renderer/src/components/terminal-pane/PaneTopWarningBanner.tsx create mode 100644 src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/open-terminal-beside-tab.ts create mode 100644 src/renderer/src/lib/claude-account-sign-in.ts create mode 100644 src/shared/claude-profile-activation-contract.test.ts diff --git a/config/scripts/claude-account-windows-spawn-repro.mjs b/config/scripts/claude-account-windows-spawn-repro.mjs deleted file mode 100644 index eaf04762e5ba..000000000000 --- a/config/scripts/claude-account-windows-spawn-repro.mjs +++ /dev/null @@ -1,342 +0,0 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts' - -const strategy = process.argv[2] -if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) { - throw new Error( - 'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs ' - ) -} -if (process.platform !== 'win32') { - throw new Error('This reproduction requires a physical Windows host.') -} - -const expectedArgs = [ - '', - 'two words', - 'amp&ersand', - 'pipe|value', - 'lessvalue', - 'caret^value', - 'trailing\\', - 'two-trailing\\\\', - '(parentheses)', - '100%', - '%ORCA_ARG_TRAP%', - 'bang!value', - '한글-λ' -] -const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-')) -const reportedDir = join(tempRoot, 'Profile with spaces 한글') -const reportedCapturePath = join(reportedDir, 'capture.json') -const reportedPidPath = join(reportedDir, 'pids.json') -const reportedShimPath = join(reportedDir, 'claude fixture.cmd') -const reportedFixturePath = join(reportedDir, 'capture-child.cjs') -const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글') -const capturePath = join(fixtureDir, 'capture.json') -const pidPath = join(fixtureDir, 'pids.json') -const shimPath = join(fixtureDir, 'claude fixture.cmd') -const fixturePath = join(fixtureDir, 'capture-child.cjs') -const fixtureEnv = { - ...process.env, - CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'), - ORCA_ARG_TRAP: 'EXPANDED_ARG', - ORCA_PATH_TRAP: 'EXPANDED_PATH', - ORCA_FIXTURE_CAPTURE: capturePath, - ORCA_FIXTURE_PIDS: pidPath, - ORCA_FIXTURE_NODE: process.execPath -} -const reportedEnv = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'), - ORCA_FIXTURE_CAPTURE: reportedCapturePath, - ORCA_FIXTURE_PIDS: reportedPidPath -} - -function quoteForCandidate(value) { - return `"${value.replace(/"/g, '""')}"` -} - -function launch(args, command = shimPath, env = fixtureEnv) { - if (strategy === 'baseline') { - return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true }) - } - if (strategy === 'candidate') { - return spawn(quoteForCandidate(command), args, { - cwd: tempRoot, - env, - shell: true, - windowsHide: true - }) - } - const invocation = buildWindowsCommandInvocation(command, args) - return spawn(invocation.command, invocation.args, { - cwd: tempRoot, - env, - shell: false, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - windowsHide: true - }) -} - -function collect(child) { - return new Promise((resolve) => { - let stdout = '' - let stderr = '' - child.stdout?.on('data', (chunk) => (stdout += chunk.toString())) - child.stderr?.on('data', (chunk) => (stderr += chunk.toString())) - child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message })) - child.on('close', (code) => resolve({ code, stdout, stderr, error: null })) - }) -} - -async function waitForFile(path, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - return JSON.parse(await readFile(path, 'utf8')) - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)) - } - } - throw new Error(`Timed out waiting for fixture output: ${path}`) -} - -async function taskExists(pid) { - const result = await collect( - spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { - windowsHide: true - }) - ) - if (result.error || result.code !== 0) { - throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`) - } - return result.stdout.includes(`"${pid}"`) -} - -async function killTree(pid) { - const result = await collect( - spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }) - ) - if (result.error || result.code !== 0) { - throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`) - } -} - -async function waitForTreeExit(pids, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - let alive = {} - do { - alive = Object.fromEntries( - await Promise.all( - Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)]) - ) - ) - if (!Object.values(alive).some(Boolean)) { - return alive - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } while (Date.now() < deadline) - return alive -} - -const results = { - strategy, - reportedPath: null, - pathMatrix: {}, - argvMatrix: {}, - hostilePathAndArgv: null, - error: null, - cancellation: null -} -const fixtureSource = - `const { spawn } = require('node:child_process')\n` + - `const { writeFileSync } = require('node:fs')\n` + - `if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` + - `if (process.argv[2] === '--linger') {\n` + - ` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` + - ` setInterval(() => {}, 1000)\n` + - `} else {\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` + - `}\n` -const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n' -let lingeringShellPid = null -try { - await mkdir(fixtureDir, { recursive: true }) - await mkdir(reportedDir, { recursive: true }) - await writeFile(fixturePath, fixtureSource, 'utf8') - await writeFile(shimPath, shimSource, 'utf8') - await writeFile(reportedFixturePath, fixtureSource, 'utf8') - await writeFile(reportedShimPath, shimSource, 'utf8') - - const reportedArgs = ['auth', 'status', '--json'] - const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv)) - let reportedCapture = null - try { - reportedCapture = await waitForFile(reportedCapturePath, 1_000) - } catch {} - results.reportedPath = { - ...reportedRun, - actual: reportedCapture, - expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }, - pass: - reportedRun.code === 0 && - JSON.stringify(reportedCapture) === - JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }) - } - - for (const [name, segment] of Object.entries({ - spaces: 'profile space', - ampersand: 'profile&name', - caret: 'profile^name', - parentheses: 'profile(name)', - percent: 'profile%ORCA_PATH_TRAP%', - bang: 'profile!name', - unicode: 'profile-한글-λ' - })) { - const directory = join(tempRoot, segment) - const captureFile = join(directory, 'capture.json') - const command = join(directory, 'claude fixture.cmd') - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8') - await writeFile(command, shimSource, 'utf8') - const env = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(directory, 'config'), - ORCA_FIXTURE_CAPTURE: captureFile - } - const run = await collect(launch(reportedArgs, command, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.pathMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs) - } - } - - for (const [name, value] of Object.entries({ - empty: '', - spaces: 'two words', - ampersand: 'amp&ersand', - pipe: 'pipe|value', - lessThan: 'lessvalue', - caret: 'caret^value', - trailingBackslash: 'trailing\\', - twoTrailingBackslashes: 'two-trailing\\\\', - parentheses: '(parentheses)', - percent: '%ORCA_ARG_TRAP%', - bang: 'bang!value', - unicode: '한글-λ' - })) { - const args = ['prefix', value, 'suffix'] - const captureFile = join(reportedDir, `capture-${name}.json`) - const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile } - const run = await collect(launch(args, reportedShimPath, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.argvMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args) - } - } - - const argvRun = await collect(launch(expectedArgs)) - let capture = null - try { - capture = await waitForFile(capturePath, 1_000) - } catch {} - results.hostilePathAndArgv = { - ...argvRun, - actual: capture, - expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }, - pass: - argvRun.code === 0 && - JSON.stringify(capture) === - JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }) - } - - results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv)) - results.error.pass = - results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !') - - const lingering = launch(['--linger'], reportedShimPath, reportedEnv) - lingeringShellPid = lingering.pid - const lingeringResult = collect(lingering) - try { - const pids = await waitForFile(reportedPidPath) - await killTree(lingering.pid) - const alive = await waitForTreeExit({ - shell: lingering.pid, - child: pids.child, - grandchild: pids.grandchild - }) - results.cancellation = { - shell: lingering.pid, - ...pids, - alive, - pass: !Object.values(alive).some(Boolean) - } - } catch (error) { - if (await taskExists(lingering.pid)) { - await killTree(lingering.pid) - } - const launchResult = await Promise.race([ - lingeringResult, - new Promise((resolve) => - setTimeout( - () => resolve({ code: null, error: 'fixture did not exit after cleanup' }), - 5_000 - ) - ) - ]) - results.cancellation = { - shell: lingering.pid, - launchResult, - pass: false, - error: error instanceof Error ? error.message : String(error) - } - } -} finally { - if (lingeringShellPid && (await taskExists(lingeringShellPid))) { - await killTree(lingeringShellPid) - } - try { - let pids - try { - pids = JSON.parse(await readFile(reportedPidPath, 'utf8')) - } catch { - pids = JSON.parse(await readFile(pidPath, 'utf8')) - } - for (const pid of [pids.child, pids.grandchild]) { - if (await taskExists(pid)) { - await killTree(pid) - } - } - } catch {} - await rm(tempRoot, { recursive: true, force: true }) -} - -console.log(JSON.stringify(results, null, 2)) -process.exitCode = - results.reportedPath?.pass && - Object.values(results.pathMatrix).every((result) => result.pass) && - Object.values(results.argvMatrix).every((result) => result.pass) && - results.hostilePathAndArgv?.pass && - results.error?.pass && - results.cancellation?.pass - ? 0 - : 1 diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index 74d2304b187a..26428153c0b0 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -10,8 +10,8 @@ // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where // upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no -// user-visible pane does. The divergence pinned below is about which +// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this +// hunk, but no user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createHash } from 'node:crypto' import { createRequire } from 'node:module' diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 66da6d4b1b71..59df0c6b3a48 100644 --- a/src/cli/handlers/account-list-format.ts +++ b/src/cli/handlers/account-list-format.ts @@ -3,7 +3,7 @@ import type { ManagedDataAccountsState } from '../../shared/managed-account-type // Why: Claude and Codex managed-account summaries both carry id+email+active id, // so one formatter renders either provider's block. type AccountsBlock = { - accounts: readonly { id: string; email: string }[] + accounts: readonly { id: string; email: string; needsSignIn?: true }[] activeAccountId: string | null activeAccountIdsByRuntime?: { host: string | null @@ -35,7 +35,10 @@ export function formatAccountsBlock(label: string, block: AccountsBlock): string ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) ]) const lines = block.accounts.map( - (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` + (account) => + ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}${ + account.needsSignIn ? ' (sign in again in Orca Settings > AI Provider Accounts)' : '' + }` ) return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` } diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8b2fe9d5ae7e..e19fd4a8ad29 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -1,6 +1,5 @@ import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution' import { EventEmitter } from 'node:events' -import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { delimiter } from 'node:path' import type * as NodeFs from 'node:fs' @@ -63,7 +62,10 @@ import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' -import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + CLAUDE_SIGN_IN_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' function successfulChild(): EventEmitter { const child = new EventEmitter() @@ -131,8 +133,14 @@ describe('account CLI handlers', () => { ok: true, result: method === 'status.get' - ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } - : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), + ? { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + } + : method === 'accounts.beginClaudeSignIn' + ? { accountId: 'draft', configDir: '/fake/final-profile', runtime: 'host' } + : accountState( + method.includes('Claude') ? 'claude@example.com' : 'codex@example.com' + ), _meta: { runtimeId: 'test-runtime' } }) ) @@ -306,37 +314,17 @@ describe('account CLI handlers', () => { expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) }) - it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - + it('runs login in the final profile and never reads, writes or cleans up Keychain credentials', async () => { await ACCOUNT_HANDLERS['account add'](context('claude')) - - const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR - expect(deleteKeychainMock).toHaveBeenCalledWith(configDir) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') - expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { - configDir, - previousLegacyCredentialsSha256: createHash('sha256') - .update('legacy-credentials') - .digest('hex') - }) - expect(existsSync(configDir)).toBe(false) - }) - - it('attributes a Claude account added through the WSL bridge to the cwd distro', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - - await ACCOUNT_HANDLERS['account add']( - context('claude', false, String.raw`\\wsl.localhost\Ubuntu-22.04\home\user\project`) + expect(spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR).toBe('/fake/final-profile') + expect(callMock).toHaveBeenCalledWith( + 'accounts.finishClaudeSignIn', + { accountId: 'draft', runtime: 'host', wslDistro: undefined }, + { timeoutMs: 300000 } ) - - const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR - expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { - configDir, - runtime: 'wsl', - wslDistro: 'Ubuntu-22.04' - }) + expect(readKeychainMock).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() + expect(deleteKeychainMock).not.toHaveBeenCalled() }) it('attributes a Codex account added through the WSL bridge to the cwd distro', async () => { @@ -364,8 +352,9 @@ describe('account CLI handlers', () => { ) expect(callMock).toHaveBeenCalledWith( - 'accounts.addClaudeFromConfigDir', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }) + 'accounts.beginClaudeSignIn', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }), + { timeoutMs: 300000 } ) }) @@ -375,10 +364,14 @@ describe('account CLI handlers', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu Work' await ACCOUNT_HANDLERS['account add'](context(agent, false, String.raw`C:\work with spaces`)) - expect(callMock).toHaveBeenCalledWith( - agent === 'claude' ? 'accounts.addClaudeFromConfigDir' : 'accounts.addCodexFromHome', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu Work' }) - ) + expect( + callMock.mock.calls.some( + ([method, params]) => + method === + (agent === 'claude' ? 'accounts.beginClaudeSignIn' : 'accounts.addCodexFromHome') && + params.wslDistro === 'Ubuntu Work' + ) + ).toBe(true) } ) @@ -470,62 +463,6 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) - it('waits for in-flight cleanup when a second signal arrives', async () => { - // Why: a boolean latch lets the second signal's process.exit fire while the - // first cleanup is still inside a Keychain call, stranding the credentials. - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - let releaseKeychainDelete: (() => void) | undefined - deleteKeychainMock.mockImplementationOnce( - () => - new Promise((resolvePromise) => { - releaseKeychainDelete = () => resolvePromise() - }) - ) - const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) - let configDir = '' - spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { - configDir = options.env.CLAUDE_CONFIG_DIR - return child - }) - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) - const sigintBefore = process.listeners('SIGINT') - const sigtermBefore = process.listeners('SIGTERM') - - const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {}) - await vi.waitFor(() => expect(configDir).not.toBe('')) - - const onSigint = newSignalListener('SIGINT', sigintBefore) - const onSigterm = newSignalListener('SIGTERM', sigtermBefore) - // Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler - // is registered with `once` — where a second Ctrl-C falls through to Node's - // default and kills the process mid-cleanup. - expect(process.rawListeners('SIGINT')).toContain(onSigint) - - onSigint('SIGINT') - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT')) - child.emit('exit', 1) - child.emit('close', 1) - await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)) - onSigterm('SIGTERM') - await new Promise((resolvePromise) => { - setImmediate(resolvePromise) - }) - - expect(exitSpy).not.toHaveBeenCalled() - expect(writeKeychainMock).not.toHaveBeenCalled() - expect(existsSync(configDir)).toBe(true) - - releaseKeychainDelete?.() - await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') - expect(existsSync(configDir)).toBe(false) - expect(child.kill).toHaveBeenCalledOnce() - - await pending - exitSpy.mockRestore() - }) - it('warns that the account may already be registered when interrupted mid-RPC', async () => { // Why: the runtime finishes the add independently of this process, so an // interrupt after sign-in cannot honestly be reported as "not added". @@ -540,7 +477,9 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -569,32 +508,6 @@ describe('account CLI handlers', () => { exitSpy.mockRestore() }) - it('stays armed for signals until post-success cleanup finishes', async () => { - // Why: detaching the handlers before cleanup leaves the multi-second Keychain - // calls covered only by Node's default handling, which kills mid-cleanup. - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - let releaseKeychainDelete: (() => void) | undefined - deleteKeychainMock.mockImplementationOnce( - () => - new Promise((resolvePromise) => { - releaseKeychainDelete = () => resolvePromise() - }) - ) - - const listenersBefore = process.listeners('SIGINT') - - const pending = ACCOUNT_HANDLERS['account add'](context('claude')) - await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled()) - - // Why: cleanup is still in flight here, so this add's guard must still be installed. - const handler = newSignalListener('SIGINT', listenersBefore) - - releaseKeychainDelete?.() - await pending - expect(process.listeners('SIGINT')).not.toContain(handler) - }) - it('fails before the login when the runtime is unreachable', async () => { // Why: discovering a dead runtime after sign-in wastes a full OAuth round trip. callMock.mockRejectedValue(new Error('runtime not running')) @@ -606,22 +519,40 @@ describe('account CLI handlers', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('fails before login when the running runtime predates account imports', async () => { + it('still adds Codex through an older host that advertises only the retired import capability', async () => { callMock.mockResolvedValue({ id: 'test', ok: true, - result: { capabilities: [] }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) + spawnMock.mockImplementation(() => { + throw new Error('stop after the capability gate') + }) - await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( - 'runtime is too old' - ) - expect(callMock).toHaveBeenCalledOnce() - expect(callMock).toHaveBeenCalledWith('status.get') - expect(spawnMock).not.toHaveBeenCalled() + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow() + expect(spawnMock).toHaveBeenCalled() }) + it.each(['claude', 'codex'] as const)( + 'refuses %s login before spawning when its capability is absent', + async (agent) => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test-runtime' } + }) + + await expect(ACCOUNT_HANDLERS['account add'](context(agent))).rejects.toThrow( + 'The running Orca runtime is too old to add accounts from the CLI.' + ) + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + } + ) + it.each(['environment', 'pairing-code'])( 'rejects --%s instead of silently ignoring it', async (flag) => { @@ -667,7 +598,9 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { + capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] + }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -699,15 +632,14 @@ describe('account CLI handlers', () => { expect(logSpy).not.toHaveBeenCalled() }) - it('fails a successful Claude add when Keychain cleanup fails', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readKeychainMock.mockResolvedValue('legacy-credentials') - deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup')) - - await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow( - 'Failed to clean up Claude login artifacts' - ) - expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + it('does not touch a locked Keychain while the CLI signs in', async () => { + readKeychainMock.mockRejectedValue(new Error('locked')) + deleteKeychainMock.mockRejectedValue(new Error('locked')) + writeKeychainMock.mockRejectedValue(new Error('locked')) + await ACCOUNT_HANDLERS['account add'](context('claude')) + expect(readKeychainMock).not.toHaveBeenCalled() + expect(deleteKeychainMock).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() }) it('rejects `--agent` with no value instead of defaulting to Claude', async () => { @@ -741,6 +673,32 @@ describe('account CLI handlers', () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)')) }) + it('tells the user which saved Claude accounts need a fresh sign-in', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { + accounts: [ + { id: 'old', email: 'old@example.com', needsSignIn: true }, + { id: 'ok', email: 'ok@example.com' } + ], + activeAccountId: 'old' + }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + ' old@example.com (active) (sign in again in Orca Settings > AI Provider Accounts)\n ok@example.com\n' + ) + ) + }) + it('lists accounts without forcing a provider usage refresh', async () => { // Why: the forced lane bypasses the poll throttle and costs one serial // round-trip per managed account, and this output shows no usage numbers. diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index d4081c3c5da1..db2cd31c8b8a 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -1,5 +1,4 @@ import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -9,10 +8,10 @@ import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from '../../main/claude-accounts/keychain' + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { getVersionManagerBinPaths, resolveCliCommand, @@ -24,9 +23,13 @@ import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' -import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + CLAUDE_SIGN_IN_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { + ClaudeAccountSignIn, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, ManagedDataAccountsState @@ -140,79 +143,53 @@ async function runAgentLoginInTerminal( }) } -async function cleanupClaudeLoginArtifacts( - configDir: string, - legacyCredentials: string | null, - restoreLegacyCredentials: boolean -): Promise { - const errors: unknown[] = [] - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(configDir) - } catch (error) { - errors.push(error) - } - if (restoreLegacyCredentials) { - try { - await (legacyCredentials - ? writeActiveClaudeKeychainCredentials(legacyCredentials) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - errors.push(error) - } - } - } - try { - rmSync(configDir, { recursive: true, force: true }) - } catch (error) { - errors.push(error) - } - if (errors.length > 0) { - throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.') - } -} - -/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ +/** Signs in to a new account folder the host created, then registers it (superset AddAccountDialog). */ async function addClaudeAccount({ client, cwd, json }: HandlerContext): Promise { - const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) + const { result: signIn } = await client.call( + 'accounts.beginClaudeSignIn', + getWslAccountTarget(cwd) ?? {}, + { timeoutMs: 300_000 } + ) const session: InteractiveLoginSession = { child: null, registering: false, terminationPromise: null } - let legacyCredentials: string | null = null - let restoreLegacyCredentials = false const result = await withInteractiveLoginCleanup( session, + // Why always: the host keeps a folder that became an account, and deletes an abandoned one. + // Never thrown: an older running Orca lacks the method, and the add itself already finished. async () => { - await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) + const { accountId, runtime, wslDistro } = signIn + await client + .call('accounts.cancelClaudeSignIn', { accountId, runtime, wslDistro }) + .catch((error: unknown) => console.warn('[account] Could not clean up sign-in:', error)) }, async () => { - if (process.platform === 'darwin') { - legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() - restoreLegacyCredentials = true + if (signIn.runtime === 'wsl' && signIn.wslDistro) { + const login = `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(signIn.configDir)} claude auth login` + await runAgentLoginInTerminal( + 'wsl.exe', + buildWslExecArgs(signIn.wslDistro, ['/bin/sh', '-c', buildWslLoginShellCommand(login)]), + {}, + json, + session + ) + } else { + await runAgentLoginInTerminal( + 'claude', + ['auth', 'login'], + { CLAUDE_CONFIG_DIR: signIn.configDir }, + json, + session + ) } - await runAgentLoginInTerminal( - 'claude', - ['auth', 'login', '--claudeai'], - { - CLAUDE_CONFIG_DIR: configDir - }, - json, - session - ) session.registering = true - return client.call('accounts.addClaudeFromConfigDir', { - configDir, - ...getWslAccountTarget(cwd), - ...(process.platform === 'darwin' - ? { - previousLegacyCredentialsSha256: legacyCredentials - ? createHash('sha256').update(legacyCredentials).digest('hex') - : null - } - : {}) - }) + return client.call( + 'accounts.finishClaudeSignIn', + { accountId: signIn.accountId, runtime: signIn.runtime, wslDistro: signIn.wslDistro }, + { timeoutMs: 300_000 } + ) } ) printResult(result, json, (state) => formatAccountsBlock('Claude', state)) @@ -265,9 +242,14 @@ function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string) ) } -async function assertAccountImportSupported({ client }: HandlerContext): Promise { +async function assertAccountImportSupported( + { client }: HandlerContext, + agent: 'claude' | 'codex' +): Promise { const status = await client.call('status.get') - if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) { + const capability = + agent === 'claude' ? CLAUDE_SIGN_IN_RUNTIME_CAPABILITY : ACCOUNT_IMPORT_RUNTIME_CAPABILITY + if (!status.result.capabilities?.includes(capability)) { throw new RuntimeClientError( 'incompatible_runtime', 'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.' @@ -300,7 +282,7 @@ export const ACCOUNT_HANDLERS: Record = { return } // Why: fail on runtime version skew before burning a full OAuth round trip. - await assertAccountImportSupported(ctx) + await assertAccountImportSupported(ctx, agent) await ctx.client.call('accounts.list', { refreshUsage: false }) await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) }, diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index c8de61a9fdac..754e1eee90d9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -138,6 +138,30 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary +__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index ff24125e2c00..076fd8f1a694 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -17,4 +17,30 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type \ No newline at end of file +set -e __orca_codex_type +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + command claude $argv + return $status + end + if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" + env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" + return 1 + end + test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null + env CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 18850b4a0efc..c73d9a73062f 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -220,6 +220,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 05f3da594e03..1b85c0f08d9d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -148,6 +148,30 @@ if [[ -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then } fi unset __orca_codex_binary +__orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" +if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } +fi +unset __orca_claude_binary # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index ff24125e2c00..076fd8f1a694 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -17,4 +17,30 @@ if test "$__orca_codex_type" = file command codex $argv end end -set -e __orca_codex_type \ No newline at end of file +set -e __orca_codex_type +set -l __orca_claude_type (type -t claude 2>/dev/null) +if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file + function claude + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + set -l pointer (string replace -r '^~/' "$HOME/" -- "$ORCA_CLAUDE_PROFILE_POINTER") + set -l profile (cat "$pointer" 2>/dev/null) + if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$ORCA_CLAUDE_INJECTED_CONFIG_DIR" + command claude $argv + return $status + end + if test -z "$profile"; and test -n "$CLAUDE_CONFIG_DIR"; and test -n "$ORCA_CLAUDE_USER_CONFIG_DIR" + env -u ORCA_CLAUDE_INJECTED_CONFIG_DIR CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR" claude $argv + return $status + end + if test -z "$profile" + env -u CLAUDE_CONFIG_DIR -u ORCA_CLAUDE_INJECTED_CONFIG_DIR claude $argv + return $status + end + if not string match -qr '^(/|[A-Za-z]:)' -- "$profile" + return 1 + end + test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null + env CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv + end +end +set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index b3c8a92869ca..52bbfa7b3272 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -227,6 +227,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 21dce8480e63..f52ef39a6767 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -190,6 +190,30 @@ __orca_deferred_init() { } fi unset __orca_codex_binary + __orca_claude_binary="$(unalias claude 2>/dev/null || :; command -v claude 2>/dev/null || :)" + if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && -x "${__orca_claude_binary}" ]]; then + function claude { + local __orca_claude_home __orca_claude_pointer="${ORCA_CLAUDE_PROFILE_POINTER:-}" + # Why: a WSL pane's pointer is relative to the guest home, which the host cannot know at spawn. + case "$__orca_claude_pointer" in '~/'*) __orca_claude_pointer="${HOME:-}/${__orca_claude_pointer#??}" ;; esac + __orca_claude_home="$(cat "$__orca_claude_pointer" 2>/dev/null || :)" + if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "${ORCA_CLAUDE_INJECTED_CONFIG_DIR:-}" ]; then + command claude "$@"; return + fi + # Why: Orca's value replaced the user's own at spawn, so System default restores theirs. + if [ -z "$__orca_claude_home" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -n "${ORCA_CLAUDE_USER_CONFIG_DIR:-}" ]; then + ( unset ORCA_CLAUDE_INJECTED_CONFIG_DIR; export CLAUDE_CONFIG_DIR="$ORCA_CLAUDE_USER_CONFIG_DIR"; command claude "$@" ); return + fi + if [ -z "$__orca_claude_home" ]; then + ( unset CLAUDE_CONFIG_DIR ORCA_CLAUDE_INJECTED_CONFIG_DIR; command claude "$@" ); return + fi + case "$__orca_claude_home" in /*|[A-Za-z]:*) ;; *) return 1 ;; esac + # Why -m 700: matches the folder Orca's setup creates; a credentials folder stays private. + [ -d "$__orca_claude_home" ] || mkdir -p -m 700 -- "$__orca_claude_home" 2>/dev/null + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + } + fi + unset __orca_claude_binary if [[ -n "${_orca_histfile:-}" ]]; then HISTFILE="$_orca_histfile" fi diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 0b0ff8ca6225..b5056e836066 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -8,7 +8,7 @@ describe('preserveAgentAuthBeforeRestart', () => { vi.restoreAllMocks() }) - it('syncs Codex then Claude before flushing the store', async () => { + it('syncs Codex and flushes the store', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ @@ -17,11 +17,6 @@ describe('preserveAgentAuthBeforeRestart', () => { calls.push('codex') }) }, - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - calls.push('claude') - }) - }, store: { flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') @@ -29,7 +24,7 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex', 'claude', 'flush']) + expect(calls).toEqual(['codex', 'flush']) }) it('drains retained WSL Codex auth before flushing the store', async () => { @@ -106,27 +101,6 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) - it('does not start Claude after host Codex exhausts the lifecycle budget', async () => { - vi.useFakeTimers() - const startedAt = Date.now() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const syncClaude = vi.fn(async () => {}) - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(() => { - vi.setSystemTime(startedAt + 2_000) - }) - }, - claudeRuntimeAuth: { syncForCurrentSelection: syncClaude } - }) - - expect(syncClaude).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Claude auth preservation exceeded 0ms; continuing restart/update' - ) - }) - it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() @@ -148,83 +122,15 @@ describe('preserveAgentAuthBeforeRestart', () => { throw new Error('codex-token-secret') }) }, - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - throw new Error('claude-token-secret') - }) - }, store: { flushPendingOrThrowAsync } }) ).resolves.toBeUndefined() expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledTimes(2) + expect(warn).toHaveBeenCalledTimes(1) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) - it('releases the lifecycle path on timeout without canceling in-flight sync', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const calls: string[] = [] - let finishClaude!: () => void - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(async () => { - calls.push('claude-start') - await new Promise((resolve) => { - finishClaude = resolve - }) - calls.push('claude-finish') - }) - }, - store: { - flushPendingOrThrowAsync: vi.fn(async () => { - calls.push('flush') - }) - } - }) - - await vi.advanceTimersByTimeAsync(2_000) - await preservation - - expect(calls).toEqual(['claude-start', 'flush']) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Claude auth preservation exceeded 2000ms; continuing restart/update' - ) - - finishClaude() - await Promise.resolve() - - expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) - }) - - it('shares the original lifecycle timeout between Claude and store preservation', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let settled = false - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(() => new Promise(() => {})) - }, - store: { - flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) - } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.runOnlyPendingTimersAsync() - await preservation - - expect(settled).toBe(true) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Store persistence exceeded 0ms; continuing restart/update' - ) - }) - it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index d00641088cc2..a41be9a01833 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -1,4 +1,3 @@ -import type { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import type { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import type { Store } from './persistence' @@ -6,23 +5,17 @@ const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 type CodexRuntimeAuthSync = Pick & Partial> -type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick -type AuthPreservationStep = - | 'Codex auth preservation' - | 'Claude auth preservation' - | 'Store persistence' +type AuthPreservationStep = 'Codex auth preservation' | 'Store persistence' export type AgentAuthRestartPreservationOptions = { codexRuntimeHome?: CodexRuntimeAuthSync | null - claudeRuntimeAuth?: ClaudeRuntimeAuthSync | null store?: ShutdownStore | null } export async function preserveAgentAuthBeforeRestart({ codexRuntimeHome, - claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { const startedAt = Date.now() @@ -30,17 +23,6 @@ export async function preserveAgentAuthBeforeRestart({ // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const claudeRemainingMs = remainingLifecycleTime(startedAt) - if (claudeRuntimeAuth && claudeRemainingMs > 0) { - await runWithinLifecycleTimeout( - 'Claude auth preservation', - () => claudeRuntimeAuth.syncForCurrentSelection(), - claudeRemainingMs - ) - } else if (claudeRuntimeAuth) { - logStepTimeout('Claude auth preservation', 0) - } - const storePreservation = store ? runWithinLifecycleTimeout( 'Store persistence', diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts index 8aead85ff78e..d3faefdd9397 100644 --- a/src/main/agent-workspace-trust.test.ts +++ b/src/main/agent-workspace-trust.test.ts @@ -203,7 +203,6 @@ describe('applyAgentWorkspaceTrust on this machine', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/u/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'test' } as const const wsl = { ...local, claudeAuth: wslAuth, wslDistro: 'Ubuntu' } diff --git a/src/main/claude-accounts/claude-account-folder.test.ts b/src/main/claude-accounts/claude-account-folder.test.ts new file mode 100644 index 000000000000..f449dbcd231f --- /dev/null +++ b/src/main/claude-accounts/claude-account-folder.test.ts @@ -0,0 +1,137 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as Os from 'node:os' + +const keychain = vi.hoisted(() => ({ items: new Map(), userHome: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => keychain.userHome +})) +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async (args: string[]) => { + const service = args[2] + const count = keychain.items.get(service) ?? 0 + if (count === 0) { + throw new Error('The specified item could not be found in the keychain.') + } + keychain.items.set(service, count - 1) + return { stdout: '', stderr: '' } + } +})) + +import { + readClaudeFolderLogin, + readClaudeFolderLoginAsync, + removeClaudeAccountFolder +} from './claude-account-folder' +import { claudeKeychainService } from './keychain' + +const roots: string[] = [] +afterEach(() => { + keychain.items.clear() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-account-folder-')) + roots.push(root) + keychain.userHome = root + const dataRoot = join(root, 'data') + const home = join(dataRoot, 'claude-profiles', 'a', 'home') + mkdirSync(home, { recursive: true }) + return { root, dataRoot, home } +} + +describe('claude account folder', () => { + it("reads the folder's login and treats a signed-out state file as no login", () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + expect(readClaudeFolderLogin(stateFile)).toBeNull() + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: ' a@example.test ' } })) + expect(readClaudeFolderLogin(stateFile)).toEqual({ + email: 'a@example.test', + organizationUuid: null, + organizationName: null + }) + writeFileSync(stateFile, JSON.stringify({ numStartups: 2 })) + expect(readClaudeFolderLogin(stateFile)).toBeNull() + }) + + it('reuses a recent answer within maxAgeMs even though the file changed', () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } })) + expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test!' } })) + expect(readClaudeFolderLogin(stateFile, 5_000)?.email).toBe('a@example.test') + expect(readClaudeFolderLogin(stateFile)?.email).toBe('b@example.test!') + }) + + it('rereads a state file over a share only when its mtime or size changes', async () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + await expect(readClaudeFolderLoginAsync(stateFile)).resolves.toBeNull() + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } })) + // Whole seconds, which every filesystem stores exactly. + const mtime = new Date(Math.floor(Date.now() / 1000) * 1000) + utimesSync(stateFile, mtime, mtime) + expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('a@example.test') + // Same size and mtime: the cached answer, so the file was not read again. + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test' } })) + utimesSync(stateFile, mtime, mtime) + expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('a@example.test') + utimesSync(stateFile, mtime, new Date(mtime.getTime() + 2_000)) + expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('b@example.test') + rmSync(stateFile) + await expect(readClaudeFolderLoginAsync(stateFile)).resolves.toBeNull() + }) + + // A real symlink needs Developer Mode on Windows (EPERM otherwise). + it.skipIf(process.platform === 'win32')( + 'unlinks shared history before deleting, and leaves the older layout alone', + async () => { + const { root, dataRoot, home } = fixture() + const history = join(root, '.claude', 'projects') + mkdirSync(history, { recursive: true }) + writeFileSync(join(history, 'chat.jsonl'), '{}') + symlinkSync(history, join(home, 'projects')) + const older = join(dataRoot, 'claude-accounts', 'a') + mkdirSync(older, { recursive: true }) + + await removeClaudeAccountFolder(dataRoot, 'a') + expect(existsSync(join(dataRoot, 'claude-profiles', 'a'))).toBe(false) + expect(existsSync(join(history, 'chat.jsonl'))).toBe(true) + expect(existsSync(older)).toBe(true) + } + ) + + it.skipIf(process.platform !== 'darwin')( + 'deletes every Keychain item Claude may have keyed to the folder', + async () => { + const { dataRoot, home } = fixture() + const rest = home.slice(keychain.userHome.length) + const spellings = [home, `${home}/`, `~${rest}`, `$HOME${rest}/`] + for (const spelling of spellings) { + keychain.items.set(claudeKeychainService(spelling), 2) + } + const unrelated = claudeKeychainService(join(keychain.userHome, 'other')) + keychain.items.set(unrelated, 1) + + await removeClaudeAccountFolder(dataRoot, 'a') + for (const spelling of spellings) { + expect(keychain.items.get(claudeKeychainService(spelling))).toBe(0) + } + expect(keychain.items.get(unrelated)).toBe(1) + } + ) +}) diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts new file mode 100644 index 000000000000..1d6e60b4e04c --- /dev/null +++ b/src/main/claude-accounts/claude-account-folder.ts @@ -0,0 +1,199 @@ +import { + existsSync, + lstatSync, + readdirSync, + rmdirSync, + rmSync, + statSync, + unlinkSync +} from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { resolveClaudeGlobalConfigFile } from '../claude/claude-folder-trust-file' +import { execSecurityCommand } from '../macos-keychain/generic-password' +import { describeClaudeProfile, readClaudeProfileObject } from './claude-profile-paths' +import { claudeConfigDirSpellings, claudeKeychainService } from './keychain' + +export type ClaudeFolderLogin = { + email: string + organizationUuid: string | null + organizationName: string | null +} + +const text = z + .string() + .transform((value) => value.trim() || null) + .nullish() + .catch(null) +const oauthAccount = z.object({ + emailAddress: text, + organizationUuid: text, + organizationName: text +}) + +/** Claude's state file for a config folder; undefined is `~/.claude`, whose file sits in home. */ +export function claudeStateFile(configDir: string | undefined, userHome = homedir()): string { + return resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: configDir }, + homeDir: userHome, + style: process.platform === 'win32' ? 'win32' : 'posix', + exists: existsSync + }) +} + +// Why memoized: a state file grows with history and the account list is read on every refresh. +const logins = new Map< + string, + { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } +>() + +/** Whether two logins name one account: the same email, and the same organization when both + * name one, so one email in two organizations stays two accounts. */ +export function isSameClaudeLogin( + left: { email: string; organizationUuid?: string | null }, + right: { email: string; organizationUuid?: string | null } +): boolean { + return ( + left.email.trim().toLowerCase() === right.email.trim().toLowerCase() && + (!left.organizationUuid || + !right.organizationUuid || + left.organizationUuid === right.organizationUuid) + ) +} + +/** The login parsed Claude state names; null before Claude finishes a sign-in. */ +export function claudeStateLogin(state: Record): ClaudeFolderLogin | null { + const parsed = oauthAccount.safeParse(state.oauthAccount) + const email = parsed.success ? parsed.data.emailAddress : null + return parsed.success && email + ? { + email, + organizationUuid: parsed.data.organizationUuid ?? null, + organizationName: parsed.data.organizationName ?? null + } + : null +} + +/** + * The login a Claude state file names; null when signed out (superset U/profiles.ts:121-137). + * `maxAgeMs` reuses a recent answer without a stat, for a file Claude rewrites constantly. + */ +export function readClaudeFolderLogin(stateFile: string, maxAgeMs = 0): ClaudeFolderLogin | null { + const recent = logins.get(stateFile) + if (recent && Date.now() - recent.readAt < maxAgeMs) { + return recent.login + } + let stat: { mtimeMs: number; size: number } + try { + stat = statSync(stateFile) + } catch { + logins.delete(stateFile) + return null + } + if (recent?.mtimeMs === stat.mtimeMs && recent.size === stat.size) { + recent.readAt = Date.now() + return recent.login + } + const read = readClaudeProfileObject(stateFile) + const login = read.kind === 'present' ? claudeStateLogin(read.value) : null + logins.set(stateFile, { mtimeMs: stat.mtimeMs, size: stat.size, readAt: Date.now(), login }) + return login +} + +/** readClaudeFolderLogin without blocking, for a state file across a WSL share. */ +export async function readClaudeFolderLoginAsync( + stateFile: string +): Promise { + const current = await stat(stateFile).catch(() => null) + if (!current) { + logins.delete(stateFile) + return null + } + const recent = logins.get(stateFile) + if (recent?.mtimeMs === current.mtimeMs && recent.size === current.size) { + return recent.login + } + let login: ClaudeFolderLogin | null = null + try { + const state: unknown = JSON.parse(await readFile(stateFile, 'utf8')) + if (state && typeof state === 'object' && !Array.isArray(state)) { + login = claudeStateLogin(Object.fromEntries(Object.entries(state))) + } + } catch { + // Unreadable reads as signed out, as on the host. + } + logins.set(stateFile, { + mtimeMs: current.mtimeMs, + size: current.size, + readAt: Date.now(), + login + }) + return login +} + +/** + * Deletes a host account's folder and, on macOS, the Keychain items Claude keyed to it + * (superset profile-remove.ts). Only `claude-profiles/` is reachable from an id, so an + * older Orca's `claude-accounts/` folders are never touched. + */ +export async function removeClaudeAccountFolder( + dataRoot: string, + accountId: string +): Promise { + const { home } = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + if (process.platform === 'darwin') { + for (const spelling of claudeConfigDirSpellings(home, homedir())) { + // Why a loop: items are filed per Keychain account name, and each delete removes one. + for (let attempt = 0; attempt < 4; attempt += 1) { + const deleted = await execSecurityCommand([ + 'delete-generic-password', + '-s', + claudeKeychainService(spelling) + ]).then( + () => true, + () => false + ) + if (!deleted) { + break + } + } + } + } + const folder = dirname(home) + unlinkLinks(folder) + rmSync(folder, { recursive: true, force: true }) +} + +// Why first: history is linked into ~/.claude, and a recursive delete that followed a Windows +// junction would delete the user's own history. +function unlinkLinks(dir: string): void { + let names: string[] + try { + names = readdirSync(dir) + } catch { + return + } + for (const name of names) { + const path = join(dir, name) + // Why no throw: an entry may vanish between the listing and the stat. + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (!stat) { + continue + } + if (stat.isSymbolicLink()) { + try { + unlinkSync(path) + } catch { + // A Windows directory link is removed as a directory. + rmdirSync(path) + } + } else if (stat.isDirectory()) { + unlinkLinks(path) + } + } +} diff --git a/src/main/claude-accounts/claude-account-registration.ts b/src/main/claude-accounts/claude-account-registration.ts index bebc26be6804..13ec68d7dee8 100644 --- a/src/main/claude-accounts/claude-account-registration.ts +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -1,250 +1,152 @@ import { randomUUID } from 'node:crypto' import type { - ClaudeManagedAccount, - ClaudeRateLimitAccountsState + ClaudeAccountSignIn, + ClaudeRateLimitAccountsState, + ClaudeSignInRequest } from '../../shared/managed-account-types' -import type { Store } from '../persistence' -import type { RateLimitService } from '../rate-limits/service' -import { findDuplicateClaudeAccount } from './claude-duplicate-account' -import type { CapturedClaudeAuth } from './claude-auth-capture' +import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' import type { - ClaudeManagedAuthLocation, - ClaudeManagedAuthSnapshot, - ClaudeManagedAuthTarget -} from './claude-managed-auth-storage' -import type { ClaudeRuntimeAuthService } from './runtime-auth-service' + ClaudeAccountRuntime, + ClaudeAccountSelection, + ClaudeAccountStore, + ClaudeAccountUsage +} from './claude-account-selection' +import type { ClaudeCommandConfig } from './claude-command-process' +import { findDuplicateClaudeAccount } from './claude-duplicate-account' import { getClaudeSelectionTargetForAccount, - normalizeClaudeRuntimeSelection + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget } from './runtime-selection' -import type { ClaudeAccountSelection } from './claude-account-selection' -type ClaudeAccountRegistrationDependencies = { - store: Store - rateLimits: RateLimitService - runtimeAuth: ClaudeRuntimeAuthService - selection: ClaudeAccountSelection - createManagedAuth: ( - accountId: string, - target?: ClaudeManagedAuthTarget - ) => Promise - assertManagedAuth: (path: string, accountId: string) => Promise - removeManagedAuth: (accountId: string, path: string) => Promise - writeManagedAuth: (accountId: string, path: string, captured: CapturedClaudeAuth) => Promise - writeCredentials: (accountId: string, path: string, value: string) => Promise - writeOauth: (accountId: string, path: string, value: unknown) => Promise - readSnapshot: (accountId: string, path: string) => Promise - restoreCredentials: ( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot - ) => Promise - restoreOauth: ( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot - ) => Promise - login: (location: ClaudeManagedAuthLocation) => Promise - captureExisting: ( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ) => Promise -} - -class DuplicateClaudeAccountError extends Error {} +export const CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE = + 'Claude has not finished signing in to this account yet. Finish the sign-in in the terminal, then try again.' +const CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE = + 'Claude login completed, but Orca could not resolve the account email.' +/** + * Sign-in runs `claude auth login` against the account's own folder: hidden from Settings, in + * the user's terminal from the CLI. A folder becomes an account only once it holds a login + * (superset U/profiles.ts:132,178), so an abandoned sign-in never shows as a row. + */ export class ClaudeAccountRegistration { - constructor(private readonly dependencies: ClaudeAccountRegistrationDependencies) {} - - async add(target?: ClaudeManagedAuthTarget): Promise { - const accountId = randomUUID() - const location = await this.dependencies.createManagedAuth(accountId, target) - const previousSettings = this.dependencies.store.getSettings() - try { - const captured = await this.dependencies.login(location) - return await this.persist(accountId, location, previousSettings, captured) - } catch (error) { - await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) - throw error + constructor( + private readonly deps: { + store: ClaudeAccountStore + rateLimits: ClaudeAccountUsage + runtimeAuth: ClaudeAccountRuntime + selection: ClaudeAccountSelection } + ) {} + + /** A new folder, or a saved account's own folder when `accountId` is given. */ + async begin(request: ClaudeSignInRequest): Promise { + const { accountId, target, folder } = await this.prepare(request) + return { accountId, configDir: folder.configDir, ...target } } - async addFromConfigDir( - configDir: string, - options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null } + /** Runs `login` into the account's folder, then saves it; a failed new folder is deleted. */ + async signIn( + request: ClaudeSignInRequest, + login: (folder: ClaudeCommandConfig) => Promise ): Promise { - const accountId = randomUUID() - const location = await this.dependencies.createManagedAuth(accountId, options) - const previousSettings = this.dependencies.store.getSettings() + const { accountId, target, folder } = await this.prepare(request) + const signIn = { accountId, ...target } try { - const captured = await this.dependencies.captureExisting( - configDir, - options?.previousLegacyCredentialsSha256 - ) - return await this.persist(accountId, location, previousSettings, captured) + await login({ + windowsPath: folder.readPath, + linuxPath: target.runtime === 'wsl' ? folder.configDir : null, + wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null + }) + return await this.finish(signIn, CLAUDE_LOGIN_WITHOUT_ACCOUNT_MESSAGE) } catch (error) { - await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) + // Why caught: the sign-in's own failure is what the user needs to see. + await this.cancel(signIn).catch((cleanupError: unknown) => + console.warn( + '[claude-accounts] Could not delete an unfinished sign-in folder:', + cleanupError + ) + ) throw error } } - async reauthenticate(accountId: string): Promise { - const account = this.dependencies.selection.requireAccount(accountId) - const managedAuthPath = await this.dependencies.assertManagedAuth( - account.managedAuthPath, - accountId - ) - const previousSettings = this.dependencies.store.getSettings() - const previousAuth = await this.dependencies.readSnapshot(accountId, managedAuthPath) - const captured = await this.dependencies.login({ - managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - wslLinuxAuthPath: account.wslLinuxAuthPath ?? null - }) - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } + private async prepare(request: ClaudeSignInRequest) { + const saved = request.accountId ? this.deps.selection.requireAccount(request.accountId) : null + const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(request) + const accountId = saved?.id ?? randomUUID() + const folder = await this.deps.runtimeAuth.prepareAccountFolder(accountId, target) + return { accountId, target, folder } + } - const settings = this.dependencies.store.getSettings() - const now = Date.now() - const nextAccounts = settings.claudeManagedAccounts.map((entry) => - entry.id === accountId - ? { - ...entry, - email: captured.identity.email!, - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - updatedAt: now, - lastAuthenticatedAt: now - } - : entry - ) - let wroteCredentials = false - try { - await this.dependencies.writeOauth(accountId, managedAuthPath, captured.oauthAccount) - await this.dependencies.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) - wroteCredentials = true - this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) - const target = getClaudeSelectionTargetForAccount(account) - await this.dependencies.selection.syncRuntimeAuth(target) - await this.dependencies.rateLimits.refreshForClaudeAccountChange(undefined, target) - return this.dependencies.selection.snapshot() - } catch (error) { - await this.rollbackReauthentication( - accountId, - managedAuthPath, - previousAuth, - previousSettings, - nextAccounts, - wroteCredentials - ) - throw error + async cancel(signIn: Omit): Promise { + if (!this.deps.selection.findAccount(signIn.accountId)) { + await this.deps.runtimeAuth.removeAccountFolder(signIn.accountId, signInTarget(signIn)) } } - private async persist( - accountId: string, - location: ClaudeManagedAuthLocation, - previousSettings: ReturnType, - captured: CapturedClaudeAuth + async finish( + signIn: Omit, + notSignedInMessage = CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE ): Promise { - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') + const { store, rateLimits, runtimeAuth, selection } = this.deps + const saved = selection.findAccount(signIn.accountId) + const target = saved ? getClaudeSelectionTargetForAccount(saved) : signInTarget(signIn) + // Why set up again: onboarding is merged only into a state file the login just created. + const folder = await runtimeAuth.prepareAccountFolder(signIn.accountId, target) + const login = readClaudeFolderLogin(claudeStateFile(folder.readPath)) + if (!login) { + throw new Error(notSignedInMessage) } + const accounts = store.getSettings().claudeManagedAccounts + const scope = { + managedAuthRuntime: target.runtime ?? 'host', + wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null + } as const if ( - findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { - email: captured.identity.email, - organizationUuid: captured.identity.organizationUuid, - managedAuthRuntime: location.managedAuthRuntime, - wslDistro: location.wslDistro + !saved && + findDuplicateClaudeAccount(accounts, { + email: login.email, + organizationUuid: login.organizationUuid, + ...scope }) ) { - throw new DuplicateClaudeAccountError('This Claude account is already added.') + await runtimeAuth.removeAccountFolder(signIn.accountId, target) + throw new Error('This Claude account is already added.') } - await this.dependencies.writeManagedAuth(accountId, location.managedAuthPath, captured) const now = Date.now() - const account: ClaudeManagedAccount = { - id: accountId, - email: captured.identity.email, - managedAuthPath: location.managedAuthPath, - managedAuthRuntime: location.managedAuthRuntime, - wslDistro: location.wslDistro, - wslLinuxAuthPath: location.wslLinuxAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - const selection = normalizeClaudeRuntimeSelection(previousSettings) - this.dependencies.store.updateSettings({ - claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: selection.host, - activeClaudeManagedAccountIdsByRuntime: selection + selection.saveSettings({ + claudeManagedAccounts: [ + ...accounts.filter((entry) => entry.id !== signIn.accountId), + { + id: signIn.accountId, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName, + managedAuthPath: folder.readPath, + ...scope, + wslLinuxAuthPath: target.runtime === 'wsl' ? folder.configDir : null, + authMethod: 'subscription-oauth', + createdAt: saved?.createdAt ?? now, + updatedAt: now, + lastAuthenticatedAt: now + } + ] }) - this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) - return this.dependencies.selection.snapshot() - } - - private async cleanupFailedAdd( - accountId: string, - managedAuthPath: string, - previousSettings: ReturnType, - error: unknown - ): Promise { - if (error instanceof DuplicateClaudeAccountError) { - await this.dependencies.removeManagedAuth(accountId, managedAuthPath) - return - } - this.dependencies.selection.restoreSettings(previousSettings) - try { - await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } catch (rollbackError) { - console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) - } - await this.dependencies.removeManagedAuth(accountId, managedAuthPath) + rateLimits.evictInactiveClaudeCache(signIn.accountId) + // Why publish: a selected account's terminals follow its folder only once it exists. + await runtimeAuth.syncForCurrentSelection(target) + selection.refreshUsage(undefined, target) + return selection.list() } +} - private async rollbackReauthentication( - accountId: string, - path: string, - snapshot: ClaudeManagedAuthSnapshot, - previousSettings: ReturnType, - nextAccounts: ClaudeManagedAccount[], - wroteCredentials: boolean - ): Promise { - let restoredCredentials = false - try { - await this.dependencies.restoreCredentials(accountId, path, snapshot) - restoredCredentials = true - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed credentials during rollback:', - rollbackError - ) - } - if (restoredCredentials || !wroteCredentials) { - try { - await this.dependencies.restoreOauth(accountId, path, snapshot) - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed oauth metadata during rollback:', - rollbackError - ) - } - } - if (restoredCredentials) { - this.dependencies.selection.restoreSettings(previousSettings) - await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } else if (wroteCredentials) { - this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.dependencies.selection.restoreSettings(previousSettings) - } +function signInTarget(request: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { + const target = normalizeClaudeAccountSelectionTarget(request) + if (target.runtime === 'wsl' && !target.wslDistro) { + throw new Error('Choose a WSL distro before signing in.') } + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } } diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index d81f2346eb70..b26010420781 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -3,9 +3,10 @@ import type { ClaudeManagedAccountSummary, ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { Store } from '../persistence' +import type { GlobalSettings } from '../../shared/global-settings-types' import type { RateLimitService } from '../rate-limits/service' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' +import { savedWslClaudeAccountHome } from './claude-profile-wsl-paths' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -18,59 +19,141 @@ import { type ClaudeAccountSelectionTarget } from './runtime-selection' +export const CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE = + 'Sign in to this Claude account again before selecting it.' + +export const CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE = + "Orca couldn't delete this Claude account's folder. Quit any Claude running in this account, then remove it again." + +export type ClaudeAccountSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +export type ClaudeAccountStore = { + getSettings: () => ClaudeAccountSettings + updateSettings: ( + patch: Partial, + options?: { notifyListeners?: boolean } + ) => unknown +} + +export type ClaudeAccountRuntime = Pick< + ClaudeRuntimeAuthService, + 'syncForCurrentSelection' | 'publishAll' | 'removeAccountFolder' | 'prepareAccountFolder' +> & { + router: Pick< + ClaudeRuntimeAuthService['router'], + 'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' | 'coveredBySystemDefault' + > +} + +export type ClaudeAccountUsage = Pick< + RateLimitService, + 'evictInactiveClaudeCache' | 'refreshForClaudeAccountChange' +> + export class ClaudeAccountSelection { constructor( - private readonly store: Store, - private readonly rateLimits: RateLimitService, - private readonly runtimeAuth: ClaudeRuntimeAuthService, - private readonly removeManagedAuth: (accountId: string, path: string) => Promise + private readonly store: ClaudeAccountStore, + private readonly rateLimits: ClaudeAccountUsage, + private readonly runtimeAuth: ClaudeAccountRuntime ) {} + /** Each row is labelled with the login its folder holds (superset U/profiles.ts:121-137). */ list(): ClaudeRateLimitAccountsState { - this.normalizeActiveSelection() - return this.snapshot() + this.pruneSelection() + const settings = this.store.getSettings() + const selection = normalizeClaudeRuntimeSelection(settings) + const accounts = settings.claudeManagedAccounts + .map((account) => this.summarize(account)) + .sort((a, b) => b.updatedAt - a.updatedAt) + const userConfigDir = this.runtimeAuth.router.userConfigDir() + // Why only with accounts, and at most every 5 s: the personal state file is large and every + // running Claude rewrites it, so a list would otherwise re-parse it on main each time. + const systemDefault = + accounts.length > 0 ? readClaudeFolderLogin(claudeStateFile(userConfigDir), 5_000) : null + return { + accounts, + activeAccountId: selection.host, + activeAccountIdsByRuntime: selection, + ...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}), + ...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault() + ? { systemDefaultMayBeCopied: true } + : {}) + } + } + + private summarize(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { + const summary: ClaudeManagedAccountSummary = { + id: account.id, + email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + authMethod: account.authMethod ?? 'unknown', + organizationUuid: account.organizationUuid ?? null, + organizationName: account.organizationName ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } + if (account.managedAuthRuntime === 'wsl') { + // Why no read: listing must not start a stopped distro. Only an older Orca's folder layout + // is known to hold no login; a launch refuses a missing folder with its own reason. + return savedWslClaudeAccountHome(account) ? summary : { ...summary, needsSignIn: true } + } + const login = readClaudeFolderLogin( + claudeStateFile(this.runtimeAuth.router.accountHome(account.id)) + ) + if (login) { + return { + ...summary, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName + } + } + // Why: an account System default is signed in to runs there until it signs in on its own. + return this.runtimeAuth.router.coveredBySystemDefault(account.id) + ? summary + : { ...summary, needsSignIn: true } } async remove(accountId: string): Promise { const account = this.requireAccount(accountId) const settings = this.store.getSettings() - const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const target = getClaudeSelectionTargetForAccount(account) + const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId const nextSelection = removeClaudeAccountIdFromSelection( normalizeClaudeRuntimeSelection(settings), accountId ) - const nextActiveId = - settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host - const target = getClaudeSelectionTargetForAccount(account) - const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId + this.saveSettings({ + activeClaudeManagedAccountId: + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + // Why before the delete: no new launch may pick the folder while it is being removed. + await this.runtimeAuth.syncForCurrentSelection(target) try { - if (wasSelected) { - this.store.updateSettings({ - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuth(target) - this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.store.updateSettings({ - claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuth(target) - } - await this.removeManagedAuth(accountId, account.managedAuthPath) - this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange( - wasSelected ? accountId : undefined, - target - ) - return this.snapshot() + await this.runtimeAuth.removeAccountFolder(accountId, target) } catch (error) { - this.restoreSettings(settings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - throw error + // Why keep the row: the folder (on Windows, with its history) is still there to retry. + console.warn('[claude-accounts] Could not delete the account folder:', error) + throw new Error(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) } + this.saveSettings({ + claudeManagedAccounts: this.store + .getSettings() + .claudeManagedAccounts.filter((entry) => entry.id !== accountId) + }) + // Why again: the last account's removal also removes the which-account file. + await this.runtimeAuth.syncForCurrentSelection(target) + this.rateLimits.evictInactiveClaudeCache(accountId) + this.refreshUsage(wasSelected ? accountId : undefined, target) + return this.list() } async select( @@ -81,111 +164,90 @@ export class ClaudeAccountSelection { if (accountId !== null) { const account = this.requireAccount(accountId) const accountTarget = getClaudeSelectionTargetForAccount(account) - const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) - const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + const requested = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const owned = normalizeClaudeAccountSelectionTarget(accountTarget) if ( - requestedTarget.runtime !== normalizedAccountTarget.runtime || - (requestedTarget.wslDistro !== null && - requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + requested.runtime !== owned.runtime || + (requested.wslDistro !== null && requested.wslDistro !== owned.wslDistro) ) { throw new Error('That Claude account belongs to a different runtime.') } + // Why on the host: the CLI and paired clients select without the renderer's check. + if (this.summarize(account).needsSignIn) { + throw new Error(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) + } effectiveTarget = accountTarget } - const previousSettings = this.store.getSettings() - const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const previous = this.store.getSettings() + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previous, effectiveTarget) const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(previousSettings), + normalizeClaudeRuntimeSelection(previous), accountId, effectiveTarget ) - this.store.updateSettings({ + this.saveSettings({ activeClaudeManagedAccountId: effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, activeClaudeManagedAccountIdsByRuntime: nextSelection }) try { - await this.syncRuntimeAuth(effectiveTarget) - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) - return this.snapshot() + await this.runtimeAuth.syncForCurrentSelection(effectiveTarget) } catch (error) { - this.restoreSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + this.saveSettings({ + activeClaudeManagedAccountId: previous.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: previous.activeClaudeManagedAccountIdsByRuntime + }) + await this.runtimeAuth.publishAll().catch((rollbackError: unknown) => { + console.warn('[claude-accounts] Rollback republish failed:', rollbackError) + }) throw error } + this.refreshUsage(outgoingAccountId, effectiveTarget) + return this.list() } - snapshot(): ClaudeRateLimitAccountsState { - const settings = this.store.getSettings() - return { - accounts: settings.claudeManagedAccounts - .map(toClaudeAccountSummary) - .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: normalizeClaudeRuntimeSelection(settings).host, - activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) - } + /** Bookkeeping: a usage failure never undoes the account change that triggered it. */ + refreshUsage( + outgoingAccountId: string | null | undefined, + target: ClaudeAccountSelectionTarget | undefined + ): void { + void this.rateLimits + .refreshForClaudeAccountChange(outgoingAccountId ?? undefined, target) + .catch((error: unknown) => + console.warn('[claude-accounts] Usage unavailable after account change:', error) + ) + } + + findAccount(accountId: string): ClaudeManagedAccount | undefined { + return this.store.getSettings().claudeManagedAccounts.find((entry) => entry.id === accountId) } requireAccount(accountId: string): ClaudeManagedAccount { - const account = this.store - .getSettings() - .claudeManagedAccounts.find((entry) => entry.id === accountId) + const account = this.findAccount(accountId) if (!account) { throw new Error('That Claude account no longer exists.') } return account } - restoreSettings(settings: ReturnType): void { - this.store.updateSettings({ - claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime - }) + // Why notify: every window's switcher and Settings read the selection from settings, including + // selections made by the CLI, a paired client or another window. + saveSettings(patch: Partial): void { + this.store.updateSettings(patch, { notifyListeners: true }) } - async syncRuntimeAuth( - target?: ClaudeAccountSelectionTarget, - operation?: () => Promise - ): Promise { - beginClaudeAuthSwitch() - try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) - } finally { - endClaudeAuthSwitch() - } - } - - private normalizeActiveSelection(): void { + private pruneSelection(): void { const settings = this.store.getSettings() - const currentSelection = normalizeClaudeRuntimeSelection(settings) - const nextSelection = pruneInvalidClaudeRuntimeSelection( - currentSelection, - settings.claudeManagedAccounts - ) + const current = normalizeClaudeRuntimeSelection(settings) + const next = pruneInvalidClaudeRuntimeSelection(current, settings.claudeManagedAccounts) if ( - nextSelection.host !== settings.activeClaudeManagedAccountId || - JSON.stringify(nextSelection) !== JSON.stringify(currentSelection) + next.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(next) !== JSON.stringify(current) ) { this.store.updateSettings({ - activeClaudeManagedAccountId: nextSelection.host, - activeClaudeManagedAccountIdsByRuntime: nextSelection + activeClaudeManagedAccountId: next.host, + activeClaudeManagedAccountIdsByRuntime: next }) } } } - -function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { - return { - id: account.id, - email: account.email, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - authMethod: account.authMethod ?? 'unknown', - organizationUuid: account.organizationUuid ?? null, - organizationName: account.organizationName ?? null, - createdAt: account.createdAt, - updatedAt: account.updatedAt, - lastAuthenticatedAt: account.lastAuthenticatedAt - } -} diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts deleted file mode 100644 index f055475506bc..000000000000 --- a/src/main/claude-accounts/claude-account-service-account-selection.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-selection-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('refreshes rate limits without recaching a removed active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.removeAccount('account-1') - - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { - runtime: 'host' - }) - expect(settings).toMatchObject({ - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null - }) - }) - - it('switches the active Claude account while PTYs are live', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(firstAuthPath, { recursive: true }) - mkdirSync(secondAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedAuthPath: firstAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedAuthPath: secondAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeClaudeManagedAccountId: 'account-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - markClaudePtySpawned('live-claude-pty') - try { - await service.selectAccount('account-2') - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(settings.activeClaudeManagedAccountId).toBe('account-2') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'account-2', - wsl: {} - }) - expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { - runtime: 'host' - }) - }) - - it('restores the previous selection when a Claude account switch fails', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(firstAuthPath, { recursive: true }) - mkdirSync(secondAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedAuthPath: firstAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedAuthPath: secondAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeClaudeManagedAccountId: 'account-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => { - throw new Error('runtime sync failed') - }), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed') - - expect(settings.activeClaudeManagedAccountId).toBe('account-1') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'account-1', - wsl: {} - }) - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - }) - - it('selects a WSL account without changing the Windows active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - mkdirSync(wslAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const snapshot = await service.selectAccountForTarget('wsl-account', { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - }) - expect(snapshot.activeAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - }) - expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - }) - - it('rejects selecting a WSL account for the Windows target', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(wslAuthPath, { recursive: true }) - const settings = { - claudeManagedAccounts: [ - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn() - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await expect( - service.selectAccountForTarget('wsl-account', { runtime: 'host' }) - ).rejects.toThrow('different runtime') - expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - }) - - it('removes a WSL account without clearing the Windows active account', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - mkdirSync(wslAuthPath, { recursive: true }) - writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'wsl-account', - email: 'wsl@example.com', - managedAuthPath: wslAuthPath, - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account', - wsl: { Ubuntu: 'wsl-account' } - } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.removeAccount('wsl-account') - - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: null } - }) - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-add-account.test.ts b/src/main/claude-accounts/claude-account-service-add-account.test.ts deleted file mode 100644 index 3e17f4c00ebb..000000000000 --- a/src/main/claude-accounts/claude-account-service-add-account.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-add-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('adds an account without switching the active Claude auth while PTYs are live', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - markClaudePtySpawned('live-claude-pty') - try { - await service.addAccount({ runtime: 'host' }) - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(settings.claudeManagedAccounts).toHaveLength(2) - expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') - expect(settings.activeClaudeManagedAccountId).toBe('host-account') - expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ - host: 'host-account', - wsl: { Ubuntu: null } - }) - expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() - expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith( - settings.claudeManagedAccounts[1].id - ) - }) - - it('reports the original add failure and still removes managed auth when rollback rematerialization fails', async () => { - // Why: this is the desktop add path. Previously the rollback's rematerialize - // was unguarded, so when it threw it replaced the real add error and skipped - // safeRemoveManagedAuth, leaking the throwaway auth dir. - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') - mkdirSync(hostAuthPath, { recursive: true }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let settings = { - claudeManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedAuthPath: hostAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'host-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { - throw new Error('rematerialize failed') - }) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: null, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - ;(service as unknown as { writeManagedAuth(): Promise }).writeManagedAuth = vi.fn( - async () => { - throw new Error('managed auth write failed') - } - ) - - await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'managed auth write failed' - ) - - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(settings.claudeManagedAccounts).toHaveLength(1) - // Why: the throwaway account directory must be gone even though rollback threw. - expect(readdirSync(join(tempDir, 'claude-accounts'))).toEqual(['host-account']) - warn.mockRestore() - }) - - it('rejects adding a Claude account whose identity already exists', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') - mkdirSync(existingAuthPath, { recursive: true }) - const existingMarkerPath = join(existingAuthPath, '.orca-managed-claude-auth') - writeFileSync(existingMarkerPath, 'existing-account\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'existing-account', - email: 'new@example.com', - managedAuthPath: existingAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'existing-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: string | null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'This Claude account is already added.' - ) - - expect(settings.claudeManagedAccounts).toHaveLength(1) - expect(readFileSync(existingMarkerPath, 'utf-8')).toBe('existing-account\n') - // The guard fires before credentials/settings change, so rollback I/O - // would only add latency and could mask the duplicate error. - expect(store.updateSettings).not.toHaveBeenCalled() - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - // The rejected add's throwaway managed-auth dir must be cleaned up, leaving - // only the pre-existing account's dir behind. - expect(readdirSync(join(tempDir, 'claude-accounts')).sort()).toEqual(['existing-account']) - }) - - it('adds a Claude account with the same email under a different organization', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') - mkdirSync(existingAuthPath, { recursive: true }) - writeFileSync( - join(existingAuthPath, '.orca-managed-claude-auth'), - 'existing-account\n', - 'utf-8' - ) - let settings = { - claudeManagedAccounts: [ - { - id: 'existing-account', - email: 'new@example.com', - managedAuthPath: existingAuthPath, - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null, - authMethod: 'subscription-oauth', - organizationUuid: 'org-A', - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'existing-account', - activeClaudeManagedAccountIdsByRuntime: { host: 'existing-account', wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: string | null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: 'org-B', organizationName: null } - })) - - await service.addAccount({ runtime: 'host' }) - - expect(settings.claudeManagedAccounts).toHaveLength(2) - expect(settings.claudeManagedAccounts[1].email).toBe('new@example.com') - expect(settings.claudeManagedAccounts[1].organizationUuid).toBe('org-B') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts deleted file mode 100644 index 09ec7813ffe8..000000000000 --- a/src/main/claude-accounts/claude-account-service-api-parity.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import { ClaudeAccountService } from './service' -import type { ClaudeAccountAddTarget } from './service' - -vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) - -function createService(): ClaudeAccountService { - const settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - return new ClaudeAccountService( - { getSettings: () => settings, updateSettings: vi.fn() } as never, - {} as never, - { getRuntimeConfigDir: () => '/tmp/claude' } as never - ) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - return { promise: new Promise((done) => (resolve = done)), resolve } -} - -describe('ClaudeAccountService mutation serialization', () => { - it('serializes mutations within one service', async () => { - const service = createService() - const first = deferred() - const calls: string[] = [] - const registration = ( - service as unknown as { - registration: { - add(target?: ClaudeAccountAddTarget): Promise - } - } - ).registration - registration.add = vi.fn(async (target) => { - calls.push(target?.wslDistro ?? 'host') - if (calls.length === 1) { - await first.promise - } - return { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - }) - - const firstAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'one' }) - const secondAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'two' }) - await vi.waitFor(() => expect(calls).toEqual(['one'])) - first.resolve() - await Promise.all([firstAdd, secondAdd]) - expect(calls).toEqual(['one', 'two']) - }) - - it('isolates mutation and cancellation state between runtime hosts', async () => { - const firstService = createService() - const secondService = createService() - const first = deferred() - const firstAdd = vi.fn(async () => { - await first.promise - return { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - }) - const secondAdd = vi.fn(async () => ({ - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - })) - ;(firstService as unknown as { registration: { add: typeof firstAdd } }).registration.add = - firstAdd - ;(secondService as unknown as { registration: { add: typeof secondAdd } }).registration.add = - secondAdd - ;( - firstService as unknown as { cancelPendingClaudeLogin: () => boolean } - ).cancelPendingClaudeLogin = vi.fn(() => true) - - const pendingFirst = firstService.addAccount() - await expect(secondService.addAccount()).resolves.toMatchObject({ accounts: [] }) - expect(firstService.cancelPendingLogin()).toBe(true) - expect(secondService.cancelPendingLogin()).toBe(false) - first.resolve() - await pendingFirst - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts deleted file mode 100644 index 8ed94bebc6eb..000000000000 --- a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createHash } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { - readActiveClaudeKeychainCredentialsStrict, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { restorePlatform, setPlatform } from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-config-dir-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService.addAccountFromConfigDir', () => { - const managedRoot = CLAUDE_SERVICE_TEST_ROOT - let sourceDir: string | null = null - - beforeEach(() => { - setPlatform('linux') - rmSync(managedRoot, { recursive: true, force: true }) - sourceDir = null - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() - vi.mocked(writeManagedClaudeKeychainCredentials).mockReset().mockResolvedValue() - }) - - afterEach(() => { - restorePlatform() - rmSync(managedRoot, { recursive: true, force: true }) - if (sourceDir) { - rmSync(sourceDir, { recursive: true, force: true }) - } - }) - - function makeDeps() { - let settings = { - claudeManagedAccounts: [] as ClaudeManagedAccount[], - activeClaudeManagedAccountId: null as string | null, - activeClaudeManagedAccountIdsByRuntime: { host: null as string | null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn() } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - return { store, rateLimits, runtimeAuth, getSettings: () => settings } - } - - it('registers a managed account by capturing an authenticated config dir', async () => { - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-')) - writeFileSync( - join(sourceDir, '.credentials.json'), - '{"claudeAiOauth":{"accessToken":"tok"}}\n', - 'utf-8' - ) - writeFileSync( - join(sourceDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), - 'utf-8' - ) - - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - // Why: avoid spawning a real `claude auth status` subprocess in the test. - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - const result = await service.addAccountFromConfigDir(sourceDir) - - const accounts = deps.getSettings().claudeManagedAccounts - expect(accounts).toHaveLength(1) - expect(accounts[0].email).toBe('new@example.com') - expect(result.accounts[0]?.email).toBe('new@example.com') - expect(readFileSync(join(accounts[0].managedAuthPath, '.credentials.json'), 'utf-8')).toBe( - '{"claudeAiOauth":{"accessToken":"tok"}}\n' - ) - expect(deps.runtimeAuth.clearLastWrittenCredentialsJson).toHaveBeenCalledWith(accounts[0].id) - }) - - it('captures only the config-scoped macOS Keychain credential', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-')) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? '{"claudeAiOauth":{"accessToken":"scoped"}}' : 'legacy-credentials' - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - await service.addAccountFromConfigDir(sourceDir) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(sourceDir) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - expect.any(String), - '{"claudeAiOauth":{"accessToken":"scoped"}}' - ) - }) - - it('does not mistake an unchanged legacy Keychain credential for the temp login', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-empty-')) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? null : 'legacy-credentials' - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"existing@example.com"}' - ) - - await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( - 'no OAuth credentials were captured' - ) - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) - }) - - it('captures a legacy Keychain credential that changed after login began', async () => { - setPlatform('darwin') - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-legacy-')) - const previousCredentials = '{"claudeAiOauth":{"accessToken":"previous"}}' - const newCredentials = '{"claudeAiOauth":{"accessToken":"new","email":"new@example.com"}}' - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => - configDir ? null : newCredentials - ) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => '{"email":"new@example.com"}' - ) - - await service.addAccountFromConfigDir(sourceDir, { - previousLegacyCredentialsSha256: createHash('sha256') - .update(previousCredentials) - .digest('hex') - }) - - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - expect.any(String), - newCredentials - ) - expect(deps.getSettings().claudeManagedAccounts[0]?.email).toBe('new@example.com') - }) - - it('still registers when the daemon cannot spawn `claude auth status`', async () => { - // Why: `allowFailure` covers a non-zero exit but not a spawn error, so a daemon - // started with a minimal PATH would hard-fail an add the user already signed in for. - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-nostatus-')) - writeFileSync( - join(sourceDir, '.credentials.json'), - '{"claudeAiOauth":{"accessToken":"tok"}}\n', - 'utf-8' - ) - writeFileSync( - join(sourceDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), - 'utf-8' - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( - async () => { - throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) - } - ) - - const result = await service.addAccountFromConfigDir(sourceDir) - - expect(result.accounts[0]?.email).toBe('new@example.com') - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(1) - warn.mockRestore() - }) - - it('rejects and rolls back when the config dir has no credentials', async () => { - sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-empty-')) - const deps = makeDeps() - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - deps.store as never, - deps.rateLimits as never, - deps.runtimeAuth as never - ) - - await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( - /No Claude credentials found/ - ) - expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) - expect(deps.runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts b/src/main/claude-accounts/claude-account-service-credential-capture.test.ts deleted file mode 100644 index 641ef58b5bee..000000000000 --- a/src/main/claude-accounts/claude-account-service-credential-capture.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from './keychain' -import { - createService, - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-capture-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -async function readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null -): Promise { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - return ( - service as unknown as { - readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null - ): Promise - } - ).readCapturedCredentials(configDir, previousLegacyKeychain) -} - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('accepts scoped Keychain capture even when it matches the previous legacy item', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce('same-account') - .mockResolvedValueOnce('same-account') - - await expect(readCapturedCredentials('/tmp/claude-config', 'same-account')).resolves.toBe( - 'same-account' - ) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith('/tmp/claude-config') - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - }) - - it('rejects unchanged legacy fallback when scoped capture is missing', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('previous') - - await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBeNull() - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( - 1, - '/tmp/claude-config' - ) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) - }) - - it('accepts changed legacy fallback for old Claude Code builds', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('new-legacy') - - await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBe( - 'new-legacy' - ) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( - 1, - '/tmp/claude-config' - ) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) - }) - - it('falls back to captured credentials file on macOS', async () => { - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-capture-')) - writeFileSync(join(tempDir, '.credentials.json'), '{"token":"file"}\n', 'utf-8') - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('previous') - - await expect(readCapturedCredentials(tempDir, 'previous')).resolves.toBe('{"token":"file"}\n') - }) - - it('fails login capture when legacy Keychain cleanup fails', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue('previous-legacy') - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue('captured-scoped') - vi.mocked(writeActiveClaudeKeychainCredentials).mockRejectedValue(new Error('restore failed')) - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const testService = service as unknown as { - runClaudeCommand: () => Promise - runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }> - } - testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}') - - await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts deleted file mode 100644 index 8aee047e5fa5..000000000000 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough } from 'node:stream' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { readActiveClaudeKeychainCredentials } from './keychain' -import { getCmdExePath } from '../../shared/windows-batch-spawn' -import { - createService, - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-login-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the doubles below implement every member the add-account login path reaches; this case drives the service only through addAccount. -const asServiceDouble = (double: unknown): T => double as T - -describe('ClaudeAccountService credential capture', () => { - beforeEach(() => { - setPlatform('darwin') - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - }) - - it('removes command listeners when Claude sign-in times out', async () => { - vi.resetModules() - vi.useFakeTimers() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: () => void - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const commandPromise = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { keepStdinOpen?: boolean } - ): Promise - } - ).runClaudeCommand( - ['login'], - { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, - 1000, - { keepStdinOpen: true } - ) - const rejection = expect(commandPromise).rejects.toThrow( - 'Claude sign-in took too long to finish.' - ) - - await vi.advanceTimersByTimeAsync(1000) - - await rejection - expect(child.kill).toHaveBeenCalledTimes(1) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - vi.doUnmock('node:child_process') - } - }) - - it('owns the complete cmd.exe command line for a resolved Windows Claude command', async () => { - setPlatform('win32') - vi.resetModules() - commandMocks.resolveClaudeCommand.mockReturnValueOnce( - 'C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd' - ) - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const spawnMock = vi.fn(() => { - child.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => child.emit('close', 0)) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'status', '--json'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - - expect(spawnMock).toHaveBeenCalledWith( - process.env.ComSpec ?? 'cmd.exe', - [ - '/d', - '/v:off', - '/s', - '/c', - '""C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd" "auth" "status" "--json""' - ], - expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) - ) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('keeps WSL execution separate from Windows command resolution', async () => { - setPlatform('win32') - vi.resetModules() - commandMocks.resolveClaudeCommand.mockClear() - const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const spawnMock = vi.fn(() => { - child.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => child.emit('close', 0)) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'status', '--json'], - { - windowsPath: 'C:\\tmp\\claude-auth', - linuxPath: '/home/user/.config/orca auth', - wslDistro: 'Ubuntu Test' - }, - 1000 - ) - - expect(commandMocks.resolveClaudeCommand).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu Test', - '--exec', - 'bash', - '-lc', - "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; export CLAUDE_SECURESTORAGE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" - ], - expect.objectContaining({ shell: false, windowsVerbatimArguments: false }) - ) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('pipes stdin only for the explicit Claude account login command', async () => { - setPlatform('linux') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const loginChild = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - loginChild.stdin = new PassThrough() - loginChild.stdout = new PassThrough() - loginChild.stderr = new PassThrough() - loginChild.kill = vi.fn() - const statusChild = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - statusChild.stdout = new PassThrough() - statusChild.stderr = new PassThrough() - statusChild.kill = vi.fn() - const spawnMock = vi.fn( - (_command: string, args: string[], options: { env: NodeJS.ProcessEnv }) => { - if (args[1] === 'login') { - writeFileSync( - join(options.env.CLAUDE_CONFIG_DIR!, '.credentials.json'), - '{"claudeAiOauth":{"email":"user@example.com","accessToken":"token"}}\n', - 'utf-8' - ) - queueMicrotask(() => loginChild.emit('close', 0)) - return loginChild - } - statusChild.stdout.write('{"email":"user@example.com"}\n') - queueMicrotask(() => statusChild.emit('close', 0)) - return statusChild - } - ) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [] as ClaudeManagedAccount[], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - await service.addAccount() - - expect(spawnMock).toHaveBeenNthCalledWith( - 1, - 'claude', - ['auth', 'login', '--claudeai'], - expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) - ) - expect(spawnMock).toHaveBeenNthCalledWith( - 2, - 'claude', - ['auth', 'status', '--json'], - expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) - ) - expect(settings.claudeManagedAccounts[0]?.email).toBe('user@example.com') - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('rejects immediately when Claude sign-in is denied', async () => { - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - pid: number - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - child.pid = 4242 - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - // Denial must tear down the whole detached login/browser tree (process-group kill on POSIX), - // not just the direct child — otherwise the orphaned auth processes the `detached` spawn guards against leak. - const killTree = vi.spyOn(process, 'kill').mockReturnValue(true) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const commandPromise = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { keepStdinOpen?: boolean } - ): Promise - } - ).runClaudeCommand( - ['login'], - { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, - 180_000, - { keepStdinOpen: true } - ) - - child.stderr.write('OAuth authorization failed: access_denied\n') - - await expect(commandPromise).rejects.toThrow('Claude sign-in was denied. Please try again.') - expect(killTree).toHaveBeenCalledWith(-child.pid) - expect(child.kill).not.toHaveBeenCalled() - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - killTree.mockRestore() - vi.doUnmock('node:child_process') - } - }) - - it('cancels an in-flight Claude account add', async () => { - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalledTimes(1) - }) - - expect(service.cancelPendingLogin()).toBe(true) - await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(child.kill).toHaveBeenCalledTimes(1) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(service.cancelPendingLogin()).toBe(false) - expect(settings.claudeManagedAccounts).toEqual([]) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('honors cancel before Claude login command starts', async () => { - setPlatform('linux') - vi.resetModules() - let releaseKeychainRead: (value: string | null) => void = () => {} - vi.mocked(readActiveClaudeKeychainCredentials).mockReturnValue( - new Promise((resolve) => { - releaseKeychainRead = resolve - }) - ) - const spawnMock = vi.fn() - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalled() - }) - - expect(service.cancelPendingLogin()).toBe(true) - expect(service.cancelPendingLogin()).toBe(false) - expect(spawnMock).not.toHaveBeenCalled() - releaseKeychainRead(null) - await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(spawnMock).not.toHaveBeenCalled() - expect(service.cancelPendingLogin()).toBe(false) - expect(settings.claudeManagedAccounts).toEqual([]) - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('supersedes the login a closed Settings pane abandoned instead of queueing behind it', async () => { - setPlatform('linux') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const children: (EventEmitter & { stdin: PassThrough; kill: ReturnType })[] = [] - // No pid: the POSIX teardown signals -pid as a process group, which must - // never reach a real group from a test double. - const spawnMock = vi.fn(() => { - const child = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn() - }) - children.push(child) - return child - }) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - asServiceDouble(store), - asServiceDouble(rateLimits), - asServiceDouble(runtimeAuth) - ) - - const abandoned = service.addAccount({ runtime: 'host' }) - const abandonedRejection = expect(abandoned).rejects.toThrow('Claude sign-in was cancelled.') - await vi.waitFor(() => { - expect(children.length).toBe(1) - }) - - // The user reopens Settings and clicks Add Account again. - const retry = service.addAccount({ runtime: 'host' }) - const retryRejection = expect(retry).rejects.toThrow() - - await abandonedRejection - expect(children[0].kill).toHaveBeenCalled() - // Why: the point of the fix — the second login starts now, not after the - // abandoned one's whole sign-in deadline elapses. - await vi.waitFor(() => { - expect(children.length).toBe(2) - }) - - children[1].emit('close', 1) - await retryRejection - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('uses taskkill to cancel the Windows Claude login process tree', async () => { - setPlatform('win32') - vi.resetModules() - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - const child = new EventEmitter() as EventEmitter & { - pid: number - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - } - child.pid = 1234 - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const taskkill = new EventEmitter() - const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) - const cleanupInteractiveLogin = vi.fn() - let publishTerminationPid: (pid: number) => void = () => {} - const buildInteractiveLoginSpawn = vi.fn(() => ({ - command: getCmdExePath(), - args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], - stdio: 'ignore' as const, - windowsHide: true, - cleanup: cleanupInteractiveLogin, - getTerminationPid: () => null, - waitForTerminationPid: () => - new Promise((resolve) => { - publishTerminationPid = resolve - }) - })) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ - buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn - })) - - try { - const { ClaudeAccountService } = await import('./service') - let settings = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn() - } - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - - const addPromise = service.addAccount() - await vi.waitFor(() => { - expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ - 'auth', - 'login', - '--claudeai' - ]) - expect(spawnMock).toHaveBeenCalledWith( - getCmdExePath(), - ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - - expect(service.cancelPendingLogin()).toBe(true) - const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') - expect(child.kill).not.toHaveBeenCalled() - expect(spawnMock).not.toHaveBeenCalledWith( - 'taskkill.exe', - expect.anything(), - expect.anything() - ) - publishTerminationPid(9876) - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalledWith( - 'taskkill.exe', - ['/pid', '9876', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - expect(destroyStdin).not.toHaveBeenCalled() - taskkill.emit('close', 0) - await rejection - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) - expect(service.cancelPendingLogin()).toBe(false) - } finally { - vi.doUnmock('node:child_process') - vi.doUnmock('../../shared/windows-interactive-login-spawn') - } - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts deleted file mode 100644 index 2ad1eb6356d1..000000000000 --- a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { - resetClaudeKeychainMocks, - restorePlatform, - setPlatform -} from './claude-account-service-test-harness' - -const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-reauth-test') - -vi.mock('electron', () => ({ - app: { - getPath: () => CLAUDE_SERVICE_TEST_ROOT - } -})) - -const commandMocks = vi.hoisted(() => ({ - resolveClaudeCommand: vi.fn(() => 'claude') -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: commandMocks.resolveClaudeCommand -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), - deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), - writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) -})) - -describe('ClaudeAccountService credential capture', () => { - let tempDir: string | null = null - - beforeEach(() => { - setPlatform('darwin') - tempDir = null - resetClaudeKeychainMocks() - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('restores previous managed auth when reauth materialization fails', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') - expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( - '{"oldOauth":true}\n' - ) - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - }) - - it('restores settings without rematerializing when managed-auth rollback write fails', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') - vi.mocked(writeManagedClaudeKeychainCredentials) - .mockResolvedValueOnce() - .mockRejectedValueOnce(new Error('managed restore failed')) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('new@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[claude-accounts] Failed to restore managed credentials during rollback:', - expect.any(Error) - ) - warn.mockRestore() - }) - - it('restores oauth metadata when new credential write and credential rollback fail', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') - vi.mocked(writeManagedClaudeKeychainCredentials) - .mockRejectedValueOnce(new Error('new credentials failed')) - .mockRejectedValueOnce(new Error('credential rollback failed')) - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn() - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( - 'new credentials failed' - ) - - expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( - '{"oldOauth":true}\n' - ) - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - '[claude-accounts] Failed to restore managed credentials during rollback:', - expect.any(Error) - ) - warn.mockRestore() - }) - - it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { - setPlatform('linux') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const oauthPath = join(managedAuthPath, 'oauth-account.json') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(oauthPath, '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: 'account-1' - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), - syncForCurrentSelection: vi.fn(async () => { - rmSync(oauthPath, { force: true }) - mkdirSync(oauthPath) - throw new Error('materialize failed') - }) - } - const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') - - expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') - expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') - expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('evicts inactive rate-limit cache after successful reauth', async () => { - setPlatform('linux') - tempDir = CLAUDE_SERVICE_TEST_ROOT - rmSync(tempDir, { recursive: true, force: true }) - const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') - let settings = { - claudeManagedAccounts: [ - { - id: 'account-1', - email: 'old@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeClaudeManagedAccountId: null - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { ...settings, ...updates } - return settings - }) - } - const runtimeAuth = { - clearLastWrittenCredentialsJson: vi.fn(), - syncForCurrentSelection: vi.fn(async () => {}), - forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) - } - const rateLimits = { - evictInactiveClaudeCache: vi.fn(), - refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) - } - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - store as never, - rateLimits as never, - runtimeAuth as never - ) - ;( - service as unknown as { - runClaudeLoginAndCapture(): Promise<{ - credentialsJson: string - oauthAccount: unknown - identity: { email: string; organizationUuid: null; organizationName: null } - }> - } - ).runClaudeLoginAndCapture = vi.fn(async () => ({ - credentialsJson: '{"new":true}\n', - oauthAccount: { newOauth: true }, - identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } - })) - - await service.reauthenticateAccount('account-1') - - expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { - runtime: 'host' - }) - expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') - }) -}) diff --git a/src/main/claude-accounts/claude-account-service-test-harness.ts b/src/main/claude-accounts/claude-account-service-test-harness.ts deleted file mode 100644 index d66d1f3ea51b..000000000000 --- a/src/main/claude-accounts/claude-account-service-test-harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { vi } from 'vitest' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' - -// Each suite declares its own `vi.mock('./keychain', ...)` (factories are hoisted and -// cannot close over this module); this only re-arms the doubles between tests. -export function resetClaudeKeychainMocks(): void { - vi.mocked(readActiveClaudeKeychainCredentials).mockReset() - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() - vi.mocked(readManagedClaudeKeychainCredentials).mockReset() - vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockClear() - vi.mocked(writeActiveClaudeKeychainCredentials).mockReset() - vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() - vi.mocked(writeManagedClaudeKeychainCredentials).mockReset() - vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -export function restorePlatform(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -export function createService(): unknown { - return {} -} diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts new file mode 100644 index 000000000000..80810512a23d --- /dev/null +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -0,0 +1,360 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE } from './claude-account-registration' +import { + CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE, + CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE +} from './claude-account-selection' +import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' +import { ClaudeAccountService } from './service' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function account(id: string, extra: Partial = {}): ClaudeManagedAccount { + return { + id, + email: `${id}@example.test`, + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...extra + } +} + +function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]) { + const root = mkdtempSync(join(tmpdir(), 'claude-accounts-')) + roots.push(root) + const home = (id: string) => join(root, id, 'home') + // Stands in for Claude finishing `claude auth login` in a folder. + const signIn = (id: string, email: string) => { + mkdirSync(home(id), { recursive: true }) + writeFileSync( + join(home(id), '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) + } + const covered = new Set() + let settings: GlobalSettings = { + ...getDefaultSettings('/tmp'), + claudeManagedAccounts: accounts, + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: { host: 'a', wsl: {} } + } + const runtimeAuth = { + router: { + accountHome: home, + userConfigDir: () => join(root, 'personal'), + copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')), + coveredBySystemDefault: (id: string) => covered.has(id) + }, + syncForCurrentSelection: vi.fn(async (_target?: ClaudeAccountSelectionTarget) => {}), + publishAll: vi.fn(async () => {}), + prepareAccountFolder: vi.fn(async (id: string) => { + mkdirSync(home(id), { recursive: true }) + return { configDir: home(id), readPath: home(id) } + }), + removeAccountFolder: vi.fn(async (id: string) => rmSync(join(root, id), { recursive: true })), + getRuntimeConfigDir: () => '/unused' + } + // Stands in for the hidden `claude auth login`; each test decides how it ends. + const runLogin = vi.fn< + ( + args: string[], + config: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions + ) => Promise + >(async () => '') + const service = new ClaudeAccountService( + { + getSettings: () => settings, + updateSettings: (patch) => { + settings = { ...settings, ...patch } + } + }, + { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn().mockResolvedValue(undefined) + }, + runtimeAuth, + runLogin + ) + return { root, home, service, runtimeAuth, runLogin, signIn, covered, settings: () => settings } +} + +// The id the last sign-in prepared a folder for. +function newId(f: ReturnType): string { + return f.runtimeAuth.prepareAccountFolder.mock.calls.at(-1)![0] +} + +describe('ClaudeAccountService', () => { + it("labels each row with its folder's login and asks a folder without one to sign in", () => { + const f = fixture([ + account('a'), + account('b'), + account('old-wsl', { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/old-wsl/auth' + }), + account('new-wsl', { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/new-wsl/home' + }) + ]) + f.signIn('a', 'now-a@example.test') + const byId = new Map(f.service.listAccounts().accounts.map((row) => [row.id, row])) + expect(byId.get('a')).toMatchObject({ email: 'now-a@example.test' }) + expect(byId.get('a')?.needsSignIn).toBeUndefined() + expect(byId.get('b')).toMatchObject({ email: 'b@example.test', needsSignIn: true }) + expect(byId.get('old-wsl')).toMatchObject({ needsSignIn: true }) + expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined() + }) + + it('asks no sign-in of an account System default runs, and lets it be selected', async () => { + const f = fixture() + f.covered.add('b') + expect(f.service.listAccounts().accounts.find((row) => row.id === 'b')?.needsSignIn).toBe( + undefined + ) + await expect(f.service.selectAccount('b')).resolves.toMatchObject({ activeAccountId: 'b' }) + }) + + it("reports System default's login from the user's own folder", () => { + const f = fixture() + mkdirSync(join(f.root, 'personal')) + writeFileSync( + join(f.root, 'personal', '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'me@example.test' } }) + ) + expect(f.service.listAccounts()).toMatchObject({ + systemDefaultEmail: 'me@example.test' + }) + expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir') + expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined() + mkdirSync(join(f.root, 'claude-runtime-auth')) + expect(f.service.listAccounts().systemDefaultMayBeCopied).toBe(true) + }) + + it('saves an account only once its folder holds a login', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + expect(begun).toMatchObject({ + runtime: 'host', + configDir: join(f.root, begun.accountId, 'home') + }) + await expect(f.service.finishSignIn(begun)).rejects.toThrow(CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + + f.signIn(begun.accountId, 'new@example.test') + await f.service.finishSignIn(begun) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + id: begun.accountId, + email: 'new@example.test', + managedAuthPath: begun.configDir + }) + }) + + it('refuses a second account for the same login and deletes its folder', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + f.signIn(begun.accountId, 'B@example.test') + await expect(f.service.finishSignIn(begun)).rejects.toThrow('already added') + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { + runtime: 'host' + }) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + }) + + it('relabels a saved account signed in again under another login', async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ accountId: 'b' }) + f.signIn('b', 'other@example.test') + await f.service.finishSignIn(begun) + expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ + email: 'other@example.test', + createdAt: 1 + }) + }) + + it('signs in with a hidden login straight into the new account folder', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async (_args, config) => { + f.signIn(newId(f), 'new@example.test') + expect(config).toEqual({ windowsPath: f.home(newId(f)), linuxPath: null, wslDistro: null }) + return '' + }) + await f.service.addAccount({ runtime: 'host' }) + expect(f.runLogin).toHaveBeenCalledWith( + ['auth', 'login', '--claudeai'], + expect.anything(), + expect.any(Number), + expect.objectContaining({ keepStdinOpen: true }) + ) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + id: newId(f), + email: 'new@example.test', + managedAuthPath: f.home(newId(f)) + }) + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + }) + + it('runs a WSL login against the guest folder', async () => { + const f = fixture() + f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => { + mkdirSync(f.home(id), { recursive: true }) + return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) } + }) + f.runLogin.mockImplementationOnce(async (_args, config) => { + f.signIn(newId(f), 'wsl@example.test') + expect(config).toEqual({ + windowsPath: f.home(newId(f)), + linuxPath: `/home/u/claude-profiles/${newId(f)}/home`, + wslDistro: 'Ubuntu' + }) + return '' + }) + await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ + email: 'wsl@example.test', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: `/home/u/claude-profiles/${newId(f)}/home` + }) + }) + + it('leaves no row or folder when the hidden login is cancelled', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce( + (_args, _config, _timeoutMs, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener('abort', () => + reject(new Error('Claude sign-in was cancelled.')) + ) + ) + ) + const adding = f.service.addAccount({ runtime: 'host' }) + await vi.waitFor(() => expect(f.runLogin).toHaveBeenCalled()) + expect(f.service.cancelPendingLogin()).toBe(true) + await expect(adding).rejects.toThrow('Claude sign-in was cancelled.') + expect(f.service.cancelPendingLogin()).toBe(false) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(newId(f), { runtime: 'host' }) + expect(existsSync(join(f.root, newId(f)))).toBe(false) + }) + + it('leaves no row or folder when the hidden login times out or finds no login', async () => { + const f = fixture() + f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in took too long to finish.')) + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow('took too long') + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'could not resolve the account email' + ) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledTimes(2) + }) + + it('refuses a hidden login to an already added account and deletes its folder', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async () => { + f.signIn(newId(f), 'B@example.test') + return '' + }) + await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'This Claude account is already added.' + ) + expect(f.settings().claudeManagedAccounts).toHaveLength(2) + expect(existsSync(join(f.root, newId(f)))).toBe(false) + }) + + it('signs a saved account in again inside its own folder and keeps it on failure', async () => { + const f = fixture() + f.runLogin.mockImplementationOnce(async (_args, config) => { + expect(config.windowsPath).toBe(f.home('b')) + f.signIn('b', 'b@example.test') + return '' + }) + await f.service.reauthenticateAccount('b') + expect(f.runtimeAuth.prepareAccountFolder).toHaveBeenCalledWith('b', { runtime: 'host' }) + expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ + email: 'b@example.test', + createdAt: 1 + }) + f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in was cancelled.')) + await expect(f.service.reauthenticateAccount('b')).rejects.toThrow('cancelled') + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) + }) + + it('clears the selection, republishes, then deletes the folder on remove', async () => { + const f = fixture() + f.signIn('a', 'a@example.test') + await f.service.removeAccount('a') + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['b']) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() + expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith('a', { runtime: 'host' }) + }) + + it("deletes a cancelled sign-in's folder but never a saved account's", async () => { + const f = fixture() + const begun = await f.service.beginSignIn({ runtime: 'host' }) + await f.service.cancelSignIn({ accountId: begun.accountId, runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { + runtime: 'host' + }) + f.runtimeAuth.removeAccountFolder.mockClear() + await f.service.cancelSignIn({ accountId: 'a', runtime: 'host' }) + expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() + }) + + it('keeps the row when its folder cannot be deleted, so the user can retry', async () => { + const f = fixture() + f.runtimeAuth.removeAccountFolder.mockRejectedValueOnce( + Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + ) + await expect(f.service.removeAccount('a')).rejects.toThrow(CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE) + expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() + }) + + it('puts the previous account back when publishing a new selection fails', async () => { + const f = fixture() + f.signIn('b', 'b@example.test') + f.runtimeAuth.syncForCurrentSelection.mockRejectedValueOnce(new Error('publish failed')) + await expect(f.service.selectAccount('b')).rejects.toThrow('publish failed') + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + expect(f.runtimeAuth.publishAll).toHaveBeenCalled() + }) + + it('refuses to select an account whose folder holds no login', async () => { + const f = fixture() + await expect(f.service.selectAccount('b')).rejects.toThrow(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + f.signIn('b', 'b@example.test') + await f.service.selectAccount('b') + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('b') + }) + + it('refuses to select a WSL account for the host', async () => { + const f = fixture([ + account('a'), + account('w', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ]) + await expect(f.service.selectAccountForTarget('w', { runtime: 'host' })).rejects.toThrow( + 'That Claude account belongs to a different runtime.' + ) + }) +}) diff --git a/src/main/claude-accounts/claude-auth-capture.ts b/src/main/claude-accounts/claude-auth-capture.ts deleted file mode 100644 index 2acaa4408683..000000000000 --- a/src/main/claude-accounts/claude-auth-capture.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { readActiveClaudeKeychainCredentialsStrict } from './keychain' - -export type ClaudeIdentity = { - email: string | null - organizationUuid: string | null - organizationName: string | null -} - -export type CapturedClaudeAuth = { - credentialsJson: string - oauthAccount: unknown - identity: ClaudeIdentity -} - -export type ClaudeCaptureCommand = ( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { allowFailure?: boolean } -) => Promise - -const STATUS_TIMEOUT_MS = 20_000 - -export async function captureClaudeAuthFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256: string | null | undefined, - runCommand: ClaudeCaptureCommand -): Promise { - const trimmed = configDir.trim() - if (!trimmed) { - throw new Error('A Claude config directory path is required.') - } - const resolvedDir = resolve(trimmed) - if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { - throw new Error( - `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` - ) - } - - let status = '' - try { - status = await runCommand( - ['auth', 'status', '--json'], - { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - } catch (error) { - console.warn('[claude-accounts] Could not read `claude auth status`:', error) - } - const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() - return captureClaudeAuthFromConfigDir( - resolvedDir, - status, - currentLegacyKeychain, - previousLegacyCredentialsSha256 - ) -} - -export async function captureClaudeAuthFromConfigDir( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null, - readCredentials = readCapturedClaudeCredentials -): Promise { - const credentialsJson = await readCredentials( - configDir, - previousLegacyKeychain, - previousLegacyCredentialsSha256 - ) - if (!credentialsJson) { - throw new Error('Claude login completed, but no OAuth credentials were captured.') - } - const oauthAccount = readClaudeOauthAccount(configDir) - return { - credentialsJson, - oauthAccount, - identity: resolveClaudeIdentity(statusOutput, oauthAccount, credentialsJson) - } -} - -export async function readCapturedClaudeCredentials( - configDir: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null -): Promise { - if (process.platform === 'darwin') { - const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) - if (scopedCredentialsJson) { - return scopedCredentialsJson - } - const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() - const legacyChanged = - previousLegacyCredentialsSha256 === undefined - ? legacyCredentialsJson !== previousLegacyKeychain - : legacyCredentialsJson !== null && - createHash('sha256').update(legacyCredentialsJson).digest('hex') !== - previousLegacyCredentialsSha256 - if (legacyCredentialsJson && legacyChanged) { - return legacyCredentialsJson - } - } - const credentialsPath = join(configDir, '.credentials.json') - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null -} - -function readClaudeOauthAccount(configDir: string): unknown { - for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { - if (!existsSync(configPath)) { - continue - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record - if (parsed.oauthAccount) { - return parsed.oauthAccount - } - } catch { - continue - } - } - return null -} - -function resolveClaudeIdentity( - statusOutput: string, - oauthAccount: unknown, - credentialsJson: string -): ClaudeIdentity { - const status = parseJsonObject(statusOutput) - const oauth = asRecord(oauthAccount) - const credentialOauth = asRecord(parseJsonObject(credentialsJson)?.claudeAiOauth) - return { - email: normalizeField( - readString(status, 'email') ?? - readString(oauth, 'emailAddress') ?? - readString(oauth, 'email') ?? - readString(credentialOauth, 'email') - ), - organizationUuid: normalizeField( - readString(status, 'organizationUuid') ?? - readString(status, 'organizationId') ?? - readString(oauth, 'organizationUuid') ?? - readString(oauth, 'organizationId') - ), - organizationName: normalizeField( - readString(status, 'organizationName') ?? readString(oauth, 'organizationName') - ) - } -} - -function parseJsonObject(value: string): Record | null { - try { - return asRecord(JSON.parse(value) as unknown) - } catch { - return null - } -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null -} - -function readString(value: Record | null, key: string): string | null { - return typeof value?.[key] === 'string' ? value[key] : null -} - -function normalizeField(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} diff --git a/src/main/claude-accounts/claude-command-process.test.ts b/src/main/claude-accounts/claude-command-process.test.ts new file mode 100644 index 000000000000..514a8ed4d487 --- /dev/null +++ b/src/main/claude-accounts/claude-command-process.test.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type SpawnSpec = { program: string; args?: readonly string[]; env?: NodeJS.ProcessEnv } +const spawned = vi.hoisted(() => { + const specs: SpawnSpec[] = [] + return { specs } +}) + +vi.mock('../../shared/child-process/run-process', () => ({ + spawnProcess: (spec: SpawnSpec) => { + spawned.specs.push(spec) + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: undefined, + kill: vi.fn() + }) + queueMicrotask(() => child.emit('close', 0)) + return child + } +})) +vi.mock('../codex-cli/command', () => ({ resolveClaudeCommand: () => 'claude' })) +vi.mock('../wsl/wsl-executable-path', () => ({ + resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' +})) + +import { buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' +import { runClaudeCommandProcess } from './claude-command-process' + +const originalPlatform = process.platform +afterEach(() => { + spawned.specs.length = 0 + Object.defineProperty(process, 'platform', { value: originalPlatform }) +}) + +describe('runClaudeCommandProcess', () => { + it("logs in on the host with CLAUDE_CONFIG_DIR set to the account's folder", async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + await runClaudeCommandProcess( + ['auth', 'login', '--claudeai'], + { windowsPath: '/data/claude-profiles/a/home', linuxPath: null, wslDistro: null }, + 1000 + ) + expect(spawned.specs[0]).toMatchObject({ + program: 'claude', + args: ['auth', 'login', '--claudeai'] + }) + expect(spawned.specs[0].env?.CLAUDE_CONFIG_DIR).toBe('/data/claude-profiles/a/home') + }) + + it("logs in through the distro's login shell with --exec for a WSL account", async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + await runClaudeCommandProcess( + ['auth', 'login', '--claudeai'], + { + windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\a', + linuxPath: "/home/u/it's/a", + wslDistro: 'Ubuntu' + }, + 1000 + ) + const spec = spawned.specs[0] + expect(spec.program).toBe('C:\\Windows\\System32\\wsl.exe') + expect(spec.args?.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', '/bin/sh', '-c']) + expect(spec.args?.[5]).toBe( + buildWslLoginShellCommand( + `exec env CLAUDE_CONFIG_DIR='/home/u/it'\\''s/a' claude 'auth' 'login' '--claudeai'` + ) + ) + expect(spec.env?.CLAUDE_CONFIG_DIR).toBe(process.env.CLAUDE_CONFIG_DIR) + }) +}) diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index 78fe109f79f8..98b867d02a78 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -4,7 +4,13 @@ import { buildWindowsHostInteractiveLoginSpawn, type WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' +import { + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { buildWindowsCommandInvocation } from './windows-command-invocation' import { terminateClaudeProcess } from './claude-login-process-termination' @@ -19,15 +25,10 @@ export type ClaudeCommandConfig = { } export type ClaudeCommandOptions = { - allowFailure?: boolean signal?: AbortSignal keepStdinOpen?: boolean } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - export function runClaudeCommandProcess( args: string[], configDir: ClaudeCommandConfig, @@ -149,7 +150,7 @@ export function runClaudeCommandProcess( return } settle(() => { - if (code === 0 || options?.allowFailure) { + if (code === 0) { resolvePromise(output) return } @@ -188,12 +189,9 @@ type ClaudeSpawnConfig = { windowsVerbatimArguments: boolean } +// Why only this variable: account terminals set just CLAUDE_CONFIG_DIR, so Claude keys the login the same way. function claudeConfigDirEnv(configDir: string): NodeJS.ProcessEnv { - return { - CLAUDE_CONFIG_DIR: configDir, - // Why: Claude Code 2.1.220+ hashes this for the Keychain service name. - CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir - } + return { CLAUDE_CONFIG_DIR: configDir } } function resolveClaudeInvocation( @@ -214,15 +212,15 @@ function resolveClaudeInvocation( } : configDir.linuxPath && configDir.wslDistro ? { - command: 'wsl.exe', - args: [ - '-d', - configDir.wslDistro, - '--exec', - 'bash', - '-lc', - `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; export CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` - ], + // Same login as the CLI's WSL sign-in: the distro's login shell finds an nvm/mise claude. + command: resolveWslExecutablePath(), + args: buildWslExecArgs(configDir.wslDistro, [ + '/bin/sh', + '-c', + buildWslLoginShellCommand( + `exec env CLAUDE_CONFIG_DIR=${quotePosixShell(configDir.linuxPath)} claude ${args.map(quotePosixShell).join(' ')}` + ) + ]), env: process.env, windowsVerbatimArguments: false } diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts new file mode 100644 index 000000000000..f462c710c5df --- /dev/null +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -0,0 +1,286 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { expect, it } from 'vitest' +import { stripComments } from '../../shared/source-scan/source-tree-scan' + +const src = join(__dirname, '../..') + +/** + * Every place a Claude login could be written from: accounts, usage, launch, hooks, the CLI, the + * relay and the generated shell scripts. Plan §9 lists these; credential checks run on all of them. + */ +const credentialRoots: readonly (string | { root: string; only: RegExp })[] = [ + 'main/claude-accounts', + // Other providers there refresh their own logins; only Claude's usage is in scope. + { root: 'main/rate-limits', only: /[\\/]claude[^\\/]*\.ts$/ }, + 'main/claude', + 'main/native-chat', + 'main/ipc/pty', + 'main/daemon', + 'main/providers', + 'main/macos-keychain', + 'cli', + 'relay', + 'shared' +] + +/** + * Only noncredential profile surfaces may persist bytes, and each only with the primitives it + * uses today; a new primitive in one of them is a review, not a silent pass. + */ +const fsAllowances: Record = { + 'claude-accounts/claude-profile-history.ts': [ + 'mkdirSync', + 'renameSync', + 'rmdirSync', + 'symlinkSync', + 'unlinkSync' + ], + 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], + // The which-account file. + 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + // Removing an account deletes its folder, links first. + 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], + 'claude-accounts/claude-profile-prompt-history.ts': [ + 'appendFileSync', + 'linkSync', + 'openSync', + 'renameSync', + 'rmSync', + 'symlinkSync', + 'unlinkSync', + 'writeFileSync', + 'writeFileAtomically' + ], + 'claude-accounts/claude-profile-provisioning.ts': [ + 'publishFileWithoutOverwrite', + 'rmSync', + 'writeFileSync' + ], + 'claude-accounts/claude-profile-sharing.ts': [ + 'mkdirSync', + 'rmdirSync', + 'symlinkSync', + 'unlinkSync', + 'writeFileAtomically' + ], + // The guest helper answers the host on stdout. + 'claude-accounts/claude-profile-wsl-entry.ts': ['write'] +} +const mutation = + /^(?:write(?:File|Json|Sync|v|.*Credentials|.*Keychain)?$|write(?:File|Json)|appendFile|copyFile|cp(?:Sync)?$|rename(?:Sync)?$|unlink(?:Sync)?$|rm(?:dir)?(?:Sync)?$|truncate(?:Sync)?$|createWriteStream|symlink(?:Sync)?$|link(?:Sync)?$|chmod(?:Sync)?$|mkdir(?:Sync)?$|mkdtemp(?:Sync)?$|open(?:Sync)?$|delete.*Keychain|refreshClaudeOauth)/ + +function persistencePrimitives(text: string): string[] { + const found = new Set() + // Import-side names survive aliases, including destructuring and wrapped helper calls. + for (const match of text.matchAll(/\b([a-zA-Z_$][\w$]*)\s*(?=\(|as\s|[:,}])/g)) { + if (mutation.test(match[1])) { + found.add(match[1]) + } + } + return [...found] +} + +const WRITE_CALL = + /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream)\s*\(/g +// The one runner that takes `security` argv from its callers; each caller must spell the verb. +const SECURITY_RUNNER = 'main/macos-keychain/generic-password.ts' +// Removing an account deletes the Keychain items Claude made for its folder; nothing adds one. +const KEYCHAIN_DELETE_OWNER = 'main/claude-accounts/claude-account-folder.ts' + +/** Names bound, directly or through another such name, to the credentials file's path. */ +function credentialFileNames(text: string): Set { + const declarations: { name: string; init: string }[] = [] + const lines = text.split('\n') + lines.forEach((line, index) => { + const declared = /^(\s*)(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) + if (!declared) { + return + } + const indent = declared[1].length + let end = index + 1 + while (end < lines.length && /^\s*$|^\s+/.test(lines[end])) { + const lead = /^\s*/.exec(lines[end])?.[0].length ?? 0 + if (lines[end].trim() && lead <= indent && !/^\s*[)\]}]/.test(lines[end])) { + break + } + end += 1 + } + declarations.push({ name: declared[2], init: lines.slice(index, end).join('\n') }) + }) + const names = new Set() + for (let changed = true; changed;) { + changed = false + for (const { name, init } of declarations) { + const tainted = + init.includes('.credentials.json') || + [...names].some((other) => new RegExp(`\\b${other}\\b`).test(init.replace(name, ''))) + if (tainted && !names.has(name)) { + names.add(name) + changed = true + } + } + } + return names +} + +/** Credential writes refused everywhere scanned, whatever form the call takes. */ +function credentialMutations(text: string, file = ''): string[] { + const found: string[] = [] + // A `security` launch whose verb is not spelled out could be a write, so only the runner may + // forward argv, and its callers must spell the verb. + if ( + (file !== SECURITY_RUNNER && + /\b(?:exec(?:File)?(?:Sync|Async)?|spawn(?:Sync)?|runProcess|spawnProcess)\s*\(\s*['"`]security['"`]\s*,\s*(?!\[\s*['"])/.test( + text + )) || + /\bprogram\s*:\s*['"`]security['"`]\s*,\s*args\s*:\s*(?!\[\s*['"])/.test(text) || + /(? new RegExp(`\\b${name}\\b`).test(args))) { + found.push('credentials file write through a name') + break + } + } + // Argv (`['add-generic-password', …]`) and shell (`security add-generic-password`) forms. + if ( + /add-generic-password/.test(text) || + (file !== KEYCHAIN_DELETE_OWNER && /delete-generic-password/.test(text)) + ) { + found.push('keychain write') + } + if (/\b(?:write|delete|store|save)\w*Keychain\w*\b/.test(text)) { + found.push('keychain helper') + } + if (/oauth\/token|grant_type|\brefresh_token\b/.test(text)) { + found.push('token refresh') + } + // A statement that names the credentials file together with anything that writes, copies, + // moves or opens it for writing (TS calls and generated shell alike). + for (const statement of text.split(/;|\n\s*\n/)) { + if ( + statement.includes('.credentials.json') && + /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream|mv|tee|install)\b|\bopen(?:Sync)?\s*\([\s\S]{0,200}?,\s*['"`](?:w|a|r\+|wx|ax)|>\s*["'$]/.test( + statement + ) + ) { + found.push('credentials file write') + } + } + return found +} + +function files(dir: string): string[] { + if (!existsSync(dir)) { + return [] + } + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = join(dir, entry.name) + return entry.isDirectory() + ? entry.name === 'node_modules' || entry.name === '__fixtures__' + ? [] + : files(file) + : /\.(?:ts|tsx|mts|cts|js|mjs|cjs|sh)$/.test(entry.name) && + !/\.test\.|\.spec\.|test-harness|harness\.ts|test-fixtures|test-utils/.test(entry.name) + ? [file] + : [] + }) +} + +it('keeps every Claude credential writer removed, in every form and on every launch path', () => { + const violations = credentialRoots + .flatMap((entry) => { + const { root, only } = typeof entry === 'string' ? { root: entry, only: null } : entry + const dir = join(src, root) + const found = statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? files(dir) : [] + return only ? found.filter((file) => only.test(file)) : found + }) + .flatMap((file) => + credentialMutations( + stripComments(readFileSync(file, 'utf8')), + relative(src, file).replaceAll('\\', '/') + ).map((kind) => `${relative(src, file)}: ${kind}`) + ) + expect(violations).toEqual([]) +}) + +it('confines filesystem persistence in accounts and usage to named profile surfaces', () => { + const main = join(src, 'main') + const candidates = [ + ...files(__dirname), + ...files(join(main, 'rate-limits')).filter((file) => /\/claude[^/]*\.ts$/.test(file)) + ] + const violations = candidates.flatMap((file) => { + const name = relative(main, file).replaceAll('\\', '/') + const allowed = new Set(fsAllowances[name] ?? []) + return persistencePrimitives(stripComments(readFileSync(file, 'utf8'))) + .filter((primitive) => !allowed.has(primitive)) + .map((primitive) => `${name}: ${primitive}`) + }) + expect(violations).toEqual([]) + const cli = readFileSync(join(main, '../cli/handlers/account.ts'), 'utf8') + expect(cli).not.toMatch(/Keychain|orca-account-add-claude|addClaudeFromConfigDir/) + const auth = readFileSync(join(__dirname, 'runtime-auth-service.ts'), 'utf8') + expect(auth).not.toMatch(/extends ClaudeRuntimeAuth|snapshot|credentialsJson|readBack/i) +}) + +it.each([ + "import { writeFile as save } from 'node:fs/promises'; save(path, token)", + "import * as fs from 'node:fs'; const save = () => fs.writeFileSync(path, token)", + "import { writeFileAtomically as persist } from '../codex-accounts/fs-utils'; persist(path, token)", + 'fs.copyFile(source, destination)', + 'fs.symlink(credentialPath, destination)', + "const fd = openSync(join(dir, 'x'), 'w'); writeSync(fd, token)", + 'await fs.promises.cp(a, b)', + 'fs.cpSync(a, b, { recursive: true })' +])('mutation control: the filesystem census rejects %s', (source) => { + expect(persistencePrimitives(source).length).toBeGreaterThan(0) +}) + +it.each([ + 'security add-generic-password -s Claude -w token', + "await execFileAsync('security', ['add-generic-password', '-U', '-s', service, '-w', token])", + "execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'])", + "import { writeKeychainPassword as persist } from '../macos-keychain/generic-password'; persist(service, user, token)", + "const fd = openSync(join(dir, '.credentials.json'), 'w'); writeSync(fd, token)", + "await fs.promises.cp(join(a, '.credentials.json'), join(b, '.credentials.json'))", + "copyFileSync(join(from, '.credentials.json'), join(home, '.credentials.json'))", + "const script = 'cp source .credentials.json'", + 'const script = `printf %s "$token" > "$HOME/.claude/.credentials.json"`', + "fetch('https://api.anthropic.com/v1/oauth/token')", + "body: JSON.stringify({ grant_type: 'refresh_token' })", + "execFile('security', [['add', 'generic', 'password'].join('-'), '-s', service])", + "spawn('security', verbArgs)", + 'execSecurityCommand(args)', + "const CREDENTIALS_FILE = '.credentials.json'\n\ncopyFileSync(join(from, CREDENTIALS_FILE), join(to, CREDENTIALS_FILE))", + "const name = '.credentials.json'\nfunction save(dir) {\n const target = join(dir, name)\n\n writeFileSync(target, token)\n}" +])('mutation control: the credential check rejects %s', (source) => { + expect(credentialMutations(source).length).toBeGreaterThan(0) +}) + +it.each([ + "const file = await readFile(join(home, '.credentials.json'), 'utf8')", + "readKeychainPassword('Claude Code-credentials', user)", + "execSecurityCommand(['find-generic-password', '-s', service, '-w'])", + "const file = join(home, '.credentials.json')\nconst raw = await readFile(file, 'utf8')" +])('the credential check leaves a read alone: %s', (source) => { + expect(credentialMutations(source)).toEqual([]) +}) + +it('lets only account removal delete a Keychain item, and nothing add one', () => { + const remove = "execSecurityCommand(['delete-generic-password', '-s', service])" + expect(credentialMutations(remove, KEYCHAIN_DELETE_OWNER)).toEqual([]) + expect(credentialMutations(remove, 'main/claude-accounts/service.ts')).not.toEqual([]) + expect( + credentialMutations( + "execSecurityCommand(['add-generic-password', '-s', service])", + KEYCHAIN_DELETE_OWNER + ) + ).not.toEqual([]) +}) diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts new file mode 100644 index 000000000000..c4cc2f42c605 --- /dev/null +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -0,0 +1,235 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { resolveClaudeStructuredInvocation } from '../claude/claude-structured-launch-resolution' +import { resolveClaudeStructuredLaunchHome } from '../claude/claude-structured-launch-home' +import { resolveStructuredClaudeAccountHomePath } from '../runtime/structured-agent-account-home' +import { + installClaudeProfileRouter, + withClaudeProfileTerminalEnv +} from './claude-profile-installed-router' +import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' +import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' +import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' +import { applyClaudeEnvPatch } from './environment' + +const roots: string[] = [] +afterEach(() => { + installClaudeProfileRouter(undefined) + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function signIn(stateDir: string, email: string): void { + mkdirSync(stateDir, { recursive: true }) + writeFileSync( + join(stateDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) +} + +/** Account a is selected and set up but signed in only in System default. */ +function coveredAccount() { + const root = mkdtempSync(join(tmpdir(), 'claude-launch-routing-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + const account: ClaudeManagedAccount = { + id: 'a', + email: 'a@example.test', + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot, + userHome, + env: {}, + runSetup: async () => ({ outcome: 'prepared', warnings: [], surfaces: {} }) + }) + const accountHome = router.accountHome('a') + mkdirSync(accountHome, { recursive: true }) + writeFileSync(join(accountHome, '..', 'profile.json'), '{}') + signIn(userHome, 'a@example.test') + installClaudeProfileRouter(router) + return { router, settings, accountHome, systemHome: join(userHome, '.claude') } +} + +/** Where every Orca-started Claude would run right now, read through each entry point. */ +async function launchHomes(f: ReturnType) { + const chatEnv: Record = {} + return { + terminal: (await f.router.prepareLaunch()).configDir, + terminalEnv: withClaudeProfileTerminalEnv>({}, null, { + runtime: 'host' + }).CLAUDE_CONFIG_DIR, + chat: await resolveClaudeStructuredLaunchHome(f.router, chatEnv, '/recorded'), + chatEnv: chatEnv.CLAUDE_CONFIG_DIR, + chatRecord: resolveStructuredClaudeAccountHomePath({ + launchEnv: {}, + wslDistro: null, + getClaudeConfigDirectory: () => f.router.systemDefaultHome() + }), + chatAuth: claudeStructuredAuthPolicyForSettings(f.settings), + usage: f.router.preparation().configDir, + inactiveUsage: f.router.accountUsagePreparation('a').configDir + } +} + +const mainRoot = join(__dirname, '..') + +function mainSourceFiles(): string[] { + const files: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + walk(path) + } else if ( + entry.name.endsWith('.ts') && + !/\.test\.|test-(?:support|fixture|harness)/.test(entry.name) + ) { + files.push(relative(mainRoot, path).split('\\').join('/')) + } + } + } + walk(mainRoot) + return files +} + +describe('one router decision for every Claude launch', () => { + it('sends every entry point to System default, then to the account once it signs in', async () => { + const f = coveredAccount() + expect(await launchHomes(f)).toEqual({ + terminal: f.systemHome, + terminalEnv: undefined, + chat: f.systemHome, + chatEnv: undefined, + chatRecord: f.systemHome, + chatAuth: { account: 'system' }, + usage: f.systemHome, + inactiveUsage: f.systemHome + }) + signIn(f.accountHome, 'a@example.test') + expect(await launchHomes(f)).toEqual({ + terminal: f.accountHome, + terminalEnv: f.accountHome, + chat: f.accountHome, + chatEnv: f.accountHome, + chatRecord: f.accountHome, + // A shell proxy's key stays with its address on the account too. + chatAuth: { account: 'managed' }, + usage: f.accountHome, + inactiveUsage: f.accountHome + }) + }) + + // The shell function's half is in claude-shell-function.test.ts; WSL's in the WSL router test. + it.each(['signed-in account', 'System default fallback'])( + "keeps a shell proxy's key with its address on every launch: %s", + async (scenario) => { + const f = coveredAccount() + if (scenario === 'signed-in account') { + signIn(f.accountHome, 'a@example.test') + } + const home = scenario === 'signed-in account' ? f.accountHome : undefined + const shell = { + ANTHROPIC_BASE_URL: 'https://proxy.example.test', + ANTHROPIC_API_KEY: 'proxy-key', + ANTHROPIC_AUTH_TOKEN: 'proxy-token' + } + // A terminal applies the launch's patch to its own env and deletes nothing more. + const terminal = applyClaudeEnvPatch( + { PATH: '/usr/bin', ...shell }, + (await f.router.prepareLaunch()).envPatch + ) + expect(terminal).toMatchObject(shell) + expect(terminal.CLAUDE_CONFIG_DIR).toBe(home) + const chat = await resolveClaudeStructuredInvocation({ + resolveCommand: () => 'claude', + resolveInheritedEnv: async () => ({ PATH: '/usr/bin', ...shell }), + resolveAuthPolicy: () => claudeStructuredAuthPolicyForSettings(f.settings) + }) + expect(chat.env).toMatchObject(shell) + const saved = Object.fromEntries(Object.keys(shell).map((key) => [key, process.env[key]])) + Object.assign(process.env, shell) + try { + const commit = await prepareLocalCommitMessageAgentEnv('claude', { + prepareForClaudeLaunch: () => f.router.prepareLaunch() + }) + expect(commit).toMatchObject({ ok: true, env: shell }) + if (home) { + expect(commit.ok && commit.env?.CLAUDE_CONFIG_DIR).toBe(home) + } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + } + ) + + // Terminals, chats, AI commit messages and automations launch through + // ClaudeRuntimeAuthService.prepareForClaudeLaunch; usage through prepareForRateLimitFetch. + it('keeps the Claude selection and account folders out of every launch path but the router', () => { + const routing = + /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|isHostManagedClaudeAccount)\b|\.accountHome\(/ + // Both map settings keys to model-catalog expiry or prewarm; they route nothing. + const allowed = new Set([ + 'native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts', + 'startup/main-process-account-services.ts' + ]) + const files = mainSourceFiles() + // Presence: the scan reaches the router, and the pattern finds what the router reads. + expect(files).toContain('claude-accounts/claude-profile-router.ts') + expect( + readFileSync(join(mainRoot, 'claude-accounts/claude-profile-router.ts'), 'utf8') + ).toMatch(routing) + const bypassing = files.filter( + (file) => + !file.startsWith('claude-accounts/') && + !allowed.has(file) && + routing.test(readFileSync(join(mainRoot, file), 'utf8')) + ) + expect(bypassing).toEqual([]) + }) + + // Terminals and AI commit messages run `claude` by agent id through generic launchers, which call + // prepareForClaudeLaunch for it; a new generic launcher is covered by the entry-point test above. + it('lets only router-aware modules resolve the claude binary or load the Agent SDK', () => { + const spawning = /\bresolveClaudeCommand\b|\bloadClaudeAgentSdk\b/ + const reviewed: Record = { + 'claude/claude-structured-child-env.ts': + 'chat child; its home comes from router.prepareLaunch', + 'claude/claude-stream-json-connection.ts': 'spawns the chat child with that launch env', + 'claude-accounts/claude-profile-router.ts': 'the router: a `--version` probe only', + 'claude-accounts/claude-command-process.ts': 'sign-in into a named account folder', + 'runtime/structured-claude-runtime-adapter.ts': 'wires the routed chat resolver', + 'runtime/structured-agent-session-runtime.ts': 'passes the resolver through', + 'runtime/structured-agent-runtime-registrations.ts': 'passes the resolver through', + 'runtime/structured-agent-model-catalog-discovery.ts': 'model listing on the routed chat env', + 'runtime/orca-runtime-get-worktree-ps.ts': 'wires the routed chat resolver' + } + const found = mainSourceFiles().filter((file) => + spawning.test(readFileSync(join(mainRoot, file), 'utf8')) + ) + // Reach: every reviewed module still exists and still matches, so the list cannot rot quietly. + expect(found.filter((file) => file in reviewed).sort()).toEqual(Object.keys(reviewed).sort()) + expect(found.filter((file) => !(file in reviewed))).toEqual([]) + }) +}) diff --git a/src/main/claude-accounts/claude-login-completion.oracle.test.ts b/src/main/claude-accounts/claude-login-completion.oracle.test.ts deleted file mode 100644 index 1f2fd963183f..000000000000 --- a/src/main/claude-accounts/claude-login-completion.oracle.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const processMocks = vi.hoisted(() => ({ - spawn: vi.fn() -})) - -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), - spawn: processMocks.spawn -})) - -vi.mock('electron', () => ({ - app: { getPath: () => 'C:\\orca-review-11407' } -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: () => 'claude.exe' -})) - -vi.mock('./keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - deleteManagedClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -type CommandConfig = { - windowsPath: string - linuxPath: string | null - wslDistro: string | null -} - -type FakeChild = EventEmitter & { - stdin: PassThrough - stdout: PassThrough - stderr: PassThrough - kill: ReturnType - pid: number -} - -type CommandRunner = { - runClaudeCommand( - args: string[], - config: CommandConfig, - timeoutMs: number, - options?: { keepStdinOpen?: boolean; signal?: AbortSignal } - ): Promise -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - -function createChild(): FakeChild { - const child = new EventEmitter() as FakeChild - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn() - child.pid = 11407 - return child -} - -async function createRunner(): Promise { - const { ClaudeAccountService } = await import('./service') - return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner -} - -async function flushPromiseCallbacks(): Promise { - await Promise.resolve() - await Promise.resolve() -} - -describe('native Windows Claude login completion oracle', () => { - beforeEach(() => { - vi.useFakeTimers() - processMocks.spawn.mockReset() - }) - - afterEach(() => { - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - }) - - it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => { - setPlatform('win32') - const child = createChild() - const destroyStdin = vi.spyOn(child.stdin, 'destroy') - const destroyStdout = vi.spyOn(child.stdout, 'destroy') - const destroyStderr = vi.spyOn(child.stderr, 'destroy') - const abortController = new AbortController() - const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener') - processMocks.spawn.mockReturnValue(child) - const runner = await createRunner() - let completions = 0 - const command = runner - .runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }, - 1000, - { keepStdinOpen: true, signal: abortController.signal } - ) - .then(() => { - completions += 1 - }) - - child.emit('exit', 0) - await flushPromiseCallbacks() - - try { - expect(completions).toBe(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('exit')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(destroyStdout).toHaveBeenCalledTimes(1) - expect(destroyStderr).toHaveBeenCalledTimes(1) - expect(removeAbortListener).toHaveBeenCalledTimes(1) - - child.emit('close', 0) - await vi.advanceTimersByTimeAsync(1001) - - expect(completions).toBe(1) - expect(child.kill).not.toHaveBeenCalled() - expect(destroyStdin).toHaveBeenCalledTimes(1) - expect(destroyStdout).toHaveBeenCalledTimes(1) - expect(destroyStderr).toHaveBeenCalledTimes(1) - expect(removeAbortListener).toHaveBeenCalledTimes(1) - } finally { - child.emit('close', 0) - await command - } - }) - - it.each([ - { - name: 'non-Windows auth login', - platform: 'linux' as const, - args: ['auth', 'login', '--claudeai'], - config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null } - }, - { - name: 'unrelated native Windows command containing login', - platform: 'win32' as const, - args: ['config', 'login'], - config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null } - }, - { - name: 'WSL auth login', - platform: 'win32' as const, - args: ['auth', 'login', '--claudeai'], - config: { - windowsPath: 'C:\\isolated-auth', - linuxPath: '/home/orca/isolated-auth', - wslDistro: 'Ubuntu-Oracle' - } - } - ])('preserves close completion for $name', async ({ platform, args, config }) => { - setPlatform(platform) - const child = createChild() - processMocks.spawn.mockReturnValue(child) - const runner = await createRunner() - let completions = 0 - const command = runner.runClaudeCommand(args, config, 1000).then(() => { - completions += 1 - }) - - child.emit('exit', 0) - await flushPromiseCallbacks() - - expect(completions).toBe(0) - expect(child.listenerCount('close')).toBe(1) - - child.emit('close', 0) - await command - - expect(completions).toBe(1) - expect(child.listenerCount('close')).toBe(0) - expect(child.kill).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts index ce9a398bb865..47160b506b22 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -1,19 +1,6 @@ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import type { CapturedClaudeAuth } from './claude-auth-capture' import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' -import type { ClaudeManagedAuthLocation } from './claude-managed-auth-storage' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials -} from './keychain' const LOGIN_TIMEOUT_MS = 180_000 -const STATUS_TIMEOUT_MS = 20_000 type ClaudeLoginSessionDependencies = { runCommand: ( @@ -22,19 +9,14 @@ type ClaudeLoginSessionDependencies = { timeoutMs: number, options?: ClaudeCommandOptions ) => Promise - capture: ( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null - ) => Promise setCancel: (cancel: (() => boolean) | null) => void } +/** Runs a hidden `claude auth login` that writes its login straight into the account's folder. */ export async function runClaudeLoginSession( - location: ClaudeManagedAuthLocation, + folder: ClaudeCommandConfig, dependencies: ClaudeLoginSessionDependencies -): Promise { - const tempConfig = await createTemporaryClaudeConfigDir(location) +): Promise { const controller = new AbortController() dependencies.setCancel(() => { if (controller.signal.aborted) { @@ -43,107 +25,12 @@ export async function runClaudeLoginSession( controller.abort() return true }) - const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() - let captured: CapturedClaudeAuth | null = null - let captureError: unknown = null - let cleanupError: unknown = null try { - if (controller.signal.aborted) { - throw new Error('Claude sign-in was cancelled.') - } - await dependencies.runCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, { + await dependencies.runCommand(['auth', 'login', '--claudeai'], folder, LOGIN_TIMEOUT_MS, { signal: controller.signal, keepStdinOpen: true }) - dependencies.setCancel(null) - const status = await dependencies.runCommand( - ['auth', 'status', '--json'], - tempConfig, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - captured = await dependencies.capture(tempConfig.windowsPath, status, previousLegacyKeychain) - } catch (error) { - captureError = error } finally { - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) - } catch (error) { - console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) - } - try { - await (previousLegacyKeychain - ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - cleanupError = error - } - } - await removeTemporaryClaudeConfigDir(tempConfig) dependencies.setCancel(null) } - if (captureError) { - throw captureError - } - if (cleanupError) { - throw cleanupError - } - return captured! -} - -async function createTemporaryClaudeConfigDir( - location: ClaudeManagedAuthLocation -): Promise { - if (location.managedAuthRuntime !== 'wsl') { - const created = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) - let windowsPath = created - try { - windowsPath = realpathSync(created) - } catch { - // Keep the mkdtemp path if the temp root cannot be resolved. - } - return { - windowsPath, - linuxPath: null, - wslDistro: null - } - } - if (!location.wslDistro) { - throw new Error('Could not resolve the active WSL distribution for Claude login.') - } - const created = await runWslProcess({ - distro: location.wslDistro, - loginPath: 'none', - shell: 'bash', - script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', - timeoutMs: 5000 - }) - const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() - if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { - throw new Error('Could not create a temporary WSL Claude login directory.') - } - return { - windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), - linuxPath, - wslDistro: location.wslDistro - } -} - -async function removeTemporaryClaudeConfigDir(config: ClaudeCommandConfig): Promise { - if (config.linuxPath && config.wslDistro) { - try { - await runWslProcess({ - distro: config.wslDistro, - loginPath: 'none', - program: 'rm', - args: ['-rf', '--', config.linuxPath], - timeoutMs: 5000 - }) - } catch { - // Cleanup cannot mask the login result. - } - return - } - rmSync(config.windowsPath, { recursive: true, force: true }) } diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts deleted file mode 100644 index b430979655c6..000000000000 --- a/src/main/claude-accounts/claude-managed-auth-storage.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import { stripSharedClaudeCredentialFields } from './shared-credential-fields' -import { - getClaudeManagedAccountsRoot, - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from './managed-auth-path' -import { - deleteManagedClaudeKeychainCredentials, - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' - -export type ClaudeManagedAuthLocation = { - managedAuthPath: string - managedAuthRuntime: 'host' | 'wsl' - wslDistro: string | null - wslLinuxAuthPath: string | null -} - -export type ClaudeManagedAuthSnapshot = { - credentialsJson: string | null - oauthAccountJson: string | null -} - -export type ClaudeManagedAuthTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -export class ClaudeManagedAuthStorage { - async create( - accountId: string, - target?: ClaudeManagedAuthTarget - ): Promise { - const wslAuth = await this.tryCreateWsl(accountId, target) - if (wslAuth) { - return wslAuth - } - const managedAuthPath = join(this.getRoot(), accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true, mode: 0o700 }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, { - encoding: 'utf-8', - mode: 0o600 - }) - return { - managedAuthPath: await this.assertOwned(managedAuthPath, accountId), - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - } - - async writeAuth( - accountId: string, - managedAuthPath: string, - captured: { credentialsJson: string; oauthAccount: unknown } - ): Promise { - await this.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) - await this.writeOauthAccount(accountId, managedAuthPath, captured.oauthAccount) - } - - async writeCredentials( - accountId: string, - managedAuthPath: string, - credentialsJson: string - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (!parseWslUncPath(trustedPath)) { - credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) - } - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) - } - } - - async writeOauthAccount( - accountId: string, - managedAuthPath: string, - oauthAccount: unknown - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - writeClaudeManagedAuthFile( - trustedPath, - 'oauth-account.json', - `${JSON.stringify(oauthAccount, null, 2)}\n` - ) - } - - async readSnapshot( - accountId: string, - managedAuthPath: string - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - return { - credentialsJson: - process.platform === 'darwin' - ? await readManagedClaudeKeychainCredentials(accountId) - : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), - oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') - } - } - - async restoreCredentials( - accountId: string, - managedAuthPath: string, - snapshot: ClaudeManagedAuthSnapshot - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (process.platform === 'darwin') { - await (snapshot.credentialsJson !== null - ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) - : deleteManagedClaudeKeychainCredentials(accountId)) - } else if (snapshot.credentialsJson !== null) { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) - } else { - rmSync(join(trustedPath, '.credentials.json'), { force: true }) - } - } - - async restoreOauth( - accountId: string, - managedAuthPath: string, - snapshot: ClaudeManagedAuthSnapshot - ): Promise { - const trustedPath = await this.assertOwned(managedAuthPath, accountId) - if (snapshot.oauthAccountJson !== null) { - writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) - } else { - rmSync(join(trustedPath, 'oauth-account.json'), { force: true }) - } - } - - async remove(accountId: string, candidatePath: string): Promise { - try { - const managedAuthPath = await this.assertOwned(candidatePath, accountId) - rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) - } catch (error) { - console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) - } - await deleteManagedClaudeKeychainCredentials(accountId) - } - - async assertOwned(candidatePath: string, expectedAccountId?: string): Promise { - const wslInfo = parseWslUncPath(candidatePath) - if (wslInfo) { - return this.assertOwnedWsl(candidatePath, wslInfo, expectedAccountId) - } - this.getRoot() - const accountId = expectedAccountId ?? this.readAccountId(candidatePath) - if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { - adoptLegacyMarker: true - }) - if (!trustedPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return trustedPath - } - - private async tryCreateWsl( - accountId: string, - target?: ClaudeManagedAuthTarget - ): Promise { - if (process.platform !== 'win32' || target?.runtime !== 'wsl') { - return null - } - const requestedDistro = target.wslDistro?.trim() || undefined - const info = await runWslProcess({ - distro: requestedDistro, - loginPath: 'none', - shell: 'bash', - script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - timeoutMs: 5000 - }) - const [rawDistro, rawHome] = - info.code === 0 && !info.timedOut - ? info.stdout - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim()) - : [] - const distro = requestedDistro || rawDistro - const home = rawHome - if (!distro || !home?.startsWith('/')) { - throw new Error('Could not resolve the active WSL home directory for Claude login.') - } - const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` - const created = await runWslProcess({ - distro, - loginPath: 'none', - shell: 'bash', - script: 'umask 077; mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', - args: [linuxPath, accountId], - timeoutMs: 5000 - }) - if (created.code !== 0 || created.timedOut) { - throw new Error('Could not create the managed WSL Claude auth directory.') - } - const managedAuthPath = toWindowsWslPath(linuxPath, distro) - return { - managedAuthPath: await this.assertOwned(managedAuthPath, accountId), - managedAuthRuntime: 'wsl', - wslDistro: distro, - wslLinuxAuthPath: linuxPath - } - } - - private async assertOwnedWsl( - candidatePath: string, - wslInfo: NonNullable>, - expectedAccountId?: string - ): Promise { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') - } - if (process.platform !== 'win32') { - if ( - !existsSync(candidatePath) || - !existsSync(join(candidatePath, '.orca-managed-claude-auth')) - ) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return candidatePath - } - try { - const expected = expectedAccountId - ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` - : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"' - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - expected, - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - const canonicalPath = owned.stdout.trim() - if (owned.code !== 0 || owned.timedOut || !canonicalPath) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - return toWindowsWslPath(canonicalPath, wslInfo.distro) - } catch (error) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { - cause: error - }) - } - } - - private getRoot(): string { - const root = getClaudeManagedAccountsRoot() - mkdirSync(root, { recursive: true, mode: 0o700 }) - return root - } - - private readAccountId(candidatePath: string): string | null { - const relativePath = relative(resolve(this.getRoot()), resolve(candidatePath)) - const parts = relativePath.split(sep) - return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null - } -} diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts index 6727996886ff..957542fdd54e 100644 --- a/src/main/claude-accounts/claude-profile-history.test.ts +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -63,6 +63,23 @@ describe('Claude profile history sharing', () => { expect(fs.readFileSync(join(f.profileHome, 'projects/later.jsonl'), 'utf8')).toBe('later') } ) + itLinks( + "keeps Claude's live sessions per folder and undoes an earlier build's link", + async () => { + const f = fixture() + fs.mkdirSync(join(f.defaultHome, 'sessions')) + fs.writeFileSync(join(f.defaultHome, 'sessions/123.json'), 'default daemon') + fs.symlinkSync(join(f.defaultHome, 'sessions'), join(f.profileHome, 'sessions')) + expect((await f.share()).surfaces.sessions).toBe('synced') + expect(fs.existsSync(join(f.profileHome, 'sessions'))).toBe(false) + expect(fs.readFileSync(join(f.defaultHome, 'sessions/123.json'), 'utf8')).toBe( + 'default daemon' + ) + fs.mkdirSync(join(f.profileHome, 'sessions')) + expect((await f.share()).surfaces.sessions).toBe('unchanged') + expect(fs.lstatSync(join(f.profileHome, 'sessions')).isSymbolicLink()).toBe(false) + } + ) itLinks('recovers a directory swap interrupted before link publication', async () => { const f = fixture() fs.mkdirSync(join(f.profileHome, 'projects.orca-profile-merge')) diff --git a/src/main/claude-accounts/claude-profile-history.ts b/src/main/claude-accounts/claude-profile-history.ts index 03f13644fcff..dec67e9e0a90 100644 --- a/src/main/claude-accounts/claude-profile-history.ts +++ b/src/main/claude-accounts/claude-profile-history.ts @@ -1,4 +1,12 @@ -import { mkdirSync, readdirSync, realpathSync, rmdirSync, statSync, symlinkSync } from 'node:fs' +import { + mkdirSync, + readdirSync, + realpathSync, + rmdirSync, + statSync, + symlinkSync, + unlinkSync +} from 'node:fs' import { join } from 'node:path' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' import { resolveClaudeDefaultHome } from './claude-profile-paths' @@ -17,9 +25,9 @@ import { mergeClaudeProfilePromptHistory } from './claude-profile-prompt-history' +// Not `sessions`: Claude's live-process registry, bound to its own folder's daemon. export const CLAUDE_PROFILE_HISTORY_DIRS = [ 'projects', - 'sessions', 'session-env', 'file-history', 'shell-snapshots', @@ -136,6 +144,24 @@ function mergeDirectory( return 'linked' } +/** Undoes an earlier build's `sessions` link into the default home; Claude recreates the folder. */ +function unlinkSharedSessions(profile: string, home: string): ClaudeProfileSurfaceOutcome { + const source = join(profile, 'sessions') + if (!lstatIfPresent(source)?.isSymbolicLink()) { + return 'unchanged' + } + if (realpathSync(source) !== realpathSync(join(home, 'sessions'))) { + return 'user-owned' + } + try { + unlinkSync(source) + } catch { + // A Windows junction is removed as a directory. + rmdirSync(source) + } + return 'synced' +} + /** * Pools a profile's sessions and prompt history into the default home. Execution-host paths only; * callers go through provisionClaudeAccountProfile, which gates and creates the profile. @@ -156,6 +182,9 @@ export async function shareClaudeProfileHistory(args: { mergeDirectory(args.profileHome, defaultHome, name, platform, report) ) } + await runClaudeProfileSurface(report, 'sessions', () => + unlinkSharedSessions(args.profileHome, defaultHome) + ) await runClaudeProfileSurface(report, 'history.jsonl', () => mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, platform, report) ) diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index a0c04e3c9baa..f622d60e9dfb 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -2,6 +2,7 @@ import { lstatSync, readdirSync } from 'node:fs' import { join } from 'node:path' // Why type-only: scan workers import this module, and the router's setup graph must not load there. import type { ClaudeProfileRouter } from './claude-profile-router' +import type { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' /** A real directory; a link is false, so a history folder shared by link is not read twice. */ @@ -26,7 +27,7 @@ export function listClaudeProfileHomes(dataRoot: string): string[] { let installed: ClaudeProfileRouter | undefined -/** Installed by the host runtime only when routing is enabled; workers and child processes have none. */ +/** Installed by the desktop's account services; workers, child processes and orcad have none. */ export function installClaudeProfileRouter(router: ClaudeProfileRouter | undefined): void { installed = router } @@ -35,6 +36,17 @@ export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { return installed } +let installedWsl: ClaudeWslProfileRouter | undefined + +/** Windows only, beside the host router. */ +export function installClaudeWslProfileRouter(router: ClaudeWslProfileRouter | undefined): void { + installedWsl = router +} + +export function getClaudeWslProfileRouter(): ClaudeWslProfileRouter | undefined { + return installedWsl +} + /** A local or WSL pane's env with the routed account's pointer added; SSH panes keep theirs. */ export function withClaudeProfileTerminalEnv | undefined>( env: Env, @@ -46,8 +58,8 @@ export function withClaudeProfileTerminalEnv } /** - * Account `` folders the System default readers cannot see. Step 1 links history into the - * System default on macOS/Linux, so only Windows (or a cross-filesystem refusal) adds any. + * Account `` folders the System default readers cannot see. Setup links history into the + * System default on every platform, so only a folder setup could not link (e.g. across drives) adds any. */ export function claudeProfileHistoryDirs(surface: 'projects' | 'transcripts'): string[] { return (installed?.accountHomes() ?? []).map((home) => join(home, surface)).filter(isDirectory) diff --git a/src/main/claude-accounts/claude-profile-launch-errors.ts b/src/main/claude-accounts/claude-profile-launch-errors.ts new file mode 100644 index 000000000000..9e2d538458e8 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-launch-errors.ts @@ -0,0 +1,18 @@ +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +// Typed so a chat names the situation; a terminal reads the same message. +export function claudeProfileMissing(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_MISSING_MESSAGE), { + reason: 'claudeAccountFolderMissing' + }) +} + +export function claudeProfileSetupFailed(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE), { + reason: 'claudeAccountSetupFailed' + }) +} diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index f0d04f0fd734..070b486eeb20 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -12,7 +12,6 @@ import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) import { - assertOutsideDefaultClaudeHomes, describeClaudeProfile, prepareClaudeProfileDirectory, readClaudeProfileObject, @@ -57,10 +56,16 @@ describe('Claude profile namespace', () => { target, home: join(dir, 'claude-profiles/account-a/home') }) - prepareClaudeProfileDirectory(dir, profile, userHome) - expect( - JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) - ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) + const markSetUp = prepareClaudeProfileDirectory(dir, profile, userHome) + const marker = join(dir, 'claude-profiles/account-a/profile.json') + expect(existsSync(marker)).toBe(false) + markSetUp() + expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ + version: 1, + accountId: 'account-a', + runtime: 'wsl', + distro: 'Ubuntu' + }) // The host id is the caller's view of the host, so another caller's spelling is the same profile. prepareClaudeProfileDirectory( dir, @@ -121,11 +126,9 @@ describe('Claude profile namespace', () => { it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { const userHome = root() mkdirSync(join(userHome, '.claude')) - expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( - 'separate directories' - ) + const dataRoot = join(userHome, '.CLAUDE', 'orca') expect(() => - assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) + prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) ).toThrow('separate directories') }) it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index 74ee5222c8e0..ece12937405c 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -1,6 +1,10 @@ import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import * as hostPath from 'node:path' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import type { ExecutionHostId } from '../../shared/execution-host' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -93,23 +97,23 @@ function readOwnershipMarker(file: string): string | null { return ownershipRecord(version, accountId, runtime, distro) } -/** - * The only gate before writing into a profile: namespace, containment, no linked components, - * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. - */ -// Written by setup's ownership gate when setup starts, not when it completes: its absence means -// setup never started here, and its presence does not prove setup finished. +// Written when a first setup finishes, so its absence means no setup has finished here yet. export function claudeProfileMarkerPath(profile: ClaudeProfileDescriptor): string { const path = profile.target.runtime === 'wsl' ? hostPath.posix : hostPath return path.join(path.dirname(profile.home), 'profile.json') } +/** + * The only gate before writing into a profile: namespace, containment, no linked components, + * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating + * anything. Returns the step that writes the marker, which setup runs last. + */ export function prepareClaudeProfileDirectory( dataRoot: string, profile: ClaudeProfileDescriptor, userHome: string, userConfigDir?: string -): void { +): () => void { let expected: ClaudeProfileDescriptor try { expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) @@ -135,12 +139,14 @@ export function prepareClaudeProfileDirectory( ) } mkdirSync(profile.home, { recursive: true, mode: 0o700 }) - if (marker === null) { - writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + return () => { + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + } } } -export function assertClaudeProfileDescendant(root: string, destination: string): void { +function assertClaudeProfileDescendant(root: string, destination: string): void { const suffix = relative(resolve(root), resolve(destination)) if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) { throw new ClaudeProfileSurfaceError( @@ -184,7 +190,7 @@ function canonicalPath(file: string): string { } } -export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { +function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { const left = canonicalPath(profile) const right = canonicalPath(defaultHome) for (const [root, destination] of [ @@ -202,7 +208,7 @@ export function assertDistinctClaudeProfile(profile: string, defaultHome: string } /** Claude's default homes: a profile may never be, contain, or sit inside one. */ -export function assertOutsideDefaultClaudeHomes( +function assertOutsideDefaultClaudeHomes( profileHome: string, userHome: string, userConfigDir?: string @@ -214,16 +220,18 @@ export function assertOutsideDefaultClaudeHomes( } } -/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ -export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' - /** The user's own CLAUDE_CONFIG_DIR (their System default), or undefined for `~/.claude`. */ export function readUserClaudeConfigDir(env: NodeJS.ProcessEnv): string | undefined { const configDir = env.CLAUDE_CONFIG_DIR?.trim() const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV]?.trim() - if (!configDir || (injected && resolve(injected) === resolve(configDir))) { + if (!configDir) { return undefined } + if (injected && resolve(injected) === resolve(configDir)) { + // Why: an outer Orca's injected value carries the user's own beside it. + const user = env[CLAUDE_USER_CONFIG_DIR_ENV]?.trim() + return user ? resolve(user) : undefined + } return resolve(configDir) } diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 66bf0346d90d..30da554f29cc 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -13,13 +13,15 @@ vi.mock('../codex-accounts/fs-utils', async (original) => { return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } }) import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { CLAUDE_PROFILE_MEMORY_IMPORT, provisionClaudeProfile } from './claude-profile-provisioning' +import { provisionClaudeProfile } from './claude-profile-provisioning' const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } const ORCA_HOOK = { matcher: '', hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] } +// What Claude writes on a finished sign-in. +const LOGIN = { emailAddress: 'p@example.com' } const roots: string[] = [] function fixture() { const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) @@ -33,13 +35,8 @@ function fixture() { const read = (file: string): Record => JSON.parse(fs.readFileSync(file, 'utf8')) return { root, userHome, profileHome, source, json, read } } -const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => - provisionClaudeProfile({ - profileHome: f.profileHome, - userHome: f.userHome, - trustKeys, - platform: 'linux' - }) +const provision = (f: { profileHome: string; userHome: string }) => + provisionClaudeProfile({ profileHome: f.profileHome, userHome: f.userHome, platform: 'linux' }) afterEach(() => { vi.clearAllMocks() for (const dir of roots.splice(0)) { @@ -50,7 +47,7 @@ afterEach(() => { // Why: these create real symlinks, which Windows needs privilege for. const itLinks = it.skipIf(process.platform === 'win32') -describe('dormant Claude profile provisioning', () => { +describe('Claude profile refresh from the default home', () => { itLinks('links resources, keeps private directories and sees later global installs', async () => { const f = fixture() const linked = [ @@ -109,7 +106,7 @@ describe('dormant Claude profile provisioning', () => { expect(fs.readFileSync(join(f.source, 'workflows/saved.js'), 'utf8')).toBe('saved in profile') } ) - it('copies keybindings, keeps a profile edit and follows the default while unedited', async () => { + it('copies top-level files over the profile copy, edited or not', async () => { const f = fixture() const source = join(f.source, 'keybindings.json') const copy = join(f.profileHome, 'keybindings.json') @@ -117,58 +114,47 @@ describe('dormant Claude profile provisioning', () => { expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') expect(fs.lstatSync(copy).isSymbolicLink()).toBe(false) expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[]}') - fs.writeFileSync(source, '{"bindings":[1]}') - expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') - expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[1]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('unchanged') fs.writeFileSync(copy, '{"bindings":["profile"]}') fs.writeFileSync(source, '{"bindings":[2]}') - expect((await provision(f)).surfaces['keybindings.json']).toBe('user-owned') - expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":["profile"]}') - expect(fs.readFileSync(source, 'utf8')).toBe('{"bindings":[2]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[2]}') + // A file Claude does not know yet is shared the same way, with its mode. + fs.writeFileSync(join(f.source, 'statusline.sh'), 'echo hi') + fs.chmodSync(join(f.source, 'statusline.sh'), 0o755) + await provision(f) + expect(fs.statSync(join(f.profileHome, 'statusline.sh')).mode & 0o777).toBe(0o755) }) - it('shares future settings keys and hooks but excludes auth; profile edits survive reprovision', async () => { + it('copies settings whole, proxy key and auth included, over profile edits', async () => { const f = fixture() - f.json(join(f.source, 'settings.json'), { + const settings = { futureFeature: true, model: 'a', hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, - apiKeyHelper: 'secret', - awsAuthRefresh: 'secret', - awsCredentialExport: 'secret', - forceLoginMethod: 'secret', - forceLoginOrgUUID: 'secret', + apiKeyHelper: 'helper', env: { - ANTHROPIC_API_KEY: 'secret', - ANTHROPIC_AUTH_TOKEN: 'secret', - CLAUDE_CODE_OAUTH_TOKEN: 'secret', + ANTHROPIC_BASE_URL: 'https://proxy.example.test', + ANTHROPIC_API_KEY: 'proxy-key', + ANTHROPIC_AUTH_TOKEN: 'token', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth', NORMAL: 'yes' } - }) - fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'source') + } + f.json(join(f.source, 'settings.json'), settings) await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - futureFeature: true, - model: 'a', - hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, - env: { NORMAL: 'yes' } - }) - f.json(join(f.profileHome, 'settings.json'), { - futureFeature: true, - model: 'private', - env: { NORMAL: 'yes' } - }) - fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'private instructions') - f.json(join(f.source, 'settings.json'), { model: 'b', futureFeature: false }) - fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'updated source') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual(settings) + // A `/model` change made on the account lasts only until the next refresh. + f.json(join(f.profileHome, 'settings.json'), { ...settings, model: 'private' }) + f.json(join(f.source, 'settings.json'), { model: 'b' }) + expect((await provision(f)).surfaces['settings.json']).toBe('synced') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'b' }) + expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') + fs.rmSync(join(f.source, 'settings.json')) await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toMatchObject({ - model: 'private', - futureFeature: false - }) - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('private instructions') + expect(fs.existsSync(join(f.profileHome, 'settings.json'))).toBe(false) }) itLinks( - 're-adds a shared key the profile deleted and leaves a linked settings file alone', + 'restores settings the profile changed and leaves a linked settings file alone', async () => { const f = fixture() f.json(join(f.source, 'settings.json'), { model: 'a', theme: 'x' }) @@ -184,142 +170,175 @@ describe('dormant Claude profile provisioning', () => { expect(f.read(elsewhere)).toEqual({ mine: true }) } ) - it('removes a key the default dropped unless the profile changed it, and only keys Orca shared', async () => { - const f = fixture() - const settings = join(f.source, 'settings.json') - f.json(settings, { model: 'a', theme: 'x', apiKeyHelper: 'source-secret' }) - await provision(f) - f.json(join(f.profileHome, 'settings.json'), { - ...f.read(join(f.profileHome, 'settings.json')), - theme: 'mine', - local: true, - apiKeyHelper: 'profile-helper' - }) - f.json(settings, { apiKeyHelper: 'source-secret' }) - expect((await provision(f)).surfaces['settings.json']).toBe('merged') - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - theme: 'mine', - local: true, - apiKeyHelper: 'profile-helper' - }) - expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') - f.json(settings, { model: 'b' }) - await provision(f) - fs.rmSync(settings) - await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - theme: 'mine', - local: true, - apiKeyHelper: 'profile-helper' - }) - const ledger = f.read(join(f.profileHome, '.orca-profile.json')) - expect(JSON.stringify(ledger)).not.toContain('apiKeyHelper') - }) - it('removes nothing while the default settings or state are unreadable', async () => { - const f = fixture() - f.json(join(f.source, 'settings.json'), { model: 'a' }) - f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) - await provision(f) - fs.writeFileSync(join(f.source, 'settings.json'), '{bad') - fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') - await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a' }) - expect(f.read(join(f.profileHome, '.claude.json'))).toMatchObject({ - mcpServers: { a: {} }, - theme: 'dark' - }) - f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - await provision(f) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - theme: 'dark', - hasCompletedOnboarding: true - }) - }) - it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes credentials', async () => { + it('copies state except the login, caches and install ids, and never copies credentials', async () => { const f = fixture() fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') - f.json(join(f.userHome, '.claude.json'), { + const shared = { mcpServers: { local: { command: 'example' } }, theme: 'dark', - oauthAccount: { email: 'source' }, - userID: 'source-id' + hasCompletedOnboarding: true, + autoModeAccepted: true, + projects: { '/work': { hasTrustDialogAccepted: true } } + } + f.json(join(f.userHome, '.claude.json'), { + ...shared, + oauthAccount: { emailAddress: 'source@example.com' }, + primaryApiKey: 'sk-source', + userID: 'source-id', + machineID: 'source-machine', + firstStartTime: 'source-start', + firstStartVersion: '1.0.0', + groveConfigCache: { a: 1 }, + cachedArtifactRoster: [], + clientDataCacheSlots: {}, + passesEligibilityCache: {}, + passesLastSeen: 1, + claudeCodeFirstTokenDate: 'source-date', + startupPrefetchedAt: 1, + additionalModelOptionsAnsweredAt: 1, + artifactRosterDenied: true, + lastSeenOrgDefaultUpdatedAt: 1, + hasAvailableSubscription: true, + subscriptionNoticeCount: 2 }) - await provision(f) - expect(fs.existsSync(join(f.profileHome, '.claude.json'))).toBe(false) + // A folder with no state file yet gets one without a login, so Claude asks for its own. + expect((await provision(f)).surfaces['.claude.json']).toBe('synced') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual(shared) + // Published whole through a staged file, which is gone afterwards. + expect(fs.readdirSync(f.profileHome).filter((name) => name.endsWith('.tmp'))).toEqual([]) expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) + fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') f.json(join(f.profileHome, '.claude.json'), { - oauthAccount: { email: 'profile' }, + oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', - projects: { '/work': { allowedTools: ['Read'] } } + groveConfigCache: { b: 2 }, + theme: 'light', + onlyHere: true }) - await provision(f, ['/work']) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - oauthAccount: { email: 'profile' }, + oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', - mcpServers: { local: { command: 'example' } }, - theme: 'dark', - hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } + groveConfigCache: { b: 2 }, + onlyHere: true, + ...shared }) + expect((await provision(f)).surfaces['.claude.json']).toBe('unchanged') expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( 'SOURCE_CREDENTIAL_BYTES' ) expect(fs.readFileSync(join(f.profileHome, '.credentials.json'), 'utf8')).toBe( 'PROFILE_CREDENTIAL_BYTES' ) - for (const name of fs.readdirSync(f.profileHome)) { - const file = join(f.profileHome, name) - if (fs.lstatSync(file).isFile()) { - expect(fs.readFileSync(file, 'utf8')).not.toContain('SOURCE_CREDENTIAL_BYTES') + }) + itLinks( + "shares user content, never Claude's runtime state, live sessions or login files", + async () => { + const f = fixture() + const unsharedDirs = [ + 'daemon', + 'jobs', + 'state', + 'sessions', + 'teams', + 'ide', + 'remote-control', + 'shares', + 'uploads', + 'storage-v2', + 'downloads', + 'scratch', + 'agent-memory-local', + '.hidden-dir' + ] + for (const name of [...unsharedDirs, 'skills', 'plugins', 'my-own-dir']) { + fs.mkdirSync(join(f.source, name)) + } + const unsharedFiles = [ + 'daemon.status.json', + '.credentials.json', + '.session_ingress_token', + 'hfi-auth.json', + 'server-sessions.json', + 'active-time.json', + 'computer-use.lock', + 'gh-pr-status-cache.json', + 'loop.md', + 'policy-limits.json', + 'mcp-needs-auth-cache.json', + 'stats-cache.json', + '.last-update-check', + '.config.json', + '.claude.json', + '.claude.json.backup.1' + ] + for (const name of unsharedFiles) { + fs.writeFileSync(join(f.source, name), 'x') } + // The account's own runtime file is never overwritten from the default home. + fs.writeFileSync(join(f.profileHome, 'server-sessions.json'), 'mine') + await provision(f) + for (const name of ['skills', 'plugins', 'my-own-dir']) { + expect(fs.realpathSync(join(f.profileHome, name))).toBe( + fs.realpathSync(join(f.source, name)) + ) + } + for (const name of [...unsharedDirs, ...unsharedFiles].filter( + (name) => name !== 'server-sessions.json' + )) { + expect(fs.existsSync(join(f.profileHome, name))).toBe(false) + } + expect(fs.readFileSync(join(f.profileHome, 'server-sessions.json'), 'utf8')).toBe('mine') } - }) - it('still forces onboarding and trust when the personal state is unreadable', async () => { + ) + it('merges trusted folders and MCP servers per entry, never untrusting an account folder', async () => { const f = fixture() - fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) - const report = await provision(f, ['/work']) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - hasCompletedOnboarding: true, - projects: { '/work': { hasTrustDialogAccepted: true } } + f.json(join(f.userHome, '.claude.json'), { + projects: { + '/shared': { hasTrustDialogAccepted: false, allowedTools: ['Read'] }, + '/default-only': { hasTrustDialogAccepted: true } + }, + mcpServers: { shared: { command: 'default' } } }) - expect(report.warnings).toContainEqual( - expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) - ) - }) - it('skips only folder trust when the profile projects value is malformed', async () => { - const f = fixture() - f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', projects: 'bad' }) - const report = await provision(f, ['/work']) - expect(report.surfaces['.claude.json']).toBe('merged') + f.json(join(f.profileHome, '.claude.json'), { + oauthAccount: LOGIN, + projects: { + '/shared': { hasTrustDialogAccepted: true }, + '/account-only': { hasTrustDialogAccepted: true, allowedTools: ['Bash'] } + }, + mcpServers: { shared: { command: 'account' }, mine: { command: 'mine' } } + }) + await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - projects: 'bad', - theme: 'dark', - hasCompletedOnboarding: true + oauthAccount: LOGIN, + projects: { + '/shared': { hasTrustDialogAccepted: true, allowedTools: ['Read'] }, + '/account-only': { hasTrustDialogAccepted: true, allowedTools: ['Bash'] }, + '/default-only': { hasTrustDialogAccepted: true } + }, + mcpServers: { shared: { command: 'default' }, mine: { command: 'mine' } } }) }) it('skips the state write while Claude holds its lock and records nothing for it', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) fs.mkdirSync(join(f.profileHome, '.claude.json.lock')) const report = await provision(f) expect(report.surfaces['.claude.json']).toBe('failed') expect(report.warnings).toContainEqual( expect.objectContaining({ surface: '.claude.json', code: 'locked' }) ) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + oauthAccount: LOGIN + }) fs.rmdirSync(join(f.profileHome, '.claude.json.lock')) expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') }) - it('records a shared value only after its write succeeded', async () => { + it('retries a failed settings copy on the next refresh', async () => { const f = fixture() f.json(join(f.source, 'settings.json'), { theme: 'dark' }) await provision(f) @@ -328,55 +347,20 @@ describe('dormant Claude profile provisioning', () => { throw Object.assign(new Error('busy'), { code: 'EBUSY' }) }) expect((await provision(f)).surfaces['settings.json']).toBe('failed') - expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect((await provision(f)).surfaces['settings.json']).toBe('synced') expect(f.read(join(f.profileHome, 'settings.json')).theme).toBe('light') }) - itLinks('resets an unreadable ledger instead of blocking every surface', async () => { - const f = fixture() - fs.mkdirSync(join(f.source, 'skills')) - f.json(join(f.source, 'settings.json'), { model: 'a' }) - fs.writeFileSync(join(f.profileHome, '.orca-profile.json'), '') - const report = await provision(f) - expect(report.surfaces.skills).toBe('linked') - expect(report.surfaces['settings.json']).toBe('merged') - expect(report.warnings).toEqual([]) - expect(f.read(join(f.profileHome, '.orca-profile.json')).keys).toEqual({ - 'settings.json': { model: '"a"' } - }) - }) - itLinks( - 'keys shared values by surface, so another spelling of the profile keeps sharing', - async () => { - const f = fixture() - fs.writeFileSync(join(f.source, 'keybindings.json'), 'v1') - f.json(join(f.source, 'settings.json'), { model: 'a' }) - await provision(f) - fs.writeFileSync(join(f.source, 'keybindings.json'), 'v2') - f.json(join(f.source, 'settings.json'), { model: 'b' }) - const aliasRoot = fs.mkdtempSync(join(tmpdir(), 'claude-profile-alias-')) - roots.push(aliasRoot) - const alias = join(aliasRoot, 'link') - fs.symlinkSync(f.root, alias) - const report = await provisionClaudeProfile({ - profileHome: join(alias, 'profile'), - userHome: f.userHome, - platform: 'linux' - }) - expect(report.surfaces['keybindings.json']).toBe('synced') - expect(f.read(join(f.profileHome, 'settings.json')).model).toBe('b') - } - ) it('imports the personal CLAUDE.md instead of copying it, so Claude loads it once', async () => { const f = fixture() await provision(f) expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( - CLAUDE_PROFILE_MEMORY_IMPORT - ) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('@~/.claude/CLAUDE.md\n') fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') + fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'edited in the profile') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') }) itLinks( "shares from the user's own CLAUDE_CONFIG_DIR, copying its CLAUDE.md and state", @@ -388,7 +372,7 @@ describe('dormant Claude profile provisioning', () => { f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') @@ -416,6 +400,7 @@ describe('dormant Claude profile provisioning', () => { }) it('leaves malformed profile state unchanged', async () => { const f = fixture() + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) fs.writeFileSync(join(f.profileHome, '.claude.json'), '{bad') expect((await provision(f)).warnings).toContainEqual( expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 752f7fee3f38..f637c3e74143 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -1,11 +1,14 @@ -import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' -import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { - applyClaudeFolderTrust, resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' +import { getManagedScriptFileName, removeManagedHooks } from '../claude/hook-settings' +import { publishFileWithoutOverwrite } from '../codex-accounts/fs-utils' +import { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' import { lstatIfPresent } from './claude-profile-prompt-history' import { @@ -16,145 +19,248 @@ import { type ClaudeProfileReport, type ClaudeProfileSurfaceOutcome } from './claude-profile-report' -import { - claudeProfileLedgerPath, - dropClaudeProfileKeys, - linkClaudeProfileDirectory, - mergeClaudeProfileKeys, - readClaudeProfileLedger, - syncClaudeProfileFile, - writeClaudeProfileLedger, - type ClaudeProfileLedger -} from './claude-profile-sharing' +import { copyClaudeProfileFile, linkClaudeProfileDirectory } from './claude-profile-sharing' + +const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' -export const CLAUDE_PROFILE_RESOURCE_DIRS = [ - 'skills', - 'plugins', - 'agents', - 'commands', - 'output-styles', - 'rules', - 'themes', - 'workflows' +/** + * Mirrors the list Claude itself leaves out when it seeds a fresh config folder from ~/.claude + * (its self-hosted runner's host-config snapshot): login, live runtime state and caches. + */ +const CLAUDE_RUNTIME_STATE_ENTRIES = [ + '.claude.json', + '.claude.json.backup', + '.credentials.json', + '.session_ingress_token', + 'active-time.json', + 'antproto.json', + 'api-dumps', + 'backups', + 'bridge-spawn', + 'cache', + 'ccr', + 'ccr-home-seed.json', + 'chrome', + 'computer-use.lock', + 'daemon', + 'debug', + 'downloads', + 'dump-prompts', + 'feedback', + 'feedback-bundles', + 'file-history', + 'file-transfers', + 'gh-pr-status-cache.json', + 'hfi-auth.json', + 'history.jsonl', + 'ide', + 'image-cache', + 'jobs', + 'local', + 'local-settings', + 'logs', + 'loop.md', + 'mcp-discovery-cache', + 'mcp-needs-auth-cache.json', + 'mcp-skill-archives', + 'paste-cache', + 'plans', + 'policy-limits.json', + 'project-settings', + 'projects', + 'remote', + 'remote-control', + 'remote-settings.json', + 'scratch', + 'seed-admin', + 'server-sessions.json', + 'server.lock', + 'session-env', + 'sessions', + 'shares', + 'shell-snapshots', + 'startup-perf', + 'state', + 'stats-cache.json', + 'statsig', + 'storage-v2', + 'systemd', + 'tasks', + 'teams', + 'telemetry', + 'todos', + 'traces', + 'uploads', + 'usage-data' ] as const -// Copied, not linked: a rename-replace save (Claude's own, or an editor's) would cut a link. -export const CLAUDE_PROFILE_RESOURCE_FILES = ['CLAUDE.md', 'keybindings.json'] as const -export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' -const PRIVATE_KEYS = new Set([ - 'apiKeyHelper', - 'awsAuthRefresh', - 'awsCredentialExport', - 'forceLoginMethod', - 'forceLoginOrgUUID' + +/** Never linked or copied: runtime state, settings.json (copied whole) and history (linked the other way). */ +const UNSHARED_ENTRIES: ReadonlySet = new Set([ + ...CLAUDE_RUNTIME_STATE_ENTRIES, + 'settings.json', + ...CLAUDE_PROFILE_HISTORY_DIRS ]) -const PRIVATE_ENV = new Set([ - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'CLAUDE_CODE_OAUTH_TOKEN' + +function isSharedEntry(name: string): boolean { + // Why the prefixes: Claude's own seeding skips every hidden entry (state files, tokens, locks), + // daemon files and per-folder agent memory too. + return ( + !UNSHARED_ENTRIES.has(name) && + !name.startsWith('.') && + !name.startsWith('daemon') && + !name.startsWith('agent-memory') + ) +} + +/** + * Mirrors what Claude itself resets in .claude.json when it signs out (its logout, 2.1.295), plus + * the subscription notices it resets with them. Onboarding, which logout also resets, is copied. + */ +const CLAUDE_LOGOUT_STATE_KEYS = [ + 'oauthAccount', + 'additionalModelOptionsCache', + 'additionalModelOptionsAnsweredAt', + 'additionalModelCostsCache', + 'modelAccessCache', + 'orgModelDefaultCache', + 'cachedArtifactRoster', + 'artifactRosterDenied', + 'lastSeenOrgDefaultUpdatedAt', + 'clientDataCache', + 'clientDataCacheSlots', + 'autoCompactWindowsCache', + 'cachedUsageUtilization', + 'metricsStatusCache', + 'metricsStatusCacheByPrincipal', + 'githubWebConnectionStatusCache', + 'startupPrefetchedAt', + 'subscriptionNoticeCount', + 'hasAvailableSubscription' +] as const + +/** Never copied: logout's keys, the install ids Claude writes before any sign-in, and logins. */ +const ACCOUNT_BOUND_STATE_KEYS: ReadonlySet = new Set([ + ...CLAUDE_LOGOUT_STATE_KEYS, + 'userID', + 'machineID', + 'firstStartTime', + 'firstStartVersion', + // Why: a Console API key login; copied, it would outrank the account's own login. + 'primaryApiKey', + // Why: Claude never refetches it. + 'claudeCodeFirstTokenDate' ]) -const SHARED_STATE_KEYS = ['mcpServers', 'theme'] -// Why `hooks` is shared: Orca writes identical entries into every folder, so the installer that -// runs after the merge finds them present and the user's own hooks keep running in every account. -function pickSettings(source: Record): Record { - return Object.fromEntries( - Object.entries(source) - .filter(([key]) => !PRIVATE_KEYS.has(key)) - .map(([key, value]) => { - if (key === 'env' && value && typeof value === 'object' && !Array.isArray(value)) { - return [ - key, - Object.fromEntries(Object.entries(value).filter(([name]) => !PRIVATE_ENV.has(name))) - ] - } - return [key, value] - }) +function isAccountBoundState(key: string): boolean { + // Why the patterns too: account caches logout leaves behind (groveConfigCache, passes*). + return ( + ACCOUNT_BOUND_STATE_KEYS.has(key) || + key.includes('Cache') || + key.startsWith('cached') || + key.startsWith('passes') ) } -function mergeSettings( - source: string, - target: string, - ledger: ClaudeProfileLedger -): ClaudeProfileSurfaceOutcome { - if (lstatIfPresent(target)?.isSymbolicLink()) { - return 'user-owned' - } - const existing = readClaudeProfileObject(target) +// Why without Orca's hooks: the profile's own hook install owns those, so differing there alone +// would recopy, and the install rewrite, the file on every launch. +function settingsWithoutOrcaHooks(value: Record): string { + return JSON.stringify(removeManagedHooks(value, getManagedScriptFileName()).config) +} + +/** The whole file: the default home is the master copy, proxy address and key included. */ +function copySettings(source: string, target: string): ClaudeProfileSurfaceOutcome { const input = readClaudeProfileObject(source) - if (existing.kind === 'unavailable' || input.kind === 'unavailable') { - throw new ClaudeProfileSurfaceError('unreadable', 'Claude settings.json is unreadable') + const current = readClaudeProfileObject(target) + // Why by value: the hook installer rewrites the copy in its own layout after every refresh. + if ( + input.kind === 'present' && + current.kind === 'present' && + !lstatIfPresent(target)?.isSymbolicLink() && + settingsWithoutOrcaHooks(input.value) === settingsWithoutOrcaHooks(current.value) + ) { + return 'unchanged' + } + const outcome = copyClaudeProfileFile(source, target) + if (outcome === 'absent' && lstatIfPresent(target)?.isFile()) { + rmSync(target, { force: true }) + return 'synced' + } + return outcome +} + +/** Keyed by folder path or server name: entries the account recorded on its own are kept. */ +const MERGED_BY_ENTRY: ReadonlySet = new Set(['projects', 'mcpServers']) + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function mergeEntries(key: string, current: unknown, source: unknown): unknown { + if (!isPlainRecord(current) || !isPlainRecord(source)) { + return source } - const config: Record = existing.kind === 'present' ? { ...existing.value } : {} - const desired = pickSettings(input.kind === 'present' ? input.value : {}) - const written = { ...ledger.keys['settings.json'] } - const changed = - mergeClaudeProfileKeys(config, desired, written).length + - dropClaudeProfileKeys(config, desired, written).length - if (changed > 0) { - writeFileAtomically(target, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + const merged: Record = { ...current, ...source } + if (key === 'projects') { + for (const [path, entry] of Object.entries(source)) { + const own = current[path] + // Why: a folder trusted in the account, often by Orca just before launch, stays trusted. + if (isPlainRecord(own) && own.hasTrustDialogAccepted === true && isPlainRecord(entry)) { + merged[path] = { ...entry, hasTrustDialogAccepted: true } + } + } } - // Committed only after the write, so a failed write never marks an unwritten value as shared. - ledger.keys['settings.json'] = written - return changed > 0 ? 'merged' : existing.kind === 'absent' ? 'absent' : 'unchanged' + return merged } -async function mergeState(args: { - source: string - target: string - ledger: ClaudeProfileLedger - trustKeys: readonly string[] - report: ClaudeProfileReport -}): Promise { - if (lstatIfPresent(args.target)?.isSymbolicLink()) { +async function copyState(source: string, target: string): Promise { + if (lstatIfPresent(target)?.isSymbolicLink()) { return 'user-owned' } - const input = readClaudeProfileObject(args.source) + const input = readClaudeProfileObject(source) if (input.kind === 'unavailable') { - // Why: onboarding and trust don't depend on the personal state; only its shared keys wait. - const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') - warnClaudeProfile(args.report, '.claude.json', error) + throw new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') } - const source = input.kind === 'present' ? input.value : {} - const desired = Object.fromEntries( - SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) - ) - let written: Record = {} - const outcome = await updateClaudeGlobalConfig(args.target, (current) => { - const config = { ...current } - written = { ...args.ledger.keys['.claude.json'] } - let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 - if ( - input.kind !== 'unavailable' && - dropClaudeProfileKeys(config, desired, written).length > 0 - ) { - changed = true - } - // Why: otherwise first launch opens the onboarding wizard, where a stray Enter starts a login that rebinds the profile. - if (config.hasCompletedOnboarding !== true) { - config.hasCompletedOnboarding = true - changed = true + if (input.kind === 'absent') { + return 'absent' + } + const shared = Object.entries(input.value).filter(([key]) => !isAccountBoundState(key)) + if (!lstatIfPresent(target)) { + const staged = `${target}.${process.pid}.${randomUUID()}.tmp` + try { + writeFileSync(staged, `${JSON.stringify(Object.fromEntries(shared), null, 2)}\n`, { + flag: 'wx', + mode: 0o600 + }) + // Why a link, not a rename: whole or absent, and Claude's own file, created meanwhile, wins. + if (publishFileWithoutOverwrite(staged, target)) { + return 'synced' + } + } finally { + rmSync(staged, { force: true }) } - // Why: a malformed `projects` refuses only trust; onboarding and shared keys still apply. - const trust = args.trustKeys.length > 0 ? applyClaudeFolderTrust(config, args.trustKeys) : null - if (trust?.kind === 'changed') { - return trust + } + // Why no deletions: a key the default home lacks may be one Claude added only in this folder. + const outcome = await updateClaudeGlobalConfig(target, (current) => { + const config = { ...current } + for (const [key, value] of shared) { + config[key] = MERGED_BY_ENTRY.has(key) ? mergeEntries(key, current[key], value) : value } - return changed ? { kind: 'changed', config } : { kind: 'unchanged' } + return JSON.stringify(config) === JSON.stringify(current) + ? { kind: 'unchanged' } + : { kind: 'changed', config } }) - if (outcome === 'missing-config') { - // Why: no state file means no completed login; writing one would fabricate an account. - return 'absent' - } - if (outcome === 'locked' || outcome === 'unreadable') { - throw new ClaudeProfileSurfaceError(outcome, `Profile Claude state is ${outcome}`) + if (outcome === 'locked' || outcome === 'unreadable' || outcome === 'missing-config') { + throw new ClaudeProfileSurfaceError( + outcome === 'locked' ? 'locked' : 'unreadable', + `Profile Claude state is ${outcome}` + ) } - args.ledger.keys['.claude.json'] = written return outcome === 'updated' ? 'merged' : 'unchanged' } /** - * Shares the default home's config into a profile. Execution-host paths; never touches credentials. + * Refreshes a profile from the default home, the master copy: everything but the login and + * account-bound items. Execution-host paths; never writes the default home or credentials. * Callers go through provisionClaudeAccountProfile, which gates and creates the profile. */ export async function provisionClaudeProfile(args: { @@ -163,40 +269,40 @@ export async function provisionClaudeProfile(args: { /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ userConfigDir?: string platform?: NodeJS.Platform - trustKeys?: readonly string[] }): Promise { const platform = args.platform ?? process.platform const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) const report = createClaudeProfileReport() - const ledgerPath = claudeProfileLedgerPath(args.profileHome) - const ledger = readClaudeProfileLedger(ledgerPath) - const recorded = JSON.stringify(ledger) - for (const name of CLAUDE_PROFILE_RESOURCE_DIRS) { - await runClaudeProfileSurface(report, name, () => - linkClaudeProfileDirectory(join(defaultHome, name), join(args.profileHome, name), platform) - ) - } // Why only for ~/.claude: Claude also loads it as a parent folder's memory for projects under // home, so a copy would load twice; a custom CLAUDE_CONFIG_DIR is copied, as superset does. const imported = resolve(defaultHome) === resolve(args.userHome, '.claude') - const memory = imported ? () => CLAUDE_PROFILE_MEMORY_IMPORT : undefined - for (const name of CLAUDE_PROFILE_RESOURCE_FILES) { - await runClaudeProfileSurface(report, name, () => - syncClaudeProfileFile( - join(defaultHome, name), - join(args.profileHome, name), - name, - ledger, - name === 'CLAUDE.md' ? memory : undefined - ) - ) + let names: string[] = [] + try { + names = readdirSync(defaultHome).filter(isSharedEntry) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + warnClaudeProfile(report, 'profile', error) + } + } + for (const name of names) { + const source = join(defaultHome, name) + const target = join(args.profileHome, name) + await runClaudeProfileSurface(report, name, () => { + // Why stat, not lstat: a linked entry is shared as what it points at. + const entry = statSync(source, { throwIfNoEntry: false }) + if (entry?.isDirectory()) { + return linkClaudeProfileDirectory(source, target, platform) + } + if (!entry?.isFile()) { + return 'absent' + } + return name === 'CLAUDE.md' && imported + ? copyClaudeProfileFile(source, target, () => Buffer.from(CLAUDE_PROFILE_MEMORY_IMPORT)) + : copyClaudeProfileFile(source, target) + }) } await runClaudeProfileSurface(report, 'settings.json', () => - mergeSettings( - join(defaultHome, 'settings.json'), - join(args.profileHome, 'settings.json'), - ledger - ) + copySettings(join(defaultHome, 'settings.json'), join(args.profileHome, 'settings.json')) ) const statePath = (configDir: string | undefined): string => resolveClaudeGlobalConfigFile({ @@ -206,20 +312,7 @@ export async function provisionClaudeProfile(args: { exists: existsSync }) await runClaudeProfileSurface(report, '.claude.json', () => - mergeState({ - source: statePath(args.userConfigDir), - target: statePath(args.profileHome), - ledger, - trustKeys: args.trustKeys ?? [], - report - }) + copyState(statePath(args.userConfigDir), statePath(args.profileHome)) ) - await runClaudeProfileSurface(report, 'ledger', () => { - if (JSON.stringify(ledger) === recorded) { - return 'unchanged' - } - writeClaudeProfileLedger(ledgerPath, ledger) - return 'synced' - }) return report } diff --git a/src/main/claude-accounts/claude-profile-report.ts b/src/main/claude-accounts/claude-profile-report.ts index 2a5baae068ed..0ceb42b504a4 100644 --- a/src/main/claude-accounts/claude-profile-report.ts +++ b/src/main/claude-accounts/claude-profile-report.ts @@ -1,19 +1,5 @@ -import type { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' -import type { - CLAUDE_PROFILE_RESOURCE_DIRS, - CLAUDE_PROFILE_RESOURCE_FILES -} from './claude-profile-provisioning' - -export type ClaudeProfileSurface = - | 'profile' - | (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] - | 'history.jsonl' - | (typeof CLAUDE_PROFILE_RESOURCE_DIRS)[number] - | (typeof CLAUDE_PROFILE_RESOURCE_FILES)[number] - | 'settings.json' - | '.claude.json' - | 'ledger' - | 'hooks' +/** A setup step: 'profile', 'hooks', or the name of what it shares (`skills`, `settings.json`). */ +export type ClaudeProfileSurface = string export type ClaudeProfileSurfaceOutcome = | 'linked' diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 5391072d0bbe..f65069ebe7a6 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -1,4 +1,6 @@ +import { spawnSync } from 'node:child_process' import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -9,22 +11,44 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { + claudeProfileMarkerPath, + describeClaudeProfile, + prepareClaudeProfileDirectory, + type ClaudeProfileDescriptor +} from './claude-profile-paths' import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - ClaudeProfileRouter, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' +import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileHistoryDirs, installClaudeProfileRouter } from './claude-profile-installed-router' +// Why: removal deletes macOS Keychain items; the test must never reach the real Keychain. +// The login shell exports what a Dock launch's own env lacks. +const shell = vi.hoisted(() => ({ hang: false })) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: () => + shell.hang + ? new Promise(() => {}) + : Promise.resolve({ CLAUDE_CONFIG_DIR: resolve('/shell/own') }) +})) +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async () => { + throw new Error('The specified item could not be found in the keychain.') + } +})) + const roots: string[] = [] afterEach(() => { + shell.hang = false installClaudeProfileRouter(undefined) roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) @@ -52,13 +76,19 @@ function fixture(env: NodeJS.ProcessEnv = {}) { agentStatusHooksEnabled: false, disabledTuiAgents: [] } - // Stands in for the worker; each setup stays pending until the test settles it. + // Stands in for the worker; like it, stays pending until settled and marks the folder last. const setup: { calls: number; outcome: ClaudeProfileSetupReport['outcome']; settle: () => void } = { calls: 0, outcome: 'prepared', settle: () => {} } - const runSetup = () => { + const runSetup = ({ profile }: { profile: ClaudeProfileDescriptor }) => { setup.calls += 1 + mkdirSync(profile.home, { recursive: true }) return new Promise((resolve) => { - setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + setup.settle = () => { + if (setup.outcome === 'prepared') { + writeFileSync(claudeProfileMarkerPath(profile), '{}') + } + resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) + } }) } const router = new ClaudeProfileRouter({ @@ -66,20 +96,129 @@ function fixture(env: NodeJS.ProcessEnv = {}) { dataRoot, userHome, env, - runSetup + runSetup, + refreshWaitMs: 500 }) const home = (id: string) => join(dataRoot, 'claude-profiles', id, 'home') return { root, userHome, dataRoot, settings, router, home, setup } } +function signIn(stateDir: string, email: string, organizationUuid?: string): void { + mkdirSync(stateDir, { recursive: true }) + writeFileSync( + join(stateDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email, organizationUuid } }) + ) +} + describe('ClaudeProfileRouter', () => { - it('publishes the selected folder, System default as empty, and no file without accounts', () => { + it('runs an account with no login of its own on System default while that is signed in to it', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + // ~/.claude's state file sits in the home folder; the email matches case-insensitively. + signIn(f.userHome, 'A@Example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ + configDir: join(f.userHome, '.claude'), + provenance: 'system' + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + expect(f.router.routedHome()).toBeNull() + // The pointer names the folder actually chosen, so a typed claude agrees with Orca's launches. + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe('') + expect(f.router.coveredBySystemDefault('a')).toBe(true) + + // After the account signs in to its own folder, the next launch uses it. + signIn(f.home('a'), 'a@example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ + configDir: f.home('a') + }) + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) + expect(f.router.coveredBySystemDefault('a')).toBe(false) + }) + + it('tells whether a terminal opened before routing runs another account than the selected one', () => { + const f = fixture() + expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) + signIn(f.userHome, 'a@example.test') + expect(f.router.systemDefaultRunsAnotherAccount()).toBe(false) + // The account's own login names it once it has one. + const other = fixture() + signIn(other.userHome, 'a@example.test') + signIn(other.home('a'), 'someone-else@example.test') + expect(other.router.systemDefaultRunsAnotherAccount()).toBe(true) + f.settings.activeClaudeManagedAccountId = null + expect(f.router.systemDefaultRunsAnotherAccount()).toBeNull() + }) + + it('keeps an account on its own folder when System default is signed in to another email, or out', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + signIn(f.userHome, 'b@example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + writeFileSync(join(f.userHome, '.claude.json'), '{}') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it('never covers an account saved for another organization of the same email', () => { + const f = fixture() + f.settings.claudeManagedAccounts = f.settings.claudeManagedAccounts.map((account) => ({ + ...account, + organizationUuid: account.id === 'a' ? 'org-a' : null + })) + signIn(f.userHome, 'a@example.test', 'org-b') + expect(f.router.coveredBySystemDefault('a')).toBe(false) + expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) + signIn(f.userHome, 'a@example.test', 'org-a') + expect(f.router.coveredBySystemDefault('a')).toBe(true) + // Either side naming no organization compares by email alone. + signIn(f.userHome, 'b@example.test', 'org-b') + expect(f.router.coveredBySystemDefault('b')).toBe(true) + }) + + it("compares against the user's own CLAUDE_CONFIG_DIR login when they set one", async () => { + const own = resolve(mkdtempSync(join(tmpdir(), 'claude-router-own-'))) + roots.push(own) + const f = fixture({ CLAUDE_CONFIG_DIR: own }) + signIn(f.userHome, 'b@example.test') + signIn(own, 'a@example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: own }) + // A covered account's folder is neither needed nor refreshed for the launch. + expect(f.setup.calls).toBe(0) + }) + + it('prepares an account folder for sign-in only when setup succeeds', async () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/custom/claude') }) + expect(f.router.accountHome('b')).toBe(f.home('b')) + expect(f.router.userConfigDir()).toBe(resolve('/custom/claude')) + const prepared = f.router.prepareAccount('b') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(prepared).resolves.toBe(f.home('b')) + f.setup.outcome = 'refused' + const refused = f.router.prepareAccount('b') + await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + f.setup.settle() + await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + + it("reads an older Orca's copy-based switching from the System default snapshot it left", () => { + const f = fixture() + expect(f.router.copiedLoginIntoSystemDefault()).toBe(false) + mkdirSync(join(f.dataRoot, 'claude-runtime-auth'), { recursive: true }) + writeFileSync(join(f.dataRoot, 'claude-runtime-auth', 'system-default-auth.json'), '{}') + expect(f.router.copiedLoginIntoSystemDefault()).toBe(true) + }) + + it('publishes the selected folder, System default as empty, and no file without accounts', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) f.router.publish() expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) // publish returned while its setup is still running in the background. - expect(f.setup.calls).toBe(1) + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.settings.activeClaudeManagedAccountId = null f.router.publish() @@ -90,42 +229,121 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(f.router.pointerPath)).toBe(false) }) - it('makes a launch wait only for a folder that was never set up, reusing the running setup', async () => { + it('makes a launch wait for a first setup that never ran or is still running, reusing it', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) - f.router.publish() let launched = false const launch = f.router.prepareLaunch().then((prepared) => { launched = true return prepared }) - await Promise.resolve() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(launched).toBe(false) + const second = f.router.prepareLaunch() + f.setup.outcome = 'refused' + f.setup.settle() + await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) expect(f.setup.calls).toBe(1) + }) + + it('refreshes a set-up folder before each launch, which never fails or hangs on it', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + const first = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + await expect(first).resolves.toMatchObject({ configDir: f.home('a') }) - f.setup.outcome = 'refused' - const refused = f.router.prepareLaunch() + // The next launch waits for its refresh. + let launched = false + const second = f.router.prepareLaunch().then((prepared) => { + launched = true + return prepared + }) + await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + expect(launched).toBe(false) f.setup.settle() - await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + await expect(second).resolves.toMatchObject({ configDir: f.home('a') }) - // Set up once (setup's marker exists): launches stop waiting. - writeFileSync(join(f.dataRoot, 'claude-profiles', 'a', 'profile.json'), '{}') + // A refresh that hangs, then fails, only delays a launch briefly. + f.setup.outcome = 'refused' await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) - expect(f.setup.calls).toBe(2) + expect(f.setup.calls).toBe(3) + f.setup.settle() + await new Promise((resolve) => setTimeout(resolve, 10)) + const fourth = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(4)) + f.setup.settle() + await expect(fourth).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + const profile = describeClaudeProfile(f.dataRoot, 'a', { + executionHostId: 'local', + runtime: 'host' + }) + // The worker timed out, or the app quit, after the gate ran. + const cutOff = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome, + env: {}, + runSetup: async (args) => { + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome) + throw new Error('Claude account setup timed out') + } + }) + await expect(cutOff.prepareLaunch()).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(existsSync(claudeProfileMarkerPath(profile))).toBe(false) + + const launch = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it("launches a set-up, signed-in account without waiting for the login shell's env", async () => { + shell.hang = true + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + signIn(f.home('a'), 'a@example.test') + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome + }) + await expect(router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) }) - it('names a never-signed-in folder without creating it, and refuses to launch it', async () => { + it("sets up a pre-update account's missing folder without a login, for Claude's own first run", async () => { const f = fixture() f.settings.activeClaudeManagedAccountId = 'b' - f.router.publish() - expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) - expect(f.setup.calls).toBe(0) - expect(existsSync(f.home('b'))).toBe(false) + // Before its setup, a terminal still opens; its claude function creates the folder itself. expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - // A terminal still opens; its claude function refuses from the pointer instead. expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + f.router.publish() + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + expect(existsSync(f.home('b'))).toBe(true) + // Claude's first run is clean only without an onboarding-done state file. + expect(existsSync(join(f.home('b'), '.claude.json'))).toBe(false) + const launch = f.router.prepareLaunch() + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('b') }) + expect(f.setup.calls).toBe(1) + }) + + it('makes a launch set up a missing folder first', async () => { + const f = fixture() + const launch = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) }) it('injects the account with its twin, and nothing over the user’s own System default', () => { @@ -133,7 +351,6 @@ describe('ClaudeProfileRouter', () => { mkdirSync(f.home('a'), { recursive: true }) expect(f.router.preparation()).toMatchObject({ configDir: f.home('a'), - stripAuthEnv: true, envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, CLAUDE_CONFIG_DIR: f.home('a'), @@ -143,18 +360,112 @@ describe('ClaudeProfileRouter', () => { f.settings.activeClaudeManagedAccountId = null expect(f.router.preparation()).toMatchObject({ configDir: resolve('/user/own'), - stripAuthEnv: false, envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath } }) expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') }) + it('carries the user’s own value beside an account terminal’s injected one', () => { + const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) + mkdirSync(f.home('a'), { recursive: true }) + expect(f.router.terminalEnv()).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_USER_CONFIG_DIR: resolve('/user/own') + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') + f.settings.activeClaudeManagedAccountId = null + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + const none = fixture() + mkdirSync(none.home('a'), { recursive: true }) + expect(none.router.terminalEnv()).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') + }) + + // Why not win32: runs the POSIX claude function under bash. + it.skipIf(process.platform === 'win32')( + 'gives an account terminal back the inherited CLAUDE_CONFIG_DIR, or none, on System default', + () => { + for (const inherited of [{ CLAUDE_CONFIG_DIR: resolve('/user/own') }, {}]) { + const f = fixture(inherited) + mkdirSync(f.home('a'), { recursive: true }) + const bin = join(f.root, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'claude'), '#!/bin/sh\nprintf "%s" "${CLAUDE_CONFIG_DIR-unset}"\n') + chmodSync(join(bin, 'claude'), 0o700) + const claude = (env: Record): string => + spawnSync( + '/bin/bash', + ['--norc', '--noprofile', '-c', `${getPosixClaudeShellFunction()}\nclaude`], + { env: { ...env, PATH: `${bin}:/usr/bin:/bin` }, encoding: 'utf8' } + ).stdout + // The pane opens while account A is selected, so Orca replaces the inherited value. + f.router.publish() + const pane = { ...inherited, ...f.router.terminalEnv() } + expect(claude(pane)).toBe(f.home('a')) + f.settings.activeClaudeManagedAccountId = null + f.router.publish() + expect(claude(pane)).toBe(inherited.CLAUDE_CONFIG_DIR ?? 'unset') + } + } + ) + + it('removes a folder only after its running setup, so the setup cannot bring it back', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + f.router.publish() + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const removal = f.router.removeAccount('a') + await new Promise((resolve) => setTimeout(resolve, 10)) + // Setup writes into the folder before it settles, as the worker does. + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), 'settings.json'), '{}') + f.setup.settle() + await removal + expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) + }) + + it("gives a pane only the pointer until the login shell's env arrives, then rewrites it", async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + signIn(f.home('a'), 'a@example.test') + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome, + runSetup: async () => ({ outcome: 'prepared', warnings: [], surfaces: {} }) + }) + expect(router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: router.pointerPath }) + await vi.waitFor(() => expect(readFileSync(router.pointerPath, 'utf8')).toBe(f.home('a'))) + expect(router.terminalEnv()).toMatchObject({ CLAUDE_CONFIG_DIR: f.home('a') }) + }) + + it('takes System default from the login shell, which a Dock launch does not inherit', async () => { + const f = fixture() + f.settings.activeClaudeManagedAccountId = null + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome + }) + await expect(router.prepareLaunch()).resolves.toMatchObject({ + configDir: resolve('/shell/own'), + provenance: 'system' + }) + }) + it('treats a CLAUDE_CONFIG_DIR an outer Orca injected as not the user’s', () => { const f = fixture({ CLAUDE_CONFIG_DIR: '/outer/profile', ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile' }) expect(f.router.systemDefaultHome()).toBe(join(f.userHome, '.claude')) + const carried = fixture({ + CLAUDE_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile', + ORCA_CLAUDE_USER_CONFIG_DIR: '/user/own' + }) + expect(carried.router.systemDefaultHome()).toBe(resolve('/user/own')) }) // Why not win32: creating the link needs privileges there. diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 3b12f6f7c82d..3a7d3f81f42d 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -1,14 +1,19 @@ -import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' -import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' +import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' import type { GlobalSettings } from '../../shared/global-settings-types' -import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' +import { probeRecentClaudeCliVersion } from '../claude/claude-hook-event-versions' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, @@ -20,11 +25,22 @@ import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' import type { ClaudeEnvPatch } from './environment' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' import { + findClaudeAccount, getSelectedClaudeAccountIdForTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' +import { + claudeStateFile, + readClaudeFolderLogin, + removeClaudeAccountFolder, + isSameClaudeLogin, + type ClaudeFolderLogin +} from './claude-account-folder' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' + +const REFRESH_WAIT_MS = 5_000 export type ClaudeProfileRouterSettings = Pick< GlobalSettings, @@ -35,12 +51,6 @@ export type ClaudeProfileRouterSettings = Pick< | 'disabledTuiAgents' > -export const CLAUDE_PROFILE_SETUP_FAILED_MESSAGE = - 'The selected Claude account could not be set up. Try again or choose another account.' - -export const CLAUDE_PROFILE_MISSING_MESSAGE = - "The selected Claude account's folder is missing. Sign in to it again or choose another account." - /** * Routes this host's Claude launches to the selected account's folder. Settings own the selection; * the pointer file mirrors it for `claude` typed in an already-open terminal. @@ -48,17 +58,38 @@ export const CLAUDE_PROFILE_MISSING_MESSAGE = export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() + private env: NodeJS.ProcessEnv + private readonly envReady: Promise + private envResolved: boolean constructor( private readonly args: { getSettings: () => ClaudeProfileRouterSettings dataRoot: string userHome?: string + /** Tests replace the login shell's env. */ env?: NodeJS.ProcessEnv /** Tests replace the worker. */ runSetup?: typeof runClaudeProfileSetupInWorker + /** Tests shorten how long a launch waits on a refresh. */ + refreshWaitMs?: number } ) { this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') + this.env = args.env ?? process.env + // Why the login shell's: a Dock launch lacks the CLAUDE_CONFIG_DIR an rc exports. Chats share it. + this.envResolved = Boolean(args.env) + this.envReady = args.env + ? Promise.resolve() + : resolveLoginShellEnvironment().then((env) => { + this.env = env + this.envResolved = true + // Why: a pointer written before then compared against the wrong System default. + try { + this.writePointer() + } catch (error) { + console.warn('[claude-profile] Could not update the Claude account pointer:', error) + } + }) } private get userHome(): string { @@ -67,57 +98,155 @@ export class ClaudeProfileRouter { private selectedProfile(): ClaudeProfileDescriptor | null { const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) - return id - ? describeClaudeProfile(this.args.dataRoot, id, { executionHostId: 'local', runtime: 'host' }) - : null + return id ? this.describe(id) : null + } + + /** The folder launches run in: the selected account's, unless System default covers it. */ + private routedProfile(): ClaudeProfileDescriptor | null { + const profile = this.selectedProfile() + return profile && !this.coveredBySystemDefault(profile.accountId) ? profile : null + } + + /** The login an account's own folder holds; null until it signs in there. */ + accountLogin(accountId: string): ClaudeFolderLogin | null { + return readClaudeFolderLogin(claudeStateFile(this.accountHome(accountId), this.userHome)) + } + + /** The login System default holds. */ + systemDefaultLogin(maxAgeMs = 0): ClaudeFolderLogin | null { + return readClaudeFolderLogin(claudeStateFile(this.userConfigDir(), this.userHome), maxAgeMs) + } + + /** + * An account with no login of its own runs on System default while that is signed in to the + * same email, so an account saved before per-account folders logs nobody out. + */ + coveredBySystemDefault(accountId: string): boolean { + if (this.accountLogin(accountId)) { + return false + } + const saved = findClaudeAccount(this.args.getSettings(), accountId) + const systemDefault = this.systemDefaultLogin() + return Boolean(saved && systemDefault && isSameClaudeLogin(saved, systemDefault)) } /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ systemDefaultHome(): string { - return resolveClaudeDefaultHome( - this.userHome, - readUserClaudeConfigDir(this.args.env ?? process.env) - ) + return resolveClaudeDefaultHome(this.userHome, this.userConfigDir()) } /** Null for System default. Throws for a missing folder: falling back would run the wrong account. */ - selectedHome(): string | null { - const home = this.selectedProfile()?.home ?? null + routedHome(): string | null { + const home = this.routedProfile()?.home ?? null if (home !== null && !isDirectory(home)) { - throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + throw claudeProfileMissing() } return home } /** Pointer first, then setup in the background, as superset does. No saved accounts means no pointer. */ publish(): void { - if (this.args.getSettings().claudeManagedAccounts.length === 0) { - rmSync(this.pointerPath, { force: true }) + if (!this.writePointer()) { return } + // Why even a missing or covered folder: setup creates it without a login, so a sign-in, + // or Claude's own first run, signs in there. const profile = this.selectedProfile() - mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) - writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) - // Why the existence check: setup creates the folder, and only sign-in may create an account. - if (profile && isDirectory(profile.home)) { - this.setUp(profile).catch((error: unknown) => { - console.warn('[claude-profile] Account setup failed:', error) - }) + if (profile) { + this.setUpInBackground(profile) } } - /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + /** The routed folder for the `claude` function; false when no account is saved, so no pointer. */ + private writePointer(): boolean { + if (this.args.getSettings().claudeManagedAccounts.length === 0) { + rmSync(this.pointerPath, { force: true }) + return false + } + const home = this.routedProfile()?.home ?? '' + // Why compare first: launches re-sync it, and most find it unchanged. + if (!existsSync(this.pointerPath) || readFileSync(this.pointerPath, 'utf8') !== home) { + mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) + writeFileAtomically(this.pointerPath, home, { mode: 0o600 }) + } + return true + } + + private setUpInBackground(profile: ClaudeProfileDescriptor): void { + this.setUp(profile).catch((error: unknown) => { + console.warn('[claude-profile] Account setup failed:', error) + }) + } + + /** Refreshes the routed folder first; only a first setup that fails stops the launch. */ async prepareLaunch(): Promise { - const profile = this.selectedProfile() - if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { + const selected = this.selectedProfile() + // Why wait only without the account's own login: System default, and whether it covers the + // account, depend on the login shell's CLAUDE_CONFIG_DIR; setup awaits it itself. + if (!selected || !this.accountLogin(selected.accountId)) { + await this.envReady + } + // Why: a sign-in or a System default login change since the last publish moves the route. + this.writePointer() + const profile = this.routedProfile() + if (!profile) { + return this.preparation() + } + // Why the marker: setup writes it last, so a missing folder is set up too. + if (!existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + throw claudeProfileSetupFailed() } + return this.preparation() } + // Why capped and never thrown: a set-up folder runs on its last refresh rather than not at all. + const refresh = this.setUp(profile).catch((error: unknown) => { + console.warn('[claude-profile] Account refresh failed:', error) + }) + const waitMs = this.args.refreshWaitMs ?? REFRESH_WAIT_MS + await Promise.race([refresh, new Promise((resolve) => setTimeout(resolve, waitMs).unref())]) return this.preparation() } + /** An account's folder on this host, whether or not it exists yet. */ + accountHome(accountId: string): string { + return this.describe(accountId).home + } + + /** Creates and sets up an account's folder for sign-in; the login itself is Claude's. */ + async prepareAccount(accountId: string): Promise { + const profile = this.describe(accountId) + const report = await this.setUp(profile).catch(() => null) + if (report?.outcome !== 'prepared') { + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + } + return profile.home + } + + /** Deletes an account's folder after any setup running for it, which would otherwise recreate it. */ + async removeAccount(accountId: string): Promise { + await this.setups.get(accountId)?.catch(() => {}) + await removeClaudeAccountFolder(this.args.dataRoot, accountId) + } + + /** True once an older Orca copied an account's login into System default: it left this snapshot. */ + copiedLoginIntoSystemDefault(): boolean { + return existsSync(join(this.args.dataRoot, 'claude-runtime-auth', 'system-default-auth.json')) + } + + /** The user's own CLAUDE_CONFIG_DIR, which wins over the selection in their terminals. */ + userConfigDir(): string | undefined { + return readUserClaudeConfigDir(this.env) + } + + private describe(accountId: string): ClaudeProfileDescriptor { + return describeClaudeProfile(this.args.dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + } + /** One setup per account at a time; a later request reuses the running one. */ private setUp(profile: ClaudeProfileDescriptor): Promise { const running = this.setups.get(profile.accountId) @@ -130,13 +259,14 @@ export class ClaudeProfileRouter { } private async runSetup(profile: ClaudeProfileDescriptor): Promise { + await this.envReady const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') - const claudeVersion = hooks ? await probeClaudeCliVersion(resolveClaudeCommand()) : null + const claudeVersion = hooks ? await probeRecentClaudeCliVersion(resolveClaudeCommand()) : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, userHome: this.userHome, - userConfigDir: readUserClaudeConfigDir(this.args.env ?? process.env), + userConfigDir: this.userConfigDir(), hooks, claudeVersion: claudeVersion ?? undefined }) @@ -146,9 +276,9 @@ export class ClaudeProfileRouter { return report } - /** Env for a launch Orca makes itself. Throws like selectedHome. */ + /** Env for a launch Orca makes itself. Throws like routedHome. */ launchEnv(): ClaudeEnvPatch { - const home = this.selectedHome() + const home = this.routedHome() return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath, // Why nothing for System default: the user's inherited CLAUDE_CONFIG_DIR must pass through. @@ -162,23 +292,78 @@ export class ClaudeProfileRouter { if (target?.runtime === 'wsl') { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } + // Why only the pointer before the login shell's env: which home runs depends on its + // CLAUDE_CONFIG_DIR, and the pane's `claude` reads the pointer rewritten once it arrives. + if (!this.envResolved) { + return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } + } try { - return this.launchEnv() + this.writePointer() + // Why in the background: a pane never waits; its `claude` starts after the refresh, mostly. + const profile = this.routedProfile() + if (profile) { + this.setUpInBackground(profile) + } + const env = this.launchEnv() + const userConfigDir = this.userConfigDir() + // Why: the injected value replaces the user's own; the claude function restores it on System default. + return env.CLAUDE_CONFIG_DIR && userConfigDir + ? { ...env, [CLAUDE_USER_CONFIG_DIR_ENV]: userConfigDir } + : env } catch { return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } } } preparation(): ClaudeRuntimeAuthPreparation { - const home = this.selectedHome() + const profile = this.routedProfile() + const home = this.routedHome() return { configDir: home ?? this.systemDefaultHome(), runtime: 'host', wslDistro: null, wslLinuxConfigDir: null, envPatch: this.launchEnv(), - stripAuthEnv: home !== null, - provenance: home ? `profile:${this.selectedProfile()?.accountId}` : 'system' + provenance: home ? `profile:${profile?.accountId}` : 'system' + } + } + + /** + * Whether `claude` in a terminal opened before routing, which runs System default's login, runs + * another account than the selected host one. Null with no host account selected. + */ + systemDefaultRunsAnotherAccount(): boolean | null { + const profile = this.selectedProfile() + if (!profile) { + return null + } + const selected = + this.accountLogin(profile.accountId) ?? + findClaudeAccount(this.args.getSettings(), profile.accountId) + const systemDefault = this.systemDefaultLogin(5_000) + return !selected || !systemDefault || !isSameClaudeLogin(selected, systemDefault) + } + + /** Whether launches run in an account's folder rather than System default's. */ + routesToAccount(): boolean { + return this.routedProfile() !== null + } + + /** Where an unselected account's usage is read: where its launches would run once selected. */ + accountUsagePreparation(accountId: string): ClaudeRuntimeAuthPreparation { + if (this.coveredBySystemDefault(accountId)) { + // Why no CLAUDE_CONFIG_DIR: naming System default's folder moves Claude to another Keychain item. + return { + configDir: this.systemDefaultHome(), + envPatch: {}, + provenance: 'system' + } + } + const home = this.accountHome(accountId) + return { + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + provenance: `profile:${accountId}` } } diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index 0195805eff32..69edd5bc3c12 100644 --- a/src/main/claude-accounts/claude-profile-setup.test.ts +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -48,7 +48,7 @@ function fixture() { installHooks, platform: 'linux' }) - return { root, defaultHome, dataRoot, service, setup } + return { root, defaultHome, dataRoot, service, installHooks, setup } } // Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. @@ -67,7 +67,7 @@ describe('Claude account profile setup', () => { projects: 'linked', 'history.jsonl': 'linked', skills: 'linked', - 'settings.json': 'merged', + 'settings.json': 'synced', '.claude.json': 'absent', hooks: 'merged' }) @@ -83,6 +83,24 @@ describe('Claude account profile setup', () => { expect(existsSync(join(home, '.credentials.json'))).toBe(false) expect((await f.setup()).warnings).toEqual([]) }) + itLinks('marks the folder set up only after its last step', async () => { + const f = fixture() + const marker = join(f.dataRoot, 'claude-profiles/a/profile.json') + let markedDuringHooks: boolean | undefined + const report = await provisionClaudeAccountProfile({ + dataRoot: f.dataRoot, + profile: describeClaudeProfile(f.dataRoot, 'a', local), + userHome: state.home, + installHooks: (target) => { + markedDuringHooks = existsSync(marker) + return f.installHooks(target) + }, + platform: 'linux' + }) + expect(report.outcome).toBe('prepared') + expect(markedDuringHooks).toBe(false) + expect(existsSync(marker)).toBe(true) + }) itLinks("brings the user's later hooks into an account set up while they had none", async () => { const f = fixture() const settings = join(f.defaultHome, 'settings.json') @@ -149,7 +167,8 @@ describe('Claude account profile setup', () => { platform: 'linux' }) expect(skipped.surfaces.hooks).toBe('absent') - writeFileSync(join(f.dataRoot, 'claude-profiles/a/home/settings.json'), '{bad') + // Copied as is from the default home, where Claude reports it the same way. + writeFileSync(join(f.defaultHome, 'settings.json'), '{bad') const failed = await f.setup() expect(failed.surfaces.hooks).toBe('failed') expect(failed.surfaces.projects).toBe('unchanged') diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts index 1687d9cf0e8b..422dfe163bcf 100644 --- a/src/main/claude-accounts/claude-profile-setup.ts +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -1,11 +1,6 @@ -import { join } from 'node:path' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { shareClaudeProfileHistory } from './claude-profile-history' -import { - prepareClaudeProfileDirectory, - readClaudeProfileObject, - type ClaudeProfileDescriptor -} from './claude-profile-paths' +import { prepareClaudeProfileDirectory, type ClaudeProfileDescriptor } from './claude-profile-paths' import { provisionClaudeProfile } from './claude-profile-provisioning' import { ClaudeProfileSurfaceError, @@ -14,27 +9,6 @@ import { warnClaudeProfile, type ClaudeProfileReport } from './claude-profile-report' -import { - claudeProfileLedgerPath, - readClaudeProfileLedger, - writeClaudeProfileLedger -} from './claude-profile-sharing' - -/** Without this the installed `hooks` look like a profile edit, so the user's own hooks never arrive. */ -function recordInstalledHooks(home: string): void { - const settings = readClaudeProfileObject(join(home, 'settings.json')) - if (settings.kind !== 'present' || !('hooks' in settings.value)) { - return - } - const ledgerPath = claudeProfileLedgerPath(home) - const ledger = readClaudeProfileLedger(ledgerPath) - const written = (ledger.keys['settings.json'] ??= {}) - const hooks = JSON.stringify(settings.value.hooks) - if (written.hooks !== hooks) { - written.hooks = hooks - writeClaudeProfileLedger(ledgerPath, ledger) - } -} /** `refused`: the profile failed its ownership gate and no surface was touched. */ export type ClaudeProfileSetupReport = ClaudeProfileReport & { outcome: 'refused' | 'prepared' } @@ -49,18 +23,23 @@ export async function provisionClaudeAccountProfile(args: { userHome: string /** The user's own CLAUDE_CONFIG_DIR (see readUserClaudeConfigDir); `~/.claude` when unset. */ userConfigDir?: string - /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ + /** Null when Orca's Claude hooks are turned off. Runs after the settings copy so its entries survive it. */ installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null - trustKeys?: readonly string[] platform?: NodeJS.Platform }): Promise { const platform = args.platform ?? process.platform const report = createClaudeProfileReport() + let markSetUp: () => void try { if (args.profile.target.runtime === 'wsl' && platform === 'win32') { throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') } - prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) + markSetUp = prepareClaudeProfileDirectory( + args.dataRoot, + args.profile, + args.userHome, + args.userConfigDir + ) } catch (error) { report.surfaces.profile = 'failed' warnClaudeProfile(report, 'profile', error) @@ -70,7 +49,7 @@ export async function provisionClaudeAccountProfile(args: { const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } for (const step of [ () => shareClaudeProfileHistory({ ...shared, platform }), - () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) + () => provisionClaudeProfile({ ...shared, platform }) ]) { try { const part = await step() @@ -89,8 +68,9 @@ export async function provisionClaudeAccountProfile(args: { if (status.state !== 'installed') { throw new Error(status.detail ?? `Claude hooks ${status.state}`) } - recordInstalledHooks(home) return 'merged' }) + // Last: a setup cut off before here leaves no marker, so the next launch waits for a full one. + markSetUp() return { outcome: 'prepared', ...report } } diff --git a/src/main/claude-accounts/claude-profile-sharing.ts b/src/main/claude-accounts/claude-profile-sharing.ts index 69201b62f5b9..be7b4509612b 100644 --- a/src/main/claude-accounts/claude-profile-sharing.ts +++ b/src/main/claude-accounts/claude-profile-sharing.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { lstatSync, mkdirSync, @@ -6,59 +5,15 @@ import { readdirSync, realpathSync, rmdirSync, + statSync, symlinkSync, unlinkSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { dirname } from 'node:path' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { readClaudeProfileObject } from './claude-profile-paths' import type { ClaudeProfileSurfaceOutcome } from './claude-profile-report' -/** Keyed by surface name, not path, so another spelling of the same profile keeps its history. */ -export type ClaudeProfileLedger = { - version: 1 - files: Record - keys: Record> -} - -function stringEntries(value: unknown): Record { - const entries: Record = {} - if (value && typeof value === 'object') { - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === 'string') { - entries[key] = entry - } - } - } - return entries -} - -export function claudeProfileLedgerPath(profileHome: string): string { - return join(profileHome, '.orca-profile.json') -} - -export function writeClaudeProfileLedger(file: string, ledger: ClaudeProfileLedger): void { - writeFileAtomically(file, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 }) -} - -/** Orca's own bookkeeping: an unreadable ledger starts empty, so nothing shared is overwritten. */ -export function readClaudeProfileLedger(file: string): ClaudeProfileLedger { - const ledger: ClaudeProfileLedger = { version: 1, files: {}, keys: {} } - const result = readClaudeProfileObject(file) - if (result.kind !== 'present') { - return ledger - } - ledger.files = stringEntries(result.value.files) - const keys = result.value.keys - if (keys && typeof keys === 'object') { - for (const [surface, entries] of Object.entries(keys)) { - ledger.keys[surface] = stringEntries(entries) - } - } - return ledger -} - export function linkClaudeProfileDirectory( source: string, target: string, @@ -102,88 +57,38 @@ export function linkClaudeProfileDirectory( return 'linked' } -export function syncClaudeProfileFile( +/** + * Copies a file the default home owns over the profile's copy. Copied, not linked: a + * rename-replace save (Claude's own, or an editor's) would cut a link. + */ +export function copyClaudeProfileFile( source: string, target: string, - surface: string, - ledger: ClaudeProfileLedger, - render: (sourceText: string) => string = (sourceText) => sourceText + render: (sourceText: Buffer) => Buffer = (sourceText) => sourceText ): ClaudeProfileSurfaceOutcome { - let desired: string + let desired: Buffer + let mode: number try { - desired = render(readFileSync(source, 'utf8')) + desired = render(readFileSync(source)) + mode = statSync(source).mode & 0o777 } catch (error) { if (isDefinitiveAbsence(error)) { return 'absent' } throw error } - const hash = (value: string): string => createHash('sha256').update(value).digest('hex') try { if (lstatSync(target).isSymbolicLink()) { return 'user-owned' } - const current = hash(readFileSync(target, 'utf8')) - if (current === hash(desired)) { - ledger.files[surface] = current + if (readFileSync(target).equals(desired)) { return 'unchanged' } - if (ledger.files[surface] !== current) { - return 'user-owned' - } } catch (error) { if (!isDefinitiveAbsence(error)) { throw error } } - writeFileAtomically(target, desired, { mode: 0o600 }) - ledger.files[surface] = hash(desired) + writeFileAtomically(target, desired, { mode }) return 'synced' } - -/** Returns the keys it changed in `target`. */ -export function mergeClaudeProfileKeys( - target: Record, - desired: Record, - written: Record -): string[] { - const changed: string[] = [] - for (const [key, value] of Object.entries(desired)) { - const serialized = JSON.stringify(value) - const current = key in target ? JSON.stringify(target[key]) : undefined - if (current !== serialized && current !== undefined && written[key] !== current) { - continue - } - if (current !== serialized) { - target[key] = value - changed.push(key) - } - written[key] = serialized - } - return changed -} - -/** - * A key the default home dropped leaves the profile when the profile still holds what Orca last - * shared; a value changed inside the profile stays. Callers skip this when the source was unreadable. - */ -export function dropClaudeProfileKeys( - target: Record, - desired: Record, - written: Record -): string[] { - const dropped: string[] = [] - for (const key of Object.keys(written)) { - if (key in desired) { - continue - } - if (!(key in target)) { - delete written[key] - } else if (JSON.stringify(target[key]) === written[key]) { - delete target[key] - delete written[key] - dropped.push(key) - } - } - return dropped -} diff --git a/src/main/claude-accounts/claude-profile-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts index 4bf99fdac515..fb4ed6255e10 100644 --- a/src/main/claude-accounts/claude-profile-shell-composition.test.ts +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -1,21 +1,13 @@ -import type * as ProfileRouting from '../../shared/claude-profile-routing' import { spawnSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -const gate = vi.hoisted(() => ({ enabled: true })) -vi.mock('../../shared/claude-profile-routing', async (original) => ({ - ...(await original()), - claudeProfileRoutingEnabled: () => gate.enabled -})) +import { afterEach, expect, it } from 'vitest' const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) ?? '/usr/bin/fish' const roots: string[] = [] afterEach(() => { - gate.enabled = true - vi.resetModules() roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) function fishInit({ args }: { args: string[] | null }): string { @@ -25,9 +17,7 @@ function fishInit({ args }: { args: string[] | null }): string { } return init } -async function composed(enabled: boolean) { - gate.enabled = enabled - vi.resetModules() +async function composed() { const local = await import('../providers/local-pty-shell-ready') const daemon = await import('../daemon/shell-ready') const powershell = await import('../powershell-osc133-bootstrap') @@ -53,35 +43,19 @@ async function composed(enabled: boolean) { } } -it('keeps the composed bash, zsh, fish and PowerShell startup text dormant with the gate off', async () => { - const off = await composed(false) - for (const text of [...off.fish, off.powershell]) { - expect(text).not.toContain('claude') - } - for (const text of [...off.bash, ...off.zsh]) { - expect(text).not.toContain('ORCA_CLAUDE') - } - const on = await composed(true) +it('gives every composed shell startup and the plain fish vendor snippet the claude function', async () => { + const on = await composed() for (const text of [...on.bash, ...on.zsh]) { expect(text).toContain('ORCA_CLAUDE_INJECTED_CONFIG_DIR') } -}) - -it('gives a plain fish tab the claude function through the vendor snippet only with the gate on', async () => { - gate.enabled = false - vi.resetModules() - const off = await import('../fish-xdg-data-dirs-handoff') - expect(off.getFishVendorConfSnippet()).not.toContain('claude') - gate.enabled = true - vi.resetModules() - const on = await import('../fish-xdg-data-dirs-handoff') + const { getFishVendorConfSnippet } = await import('../fish-xdg-data-dirs-handoff') const { getFishClaudeShellFunction } = await import('../../shared/claude-shell-function') expect(getFishClaudeShellFunction()).toContain('function claude') - expect(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) + expect(getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) }) it('starts the PowerShell claude function on its own line after the codex fragment', async () => { - const on = await composed(true) + const on = await composed() expect(on.powershell).toContain( 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' ) @@ -95,7 +69,7 @@ it.skipIf(!existsSync(FISH))( mkdirSync(join(root, 'bin')) writeFileSync(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n') chmodSync(join(root, 'bin', 'claude'), 0o700) - const on = await composed(true) + const on = await composed() for (const init of on.fish) { const result = spawnSync( '/usr/bin/env', diff --git a/src/main/claude-accounts/claude-profile-wsl-entry.ts b/src/main/claude-accounts/claude-profile-wsl-entry.ts index 47629865c540..cab59aee0798 100644 --- a/src/main/claude-accounts/claude-profile-wsl-entry.ts +++ b/src/main/claude-accounts/claude-profile-wsl-entry.ts @@ -2,7 +2,7 @@ import { provisionClaudeAccountProfile } from './claude-profile-setup' import { wslClaudeProfile } from './claude-profile-wsl-paths' // Runs inside a WSL distro on Orca's pinned Node: ` `. -// Hooks are not installed here: they reach the account through the settings merge from ~/.claude. +// Hooks are not installed here: they reach the account through the settings copy from ~/.claude. async function main(): Promise { const [userHome = '', distro = '', accountId = ''] = process.argv.slice(2) const { dataRoot, profile } = wslClaudeProfile(userHome, distro, accountId) diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts index a5096c040eea..39d2bf677282 100644 --- a/src/main/claude-accounts/claude-profile-wsl-paths.ts +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -25,3 +25,12 @@ export function wslClaudeProfilePointer(hostDataRoot: string): string { const build = basename(hostDataRoot).replace(/[^\w.-]/g, '_') return `.local/share/orca/claude-profiles/selected-wsl-${build}` } + +/** A saved WSL account's guest folder; null for an older Orca's folder, which holds no login. */ +export function savedWslClaudeAccountHome(account: { + id: string + wslLinuxAuthPath?: string | null +}): string | null { + const home = account.wslLinuxAuthPath + return home?.endsWith(`/claude-profiles/${account.id}/home`) ? home : null +} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index decde2f9d35a..be520cac3eeb 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -37,10 +38,13 @@ vi.mock('../wsl/wsl-runner', () => ({ import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import type { ClaudeProfileRouterSettings } from './claude-profile-router' +import { prepareClaudeProfileDirectory } from './claude-profile-paths' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' +import { applyClaudeEnvPatch } from './environment' +import { wslClaudeProfile } from './claude-profile-wsl-paths' // Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. const posixHost = process.platform !== 'win32' @@ -75,12 +79,14 @@ function fixture() { const router = new ClaudeWslProfileRouter({ getSettings: () => settings, dataRoot: join(root, 'orca-dev'), + // Like the guest helper, marks the folder only once it finishes. runSetup: async () => { setup.calls += 1 await setup.gate if (setup.fail) { throw new Error('refused') } + mkdirSync(profileHome, { recursive: true }) writeFileSync(join(profileHome, '..', 'profile.json'), '{}') } }) @@ -90,14 +96,10 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { - it('writes the guest pointer per build, sets up only a signed-in folder, and removes it with the last account', async () => { + it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => { const f = fixture() await f.router.publish('Ubuntu') expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - expect(f.setup.calls).toBe(0) - - mkdirSync(f.profileHome, { recursive: true }) - await f.router.publish('Ubuntu') await vi.waitFor(() => expect(f.setup.calls).toBe(1)) f.wsl.Ubuntu = null @@ -109,15 +111,21 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(f.pointer)).toBe(false) }) - it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { + it('sets up a missing folder, waits for a first setup never run or still running, then launches at once', async () => { const f = fixture() - await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - - mkdirSync(f.profileHome, { recursive: true }) + await expect(f.router.preparation('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) f.setup.fail = true - await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ) + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + const launch = f.router.prepareLaunch('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const second = f.router.prepareLaunch('Ubuntu') + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await expect(launch).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(f.setup.calls).toBe(1) + f.setup.fail = false const prepared = await f.router.prepareLaunch('Ubuntu') expect(prepared).toMatchObject({ @@ -131,10 +139,97 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome } }) + // The failed first setup left no marker, so this launch ran setup again; the next refreshes + // alongside the launch instead of before it. + expect(f.setup.calls).toBe(2) + f.setup.gate = new Promise(() => {}) await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(3) + }) + + it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + // The guest helper failed, or timed out, after the gate ran. + const cutOff = new ClaudeWslProfileRouter({ + getSettings: () => f.settings, + dataRoot: join(guest.home, '..', 'orca-dev'), + runSetup: async (distro, home, accountId) => { + const { dataRoot, profile } = wslClaudeProfile(home, distro, accountId) + prepareClaudeProfileDirectory(dataRoot, profile, home) + throw new Error('timed out') + } + }) + await expect(cutOff.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + expect(existsSync(join(f.profileHome, '..', 'profile.json'))).toBe(false) + + await f.router.prepareLaunch('Ubuntu') + expect(f.setup.calls).toBe(1) + }) + + it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + f.setup.fail = true + let release = () => {} + f.setup.gate = new Promise((resolve) => (release = resolve)) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + configDir: f.profileHome + }) + release() + await new Promise((resolve) => setTimeout(resolve, 10)) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + configDir: f.profileHome + }) + // That launch refreshed in the background. expect(f.setup.calls).toBe(2) }) + it('sets up a new account folder for sign-in and deletes it without following its links', async () => { + const f = fixture() + const sharedHistory = join(guest.home, '.claude', 'projects') + mkdirSync(sharedHistory) + writeFileSync(join(sharedHistory, 'chat.jsonl'), '{}') + // The stub setup marks account a's folder. + mkdirSync(f.profileHome, { recursive: true }) + const home = await f.router.prepareAccount('Ubuntu', 'b') + expect(home).toBe(join(guest.home, '.local/share/orca/claude-profiles/b/home')) + expect(f.setup.calls).toBe(1) + mkdirSync(home, { recursive: true }) + symlinkSync(sharedHistory, join(home, 'projects')) + + await f.router.removeAccount('Ubuntu', 'b') + expect(existsSync(join(home, '..'))).toBe(false) + expect(existsSync(join(sharedHistory, 'chat.jsonl'))).toBe(true) + + f.setup.fail = true + await expect(f.router.prepareAccount('Ubuntu', 'c')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + }) + + it('removes a guest folder only after its running setup, so the setup cannot bring it back', async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + let release = () => {} + // Like the guest helper, setup recreates the folder it writes into. + f.setup.gate = new Promise((resolve) => (release = resolve)).then(() => { + mkdirSync(f.profileHome, { recursive: true }) + }) + await f.router.publish('Ubuntu') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + const removal = f.router.removeAccount('Ubuntu', 'a') + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await removal + expect(existsSync(join(f.profileHome, '..'))).toBe(false) + }) + it('writes a missing guest pointer before a launch returns', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) @@ -172,6 +267,61 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) }) + it("runs an account with no login of its own on the guest's ~/.claude while that is signed in to it", async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + const login = (dir: string, email: string) => + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) + login(guest.home, 'A@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: join(guest.home, '.claude'), + provenance: 'wsl:Ubuntu:system' + }) + expect(readFileSync(f.pointer, 'utf8')).toBe('') + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe('') + + login(f.profileHome, 'a@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + + // Another organization of the same email never stands in for the account. + rmSync(join(f.profileHome, '.claude.json')) + f.settings.claudeManagedAccounts = [{ ...f.account('a'), organizationUuid: 'org-a' }] + writeFileSync( + join(guest.home, '.claude.json'), + JSON.stringify({ + oauthAccount: { emailAddress: 'a@example.test', organizationUuid: 'org-b' } + }) + ) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + // Another email in the guest's ~/.claude never stands in for the account either. + login(guest.home, 'b@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + }) + + it("keeps a shell proxy's key with its address on WSL launches, account or System default", async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + const shell = { ANTHROPIC_BASE_URL: 'https://proxy.example.test', ANTHROPIC_API_KEY: 'k' } + for (const selected of ['a', null]) { + f.wsl.Ubuntu = selected + const prepared = await f.router.prepareLaunch('Ubuntu') + expect(applyClaudeEnvPatch({ ...shell }, prepared.envPatch)).toMatchObject(shell) + } + }) + it('launches System default from the guest ~/.claude with no account env', async () => { const f = fixture() f.wsl.Ubuntu = null diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 0fba0eb59a68..c71a8c76d2fa 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -2,26 +2,32 @@ import { existsSync } from 'node:fs' import { lstat, readFile } from 'node:fs/promises' import { join, posix } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' import { WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../shared/relay-artifacts' import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' +import type { ClaudeProfileRouterSettings } from './claude-profile-router' +import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' import { - CLAUDE_INJECTED_CONFIG_DIR_ENV, - claudeProfileMarkerPath, - type ClaudeProfileDescriptor -} from './claude-profile-paths' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + isSameClaudeLogin, + readClaudeFolderLoginAsync, + type ClaudeFolderLogin +} from './claude-account-folder' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -import { getClaudeWslSelectionKey, getSelectedClaudeAccountIdForTarget } from './runtime-selection' +import { + findClaudeAccount, + getClaudeWslSelectionKey, + getSelectedClaudeAccountIdForTarget +} from './runtime-selection' type WslSetup = (distro: string, guestHome: string, accountId: string) => Promise @@ -63,7 +69,23 @@ export class ClaudeWslProfileRouter { if (!home?.startsWith('/')) { throw new Error(`Could not read the home folder of WSL distro ${distro}.`) } - return { home, profile: this.selectedProfile(home, distro) } + const selected = this.selectedProfile(home, distro) + return { home, selected, profile: await this.routed(distro, home, selected) } + } + + /** The host router's fallback: an account with no login of its own runs on the guest's ~/.claude + * while that is signed in to the same email. */ + private async routed( + distro: string, + home: string, + selected: ClaudeProfileDescriptor | null + ): Promise { + if (!selected || (await guestLogin(distro, posix.join(selected.home, '.claude.json')))) { + return selected + } + const saved = findClaudeAccount(this.args.getSettings(), selected.accountId) + const systemDefault = await guestLogin(distro, posix.join(home, '.claude.json')) + return saved && systemDefault && isSameClaudeLogin(saved, systemDefault) ? null : selected } private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { @@ -80,7 +102,7 @@ export class ClaudeWslProfileRouter { /** Pointer first, then setup in the background, as on the host. No accounts here means no pointer. */ async publish(distro: string): Promise { - const { home, profile } = await this.resolve(distro) + const { home, selected, profile } = await this.resolve(distro) if (!this.accountIn(distro)) { await runGuest(distro, { script: 'rm -f -- "$1"', @@ -90,43 +112,96 @@ export class ClaudeWslProfileRouter { return } await writePointer(distro, this.pointerIn(home), profile?.home ?? '') - // Why the existence check: setup creates the folder, and only sign-in may create an account. - if (profile && (await guestStat(distro, profile.home))?.isDirectory()) { - this.setUp(distro, home, profile.accountId).catch((error: unknown) => { - console.warn('[claude-profile] WSL account setup failed:', error) - }) + // Why even a missing or covered folder: setup creates it without a login, for a sign-in. + if (selected) { + this.setUpInBackground(distro, home, selected.accountId) } } - /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ + private setUpInBackground(distro: string, home: string, accountId: string): void { + this.setUp(distro, home, accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + }) + } + + /** Waits for a first setup that never finished, running or not; otherwise refreshes alongside. */ async prepareLaunch(distro: string): Promise { const { home, profile } = await this.resolve(distro) - await this.assertPresent(distro, profile) - if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { + // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a + // set-up folder never blocks. + if (profile && !(await hasMarker(distro, profile))) { await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) - throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + throw claudeProfileSetupFailed() }) + } else if (profile) { + // Why in the background: a guest refresh starts a guest Node, too slow for every launch. + this.setUpInBackground(distro, home, profile.accountId) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. // Re-read the selection: one made during setup has already published its own pointer. if (this.accountIn(distro)) { - const selected = this.selectedProfile(home, distro) - await writePointer(distro, this.pointerIn(home), selected?.home ?? '') + const current = await this.routed(distro, home, this.selectedProfile(home, distro)) + await writePointer(distro, this.pointerIn(home), current?.home ?? '') } return this.preparationFor(distro, home, profile) } + /** The host router's old-terminal check for a pane in this distro; null with no account selected. */ + async systemDefaultRunsAnotherAccount(distro: string): Promise { + const { home, selected } = await this.resolve(distro) + if (!selected) { + return null + } + const login = + (await guestLogin(distro, posix.join(selected.home, '.claude.json'))) ?? + findClaudeAccount(this.args.getSettings(), selected.accountId) + const systemDefault = await guestLogin(distro, posix.join(home, '.claude.json')) + return !login || !systemDefault || !isSameClaudeLogin(login, systemDefault) + } + + /** Where an unselected account's usage is read in the guest: where its launches would run. */ + async accountUsagePreparation( + distro: string, + accountId: string + ): Promise { + const { home } = await this.resolve(distro) + const { profile } = wslClaudeProfile(home, distro, accountId) + return this.preparationFor(distro, home, await this.routed(distro, home, profile)) + } + async preparation(distro: string): Promise { const { home, profile } = await this.resolve(distro) await this.assertPresent(distro, profile) return this.preparationFor(distro, home, profile) } + /** Creates and sets up an account's guest folder for sign-in; the login itself is Claude's. */ + async prepareAccount(distro: string, accountId: string): Promise { + const { home } = await this.resolve(distro) + const { profile } = wslClaudeProfile(home, distro, accountId) + await this.setUp(distro, home, accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + }) + return profile.home + } + + /** + * Deletes an account's guest folder after any setup running for it, which would otherwise + * recreate it. Linux `rm -r` never follows the history links. + */ + async removeAccount(distro: string, accountId: string): Promise { + await this.setups.get(accountId)?.catch(() => {}) + const { home } = await this.resolve(distro) + const folder = posix.dirname(wslClaudeProfile(home, distro, accountId).profile.home) + await runGuest(distro, { script: 'rm -rf -- "$1"', args: [folder], loginPath: 'none' }) + } + /** Falling back would run the wrong account. */ private async assertPresent(distro: string, profile: ClaudeProfileDescriptor | null) { if (profile && !(await guestStat(distro, profile.home))?.isDirectory()) { - throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) + throw claudeProfileMissing() } } @@ -148,7 +223,6 @@ export class ClaudeWslProfileRouter { ? { CLAUDE_CONFIG_DIR: profile.home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: profile.home } : {}) }, - stripAuthEnv: true, provenance: profile ? `profile:${profile.accountId}:wsl:${distro}` : `wsl:${distro}:system` } } @@ -167,11 +241,20 @@ export class ClaudeWslProfileRouter { } } +/** The login a guest state file names, read over the share like the marker. */ +function guestLogin(distro: string, stateFile: string): Promise { + return readClaudeFolderLoginAsync(toWindowsWslPath(stateFile, distro)) +} + // Why over the distro's share: a launch must not wait on a guest process for two stats. async function guestStat(distro: string, linuxPath: string) { return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) } +async function hasMarker(distro: string, profile: ClaudeProfileDescriptor): Promise { + return (await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile() ?? false +} + /** Writes in the guest only when the file differs, so a launch reads it over the share instead. */ async function writePointer(distro: string, pointer: string, home: string): Promise { // Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides. diff --git a/src/main/claude-accounts/claude-structured-auth-policy.test.ts b/src/main/claude-accounts/claude-structured-auth-policy.test.ts index a2d7c7d5da82..66f5f5d7b5d8 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.test.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { - CLAUDE_AUTH_ENV_VARS, - hasClaudeAuthEnvConflict, - shouldStripClaudeAuthEnvForAccount -} from './environment' -import { - normalizeTuiAgentEnvRecord, - resolveTuiAgentLaunchEnv -} from '../../shared/tui-agent-launch-defaults' +import { CLAUDE_AUTH_ENV_VARS, isHostManagedClaudeAccount } from './environment' import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' const HOST_ACCOUNT = { id: 'host-a', managedAuthRuntime: 'host' } as ClaudeManagedAccount @@ -33,34 +25,29 @@ function settings( } as Parameters[0] } -// The predicate now backs BOTH transports (runtime-auth-preparation.ts and the -// structured wiring), so it needs a test of its own: forcing it to a constant used -// to leave ~1000 tests green. -describe('shouldStripClaudeAuthEnvForAccount', () => { - it('does not strip when no managed account is selected', () => { - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], null)).toBe(false) - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], undefined)).toBe(false) - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], '')).toBe(false) +describe('isHostManagedClaudeAccount', () => { + it('is false when no managed account is selected', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], null)).toBe(false) + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], undefined)).toBe(false) + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], '')).toBe(false) }) - it('strips for a host-managed account', () => { - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true) + it('is true for a host-managed account', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true) }) - it('strips for an account with no explicit runtime (the legacy host shape)', () => { - expect(shouldStripClaudeAuthEnvForAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true) + it('is true for an account with no explicit runtime (the legacy host shape)', () => { + expect(isHostManagedClaudeAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true) }) - it('does not strip for a WSL-managed account, matching runtime-auth-preparation', () => { - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false) + it('is false for a WSL-managed account', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false) }) - it('strips for a selected id no account list explains', () => { - // Fail-safe: an id we cannot resolve is treated as a pinned account, never as - // "no account", so an unreadable settings blob cannot open the strip. - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true) - expect(shouldStripClaudeAuthEnvForAccount(undefined, 'deleted-d')).toBe(true) - expect(shouldStripClaudeAuthEnvForAccount([], 'deleted-d')).toBe(true) + it('is true for a selected id no account list explains', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true) + expect(isHostManagedClaudeAccount(undefined, 'deleted-d')).toBe(true) + expect(isHostManagedClaudeAccount([], 'deleted-d')).toBe(true) }) }) @@ -73,19 +60,21 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } }) ) - ).toEqual({ stripAuthEnv: true }) + ).toEqual({ account: 'managed' }) }) - it('strips when a host account is pinned by runtime selection', () => { + it('names a host account pinned by runtime selection', () => { expect( claudeStructuredAuthPolicyForSettings( settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } }) ) - ).toEqual({ stripAuthEnv: true }) + ).toEqual({ account: 'managed' }) }) - it('does not strip for system auth, so an API-key-only user keeps their sign-in', () => { - expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ stripAuthEnv: false }) + it('names System default when no account is selected', () => { + expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ + account: 'system' + }) }) it('ignores a WSL-only selection: the structured child is always a native host process', () => { @@ -95,14 +84,13 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } } }) ) - ).toEqual({ stripAuthEnv: false }) + ).toEqual({ account: 'system' }) }) }) -describe('the strip vocabulary the policy governs', () => { +describe('the Anthropic auth variables Orca keeps out of its own processes', () => { it('covers every Anthropic auth variable the terminal path knows about', () => { - // A new auth var added to the list without a matching refusal/strip path is the - // shape of the leak this lane already shipped once. + // Only Orca's own process env and a `--version` probe drop these; launches never do. expect([...CLAUDE_AUTH_ENV_VARS]).toEqual([ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', @@ -111,58 +99,3 @@ describe('the strip vocabulary the policy governs', () => { ]) }) }) - -// The refusal has to cover exactly what the strip removes. Anything narrower lets an -// override reach the child that applyClaudeEnvPatch would have deleted. -describe('hasClaudeAuthEnvConflict matches the strip it guards', () => { - it('refuses each Anthropic auth variable', () => { - for (const key of CLAUDE_AUTH_ENV_VARS) { - expect(hasClaudeAuthEnvConflict({ [key]: 'v' }, 'linux')).toBe(true) - } - }) - - // `ANTHROPIC_API_KEY=` in the agent env box is how a user blanks a variable, and the - // settings pipeline preserves the empty value (agent-default-env-draft.ts assigns - // everything after the `=`; normalizeTuiAgentEnvRecord drops empty KEYS only). An - // empty value cannot beat the pinned account and the strip removes the name anyway, - // so refusing it would break a terminal launch that works today for no security gain. - it('admits an override whose value is empty, the documented way to blank a variable', () => { - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: '' }, 'linux')).toBe(false) - expect(hasClaudeAuthEnvConflict({ anthropic_api_key: '' }, 'win32')).toBe(false) - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: '' }, 'linux')).toBe(false) - }) - - it('still refuses the same names once they carry a value', () => { - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: 'sk-ant' }, 'linux')).toBe(true) - }) - - // The end-to-end shape the regression actually took: settings text -> normalized - // record -> launch env -> the predicate the terminal preflight gates on. - it('admits a blanked variable all the way from the settings record', () => { - const configured = normalizeTuiAgentEnvRecord({ claude: { ANTHROPIC_API_KEY: '' } }) - const launchEnv = resolveTuiAgentLaunchEnv('claude', configured) - - expect(launchEnv).toEqual({ ANTHROPIC_API_KEY: '' }) - expect(hasClaudeAuthEnvConflict(launchEnv, 'linux')).toBe(false) - }) - - it('folds case on win32, where the OS does', () => { - expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'win32')).toBe(true) - expect(hasClaudeAuthEnvConflict({ Anthropic_Custom_Headers: 'x-api-key: v' }, 'win32')).toBe( - true - ) - }) - - it('keeps env names case-sensitive off win32', () => { - expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'linux')).toBe(false) - }) - - it('admits non-auth Anthropic settings on both platforms', () => { - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'linux')).toBe(false) - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'win32')).toBe(false) - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: 1' }, 'linux')).toBe( - false - ) - expect(hasClaudeAuthEnvConflict(undefined, 'linux')).toBe(false) - }) -}) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts index c30cd69b8276..38ed700bc1fa 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -1,11 +1,13 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { GlobalSettings } from '../../shared/global-settings-types' -import { shouldStripClaudeAuthEnvForAccount } from './environment' +import { getClaudeProfileRouter } from './claude-profile-installed-router' +import { isHostManagedClaudeAccount } from './environment' import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' -/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result: - * the one field a launch resolution needs from the managed-account state. */ +/** What a structured launch needs from the managed-account state: which login a failed + * sign-in names. A chat keeps the shell's Anthropic auth on every account, as terminals do. */ export type ClaudeStructuredAuthPolicy = { - stripAuthEnv: boolean + account: AgentSessionAccountKind } /** @@ -28,10 +30,13 @@ export function claudeStructuredAuthPolicyForSettings( | 'activeClaudeManagedAccountIdsByRuntime' > ): ClaudeStructuredAuthPolicy { - return { - stripAuthEnv: shouldStripClaudeAuthEnvForAccount( - settings.claudeManagedAccounts, - getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - ) - } + // Why the router first: it decides whether the account or System default runs, as for terminals. + const router = getClaudeProfileRouter() + const managed = router + ? router.routesToAccount() + : isHostManagedClaudeAccount( + settings.claudeManagedAccounts, + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + ) + return { account: managed ? 'managed' : 'system' } } diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts deleted file mode 100644 index 69ca24c21a8f..000000000000 --- a/src/main/claude-accounts/claude-windows-interactive-login.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { getCmdExePath } from '../../shared/windows-batch-spawn' -import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' - -vi.mock('electron', () => ({ - app: { - getPath: () => '/tmp/orca-claude-windows-login-test' - } -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') -})) - -describe('Claude Windows host interactive login', () => { - afterEach(() => { - restorePlatform() - }) - - it('resolves a Windows auth login whose child has no piped streams', async () => { - setPlatform('win32') - vi.resetModules() - const child = new EventEmitter() as EventEmitter & { - stdout: null - stderr: null - kill: ReturnType - pid: number - } - child.stdout = null - child.stderr = null - child.kill = vi.fn() - child.pid = 4242 - const spawnMock = vi.fn(() => { - queueMicrotask(() => child.emit('exit', 0)) - return child - }) - const buildInteractiveLoginSpawn = vi.fn(() => ({ - command: getCmdExePath(), - args: [ - '/d', - '/c', - 'start', - '', - '/wait', - 'C:\\Tools\\claude.cmd', - 'auth', - 'login', - '--claudeai' - ], - stdio: 'ignore' as const, - windowsHide: true - })) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ - buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn - })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - await ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - - expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ - 'auth', - 'login', - '--claudeai' - ]) - expect(spawnMock).toHaveBeenCalledWith( - getCmdExePath(), - expect.arrayContaining(['start', '', '/wait']), - expect.objectContaining({ - stdio: 'ignore', - windowsHide: true - }) - ) - expect(child.kill).not.toHaveBeenCalled() - } finally { - vi.doUnmock('node:child_process') - vi.doUnmock('../../shared/windows-interactive-login-spawn') - } - }) - - it('times out a denied Windows login that stays alive without piped output', async () => { - setPlatform('win32') - vi.resetModules() - vi.useFakeTimers() - const child = new EventEmitter() as EventEmitter & { - stdout: null - stderr: null - kill: ReturnType - pid: number - } - child.stdout = null - child.stderr = null - child.kill = vi.fn() - child.pid = 0 - const spawnMock = vi.fn(() => child) - vi.doMock('node:child_process', () => ({ spawn: spawnMock })) - - try { - const { ClaudeAccountService } = await import('./service') - const service = new ClaudeAccountService( - createService() as never, - createService() as never, - createService() as never - ) - const login = ( - service as unknown as { - runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number - ): Promise - } - ).runClaudeCommand( - ['auth', 'login', '--claudeai'], - { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, - 1000 - ) - const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') - - await vi.advanceTimersByTimeAsync(3_000) - - await rejection - expect(child.kill).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - vi.doUnmock('node:child_process') - } - }) -}) diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 3fd7871c70d7..11059a0f7a32 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,4 +1,9 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', @@ -8,8 +13,9 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { - ORCA_CLAUDE_PROFILE_POINTER?: string - ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string + [CLAUDE_PROFILE_POINTER_ENV]?: string + [CLAUDE_INJECTED_CONFIG_DIR_ENV]?: string + [CLAUDE_USER_CONFIG_DIR_ENV]?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -35,11 +41,15 @@ export function applyClaudeEnvPatch( } } - if (patch.ORCA_CLAUDE_PROFILE_POINTER) { - baseEnv.ORCA_CLAUDE_PROFILE_POINTER = patch.ORCA_CLAUDE_PROFILE_POINTER - } - if (patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR) { - baseEnv.ORCA_CLAUDE_INJECTED_CONFIG_DIR = patch.ORCA_CLAUDE_INJECTED_CONFIG_DIR + for (const key of [ + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV + ] as const) { + const value = patch[key] + if (value) { + baseEnv[key] = value + } } if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR @@ -51,22 +61,8 @@ 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( +/** Whether the selected account is a host-managed one; an id no account explains counts as one. */ +export function isHostManagedClaudeAccount( accounts: readonly ClaudeManagedAccount[] | undefined, activeAccountId: string | null | undefined ): boolean { @@ -78,39 +74,6 @@ export function shouldStripClaudeAuthEnvForAccount( ) } -/** - * Whether a launch's explicit env carries Anthropic auth a managed account must own. - * - * The key comparison mirrors applyClaudeEnvPatch's strip exactly: case-insensitive on - * win32, where the OS folds env names so `anthropic_api_key` is an effective - * `ANTHROPIC_API_KEY`, and case-sensitive elsewhere. A refusal narrower than the strip - * lets an override through that the strip would have removed. - * - * A non-empty value is what makes it a conflict. `ANTHROPIC_API_KEY=` in the agent env - * box is how a user blanks a variable — the settings pipeline preserves that empty value - * (normalizeTuiAgentEnvRecord drops empty KEYS only) — and an empty override can neither - * authenticate nor beat the pinned account, while the strip removes the name regardless. - * Refusing it would break a terminal launch that works today for no security gain. - */ -export function hasClaudeAuthEnvConflict( - env: Record | undefined, - platform: NodeJS.Platform = process.platform -): boolean { - if (!env) { - return false - } - for (const [key, value] of Object.entries(env)) { - const normalized = platform === 'win32' ? key.toUpperCase() : key - if (value && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) { - return true - } - if (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) { - return true - } - } - return false -} - function isAuthLikeCustomHeaders(value: string | undefined): boolean { if (!value) { return false diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 3ee2c6b77593..3bd725ba068f 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,277 +1,21 @@ -import { createHash } from 'node:crypto' -import { execFile } from 'node:child_process' -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - deleteActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentialsForRuntime -} from './keychain' +import { afterEach, expect, it, vi } from 'vitest' -vi.mock('node:child_process', () => ({ - execFile: vi.fn() -})) +const reads = vi.hoisted(() => vi.fn(async () => null)) +vi.mock('../macos-keychain/generic-password', () => ({ readKeychainPassword: reads })) -const execFileMock = vi.mocked(execFile) -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalUser = process.env.USER -const originalUsername = process.env.USERNAME -const TEST_USER = 'orca-test-user' -const SSO_USER = 'sso.user@example.com' -let configDir: string - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -function serviceForConfigDir(configDir: string): string { - const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) - return `Claude Code-credentials-${suffix}` -} - -function invokeExecFileCallback( - callback: unknown, - error: Error | null, - stdout: string, - stderr: string -): void { - const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void - execCallback(error, stdout, stderr) -} - -describe('Claude Keychain credentials', () => { - beforeEach(() => { - configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-'))) - setPlatform('darwin') - execFileMock.mockReset() - process.env.USER = TEST_USER - delete process.env.USERNAME - }) - - afterEach(() => { - rmSync(configDir, { recursive: true, force: true }) - vi.useRealTimers() - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - if (originalUser === undefined) { - delete process.env.USER - } else { - process.env.USER = originalUser - } - if (originalUsername === undefined) { - delete process.env.USERNAME - } else { - process.env.USERNAME = originalUsername - } - }) - - it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe( - '{"claudeAiOauth":{"accessToken":"scoped"}}' - ) - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - scopedService, - '-a', - TEST_USER, - '-w' - ]) - }) - - it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { - const notFound = Object.assign(new Error('not found'), { code: 44 }) - execFileMock - .mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, notFound, '', 'could not be found') - return null as never - }) - .mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, 'legacy\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe('legacy') - - expect(execFileMock.mock.calls[1][1]).toEqual([ - 'find-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - TEST_USER, - '-w' - ]) - }) - - it('writes active credentials to the config-scoped Claude Code service', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await writeActiveClaudeKeychainCredentials('credentials-json', configDir) - - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'add-generic-password', - '-U', - '-s', - scopedService, - '-a', - TEST_USER, - '-w', - 'credentials-json' - ]) - }) - - it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await writeActiveClaudeKeychainCredentialsForRuntime('credentials-json', configDir) +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - [ - 'add-generic-password', - '-U', - '-s', - scopedService, - '-a', - TEST_USER, - '-w', - 'credentials-json' - ], - [ - 'add-generic-password', - '-U', - '-s', - 'Claude Code-credentials', - '-a', - TEST_USER, - '-w', - 'credentials-json' - ] - ]) - }) - - it('strictly reads only the requested active credentials service', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, 'scoped\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentialsStrict(configDir)).resolves.toBe('scoped') - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - scopedService, - '-a', - TEST_USER, - '-w' - ]) - }) - - it('rejects when a keychain read never reports completion', async () => { - vi.useFakeTimers() - const killMock = vi.fn() - execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) - - let settled = false - let rejected: unknown - const readPromise = readActiveClaudeKeychainCredentialsStrict(configDir).then( - (credentials) => { - settled = true - return credentials - }, - (error: unknown) => { - settled = true - rejected = error - return null - } - ) - - await vi.advanceTimersByTimeAsync(3000) - - expect(settled).toBe(true) - await readPromise - expect(rejected).toEqual( - expect.objectContaining({ message: 'security timed out after 3000ms' }) - ) - expect(killMock).toHaveBeenCalled() - }) - - it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await deleteActiveClaudeKeychainCredentials(configDir) - - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - ['delete-generic-password', '-s', scopedService, '-a', TEST_USER], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', TEST_USER] - ]) - }) - - it('looks up claude-code-user when $USER contains @ (#12857)', async () => { - process.env.USER = SSO_USER - execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"ok"}}\n', '') - return null as never - }) - - await expect(readActiveClaudeKeychainCredentials()).resolves.toBe( - '{"claudeAiOauth":{"accessToken":"ok"}}' - ) - expect(execFileMock.mock.calls[0][1]).toEqual([ - 'find-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - 'claude-code-user', - '-w' - ]) - }) - - it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { - process.env.USER = SSO_USER - const scopedService = serviceForConfigDir(configDir) - execFileMock.mockImplementation((_file, _args, _options, callback) => { - invokeExecFileCallback(callback, null, '', '') - return null as never - }) - - await deleteActiveClaudeKeychainCredentials(configDir) +const originalUser = process.env.USER +afterEach(() => { + process.env.USER = originalUser + reads.mockClear() +}) - expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - ['delete-generic-password', '-s', scopedService, '-a', 'claude-code-user'], - ['delete-generic-password', '-s', scopedService, '-a', SSO_USER], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', 'claude-code-user'], - ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', SSO_USER] - ]) - }) +it('reads the claude-code-user item when $USER is not a valid Keychain account (#12857)', async () => { + process.env.USER = 'first@example.com' + await readActiveClaudeKeychainCredentialsStrict() + expect(reads).toHaveBeenCalledWith('Claude Code-credentials', 'claude-code-user') + process.env.USER = 'first.last' + await readActiveClaudeKeychainCredentialsStrict() + expect(reads).toHaveBeenLastCalledWith('Claude Code-credentials', 'first.last') }) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index aa4dcfa64dbc..14070e6bb631 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -2,36 +2,18 @@ import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' -import { - deleteKeychainPassword, - readKeychainPassword, - writeKeychainPassword -} from '../macos-keychain/generic-password' +import { readKeychainPassword } from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' -const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' -export async function readActiveClaudeKeychainCredentials( - configDir?: string -): Promise { - for (const service of getActiveClaudeServices(configDir)) { - const credentials = await readKeychainPassword(service, getKeychainUser()) - if (credentials) { - return credentials - } - } - return null -} - export async function readActiveClaudeKeychainCredentialsStrict( configDir?: string ): Promise { if (!configDir) { - return readKeychainPassword(getActiveClaudeService(), getKeychainUser()) + return readKeychainPassword(claudeKeychainService(), getKeychainUser()) } - // Why: macOS tmp is /var → /private/var. Claude hashes the realpath; a - // mkdtemp login dir would miss the Keychain item if we only hashed the raw path. + // Keep both lexical and canonical profile aliases scoped; never try System Default. for (const dir of claudeConfigDirKeychainAliases(configDir)) { - const credentials = await readKeychainPassword(getActiveClaudeService(dir), getKeychainUser()) + const credentials = await readKeychainPassword(claudeKeychainService(dir), getKeychainUser()) if (credentials) { return credentials } @@ -39,63 +21,6 @@ export async function readActiveClaudeKeychainCredentialsStrict( return null } -export async function writeActiveClaudeKeychainCredentials( - contents: string, - configDir?: string -): Promise { - await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents) -} - -export async function writeActiveClaudeKeychainCredentialsForRuntime( - contents: string, - configDir: string -): Promise { - const user = getKeychainUser() - const scopedService = getActiveClaudeService(configDir) - await writeKeychainPassword(scopedService, user, contents) - if (scopedService !== ACTIVE_CLAUDE_SERVICE) { - await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, user, contents) - } -} - -export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise { - for (const service of getActiveClaudeServices(configDir)) { - for (const account of getKeychainUsersForCleanup()) { - await deleteKeychainPassword(service, account) - } - } -} - -export async function deleteActiveClaudeKeychainCredentialsStrict( - configDir?: string -): Promise { - const dirs = configDir ? claudeConfigDirKeychainAliases(configDir) : [undefined] - for (const dir of dirs) { - for (const account of getKeychainUsersForCleanup()) { - await deleteKeychainPassword(getActiveClaudeService(dir), account, { - failOnAccessError: true - }) - } - } -} - -export async function readManagedClaudeKeychainCredentials( - accountId: string -): Promise { - return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) -} - -export async function writeManagedClaudeKeychainCredentials( - accountId: string, - contents: string -): Promise { - await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents) -} - -export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise { - await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) -} - const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/ const CLAUDE_CODE_FALLBACK_USER = 'claude-code-user' @@ -111,13 +36,7 @@ function getKeychainUser(): string { return KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_CODE_FALLBACK_USER } -function getKeychainUsersForCleanup(): string[] { - const derived = getKeychainUser() - const raw = process.env.USER || process.env.USERNAME - return raw && raw !== derived ? [derived, raw] : [derived] -} - -function getActiveClaudeService(configDir?: string): string { +export function claudeKeychainService(configDir?: string): string { if (!configDir) { return ACTIVE_CLAUDE_SERVICE } @@ -173,10 +92,16 @@ export function claudeConfigDirKeychainAliases(configDir: string): string[] { return aliases } -function getActiveClaudeServices(configDir?: string): string[] { - if (!configDir) { - return [ACTIVE_CLAUDE_SERVICE] +/** Every spelling of a folder Claude may have hashed into its Keychain item name (superset U/profiles.ts). */ +export function claudeConfigDirSpellings(configDir: string, userHome: string): string[] { + const spellings = new Set() + for (const dir of claudeConfigDirKeychainAliases(configDir)) { + const trimmed = dir.replace(/[\\/]+$/, '') + const rest = trimmed.startsWith(`${userHome}/`) ? trimmed.slice(userHome.length) : null + for (const spelling of [trimmed, ...(rest ? [`~${rest}`, `$HOME${rest}`] : [])]) { + spellings.add(spelling) + spellings.add(`${spelling}/`) + } } - const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) - return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] + return [...spellings] } diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts deleted file mode 100644 index 7359bbd98055..000000000000 --- a/src/main/claude-accounts/live-pty-gate.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - attachClaudeLivePtyPersistence, - beginClaudeAuthSwitch, - confirmSeededClaudeLivePtys, - endClaudeAuthSwitch, - hasLiveClaudePtys, - isClaudeAuthSwitchInProgress, - markClaudePtyExited, - markClaudePtySpawned, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './live-pty-gate' - -describe('Claude live PTY gate', () => { - afterEach(() => { - markClaudePtyExited('live-claude-pty') - markClaudePtyExited('seeded-pty-1') - markClaudePtyExited('seeded-pty-2') - confirmSeededClaudeLivePtys([]) - attachClaudeLivePtyPersistence(null) - endClaudeAuthSwitch() - }) - - it('allows switching while Claude PTYs are live', () => { - markClaudePtySpawned('live-claude-pty') - - beginClaudeAuthSwitch() - - expect(isClaudeAuthSwitchInProgress()).toBe(true) - }) - - it('still rejects overlapping account switches', () => { - beginClaudeAuthSwitch() - - expect(() => beginClaudeAuthSwitch()).toThrow('already in progress') - }) - - it('counts seeded session ids as live until confirmed dead', () => { - seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) - - expect(hasLiveClaudePtys()).toBe(true) - - confirmSeededClaudeLivePtys(['seeded-pty-1']) - - expect(hasLiveClaudePtys()).toBe(true) - - confirmSeededClaudeLivePtys([]) - - expect(hasLiveClaudePtys()).toBe(true) - - markClaudePtyExited('seeded-pty-1') - - expect(hasLiveClaudePtys()).toBe(false) - }) - - it('releases seeded ids the daemon no longer knows', () => { - const removeClaudeLivePtySessionId = vi.fn() - attachClaudeLivePtyPersistence({ - addClaudeLivePtySessionId: vi.fn(), - removeClaudeLivePtySessionId - }) - seedLiveClaudePtysFromPersistence(['seeded-pty-1', 'seeded-pty-2']) - - confirmSeededClaudeLivePtys(['seeded-pty-2']) - - expect(hasLiveClaudePtys()).toBe(true) - expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('seeded-pty-1') - expect(removeClaudeLivePtySessionId).not.toHaveBeenCalledWith('seeded-pty-2') - }) - - it('keeps a seeded id confirmed by a real spawn out of later pruning', () => { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - markClaudePtySpawned('seeded-pty-1') - - confirmSeededClaudeLivePtys([]) - - expect(hasLiveClaudePtys()).toBe(true) - }) - - it('notifies drain listeners only when the last live Claude PTY exits', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - markClaudePtySpawned('live-claude-pty') - markClaudePtySpawned('seeded-pty-1') - - markClaudePtyExited('live-claude-pty') - expect(onDrained).not.toHaveBeenCalled() - - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - - // Why: exits with no live PTYs left must not fire again — the drain - // signal marks the 1 -> 0 transition, not every teardown call. - markClaudePtyExited('seeded-pty-1') - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('notifies drain listeners when seed reconciliation releases the last live id', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - try { - seedLiveClaudePtysFromPersistence(['seeded-pty-1']) - - confirmSeededClaudeLivePtys([]) - - expect(onDrained).toHaveBeenCalledTimes(1) - } finally { - unsubscribe() - } - }) - - it('stops notifying an unsubscribed drain listener', () => { - const onDrained = vi.fn() - const unsubscribe = onLiveClaudePtysDrained(onDrained) - unsubscribe() - - markClaudePtySpawned('live-claude-pty') - markClaudePtyExited('live-claude-pty') - - expect(onDrained).not.toHaveBeenCalled() - }) - - it('persists spawns and exits when persistence is attached', () => { - const addClaudeLivePtySessionId = vi.fn() - const removeClaudeLivePtySessionId = vi.fn() - attachClaudeLivePtyPersistence({ - addClaudeLivePtySessionId, - removeClaudeLivePtySessionId - }) - - markClaudePtySpawned('live-claude-pty') - expect(addClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') - - markClaudePtyExited('live-claude-pty') - expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') - }) -}) diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts deleted file mode 100644 index caab66c34308..000000000000 --- a/src/main/claude-accounts/live-pty-gate.ts +++ /dev/null @@ -1,174 +0,0 @@ -const liveClaudePtyIds = new Set() -// Why: ids restored from persistence at startup, not yet confirmed against the -// daemon. They keep the OAuth refresh gate closed so an early managed refresh -// cannot rotate the single-use refresh token out from under a Claude CLI that -// survived the app restart inside the daemon. -const seededUnconfirmedPtyIds = new Set() -let switchInProgress = false -// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the -// swap out instead of refusing. See whenClaudeAuthSwitchSettles. -const switchSettledListeners = new Set<() => void>() - -/** A managed account swap is a credential-file rewrite, not a network round trip; - * anything past this is a wedged switch, and refusing beats waiting forever. */ -export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000 - -export type ClaudeLivePtyPersistence = { - addClaudeLivePtySessionId(sessionId: string): void - removeClaudeLivePtySessionId(sessionId: string): void -} - -let persistence: ClaudeLivePtyPersistence | null = null - -export function attachClaudeLivePtyPersistence(target: ClaudeLivePtyPersistence | null): void { - persistence = target -} - -// Why: a live claude defers the managed OAuth refresh ("Waiting for Claude -// session"); consumers need the 1 -> 0 transition to recover promptly instead -// of waiting out the usage-fetch failure backoff. -type LiveClaudePtyDrainListener = () => void -const drainListeners = new Set() - -export function onLiveClaudePtysDrained(listener: LiveClaudePtyDrainListener): () => void { - drainListeners.add(listener) - return () => drainListeners.delete(listener) -} - -function notifyDrainedOnTransition(hadLivePtys: boolean): void { - if (!hadLivePtys || liveClaudePtyIds.size > 0) { - return - } - for (const listener of drainListeners) { - listener() - } -} - -export function seedLiveClaudePtysFromPersistence(sessionIds: readonly string[]): void { - for (const sessionId of sessionIds) { - liveClaudePtyIds.add(sessionId) - seededUnconfirmedPtyIds.add(sessionId) - } -} - -export function hasSeededUnconfirmedClaudePtys(): boolean { - return seededUnconfirmedPtyIds.size > 0 -} - -/** - * Reconcile seeded ids against the daemon's live session list. Seeded ids the - * daemon no longer knows are dead — release them so they cannot defer OAuth - * refresh forever. Seeded ids that are still alive stay in the gate even if - * their pane never reattaches: that daemon process still owns the credentials. - */ -export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - const alive = new Set(aliveSessionIds) - for (const sessionId of seededUnconfirmedPtyIds) { - if (!alive.has(sessionId)) { - liveClaudePtyIds.delete(sessionId) - persistence?.removeClaudeLivePtySessionId(sessionId) - } - } - seededUnconfirmedPtyIds.clear() - notifyDrainedOnTransition(hadLivePtys) -} - -export function markClaudePtySpawned(ptyId: string): void { - liveClaudePtyIds.add(ptyId) - seededUnconfirmedPtyIds.delete(ptyId) - persistence?.addClaudeLivePtySessionId(ptyId) -} - -export function markClaudePtyExited(ptyId: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - liveClaudePtyIds.delete(ptyId) - seededUnconfirmedPtyIds.delete(ptyId) - persistence?.removeClaudeLivePtySessionId(ptyId) - notifyDrainedOnTransition(hadLivePtys) -} - -/** - * Register a structured Claude child with the same gate the terminal path uses. - * - * The gate is what makes the managed OAuth refresh defer instead of rotating a - * single-use refresh token out from under a running Claude (runtime-auth-sync.ts). - * A structured session's child is as much a live Claude as a PTY's is, so it has to - * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an - * identical terminal session is protected. - * - * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct - * children of this process and cannot survive a restart, so seeding them back on the - * next launch would hold the gate closed for a process that is provably gone. - */ -export function markClaudeStructuredChildSpawned(childKey: string): void { - liveClaudePtyIds.add(structuredChildGateId(childKey)) -} - -export function markClaudeStructuredChildExited(childKey: string): void { - const hadLivePtys = liveClaudePtyIds.size > 0 - liveClaudePtyIds.delete(structuredChildGateId(childKey)) - notifyDrainedOnTransition(hadLivePtys) -} - -// Namespaced so a structured child can never collide with a daemon PTY session id, -// which confirmSeededClaudeLivePtys reconciles against the daemon's own list. -function structuredChildGateId(childKey: string): string { - return `claude-structured:${childKey}` -} - -export function hasLiveClaudePtys(): boolean { - return liveClaudePtyIds.size > 0 -} - -export function beginClaudeAuthSwitch(): void { - if (switchInProgress) { - throw new Error('A Claude account switch is already in progress.') - } - switchInProgress = true -} - -export function endClaudeAuthSwitch(): void { - const wasInProgress = switchInProgress - switchInProgress = false - if (!wasInProgress) { - return - } - // Each listener removes itself as it settles; Set iteration is defined over that. - for (const listener of switchSettledListeners) { - listener() - } -} - -/** - * Resolves `true` once no account switch is running, `false` if one is still running - * at the deadline. - * - * Exists for callers that have already done irreversible work — a structured acquire - * has closed the old child by the time it resolves its launch, so turning a switch - * into a refusal there strands the user with a dead session and no replacement. - * Waiting for the swap and then launching against it is the recoverable answer; - * refusing is only correct when nothing has been torn down yet. - */ -export function whenClaudeAuthSwitchSettles( - timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS -): Promise { - if (!switchInProgress) { - return Promise.resolve(true) - } - return new Promise((resolve) => { - const settle = (settled: boolean): void => { - switchSettledListeners.delete(listener) - clearTimeout(timer) - resolve(settled) - } - const listener = (): void => settle(true) - switchSettledListeners.add(listener) - const timer = setTimeout(() => settle(false), timeoutMs) - timer.unref?.() - }) -} - -export function isClaudeAuthSwitchInProgress(): boolean { - return switchInProgress -} diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts deleted file mode 100644 index 2914b72e0334..000000000000 --- a/src/main/claude-accounts/managed-auth-path.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import { app } from 'electron' -import { writeFileAtomically } from '../codex-accounts/fs-utils' - -const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' - -export function getClaudeManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'claude-accounts') -} - -export function resolveOwnedClaudeManagedAuthPath( - accountId: string, - candidatePath: string, - options: { adoptLegacyMarker?: boolean } = {} -): string | null { - const rootPath = getClaudeManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) { - return null - } - try { - if (lstatSync(resolvedCandidate).isSymbolicLink()) { - return null - } - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(rootPath) - if ( - canonicalCandidate === canonicalRoot || - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - return null - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const relativeParts = relativePath.split(sep) - const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - if ( - escaped || - relativeParts.length !== 2 || - relativeParts[0] !== accountId || - relativeParts[1] !== 'auth' - ) { - return null - } - const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) - const markerValid = isManagedAuthMarkerValid(markerPath, accountId) - if (!markerValid && options.adoptLegacyMarker) { - writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }) - } - if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) { - return null - } - return canonicalCandidate - } catch { - return null - } -} - -export function readClaudeManagedAuthFile( - managedAuthPath: string, - filename: '.credentials.json' | 'oauth-account.json' -): string | null { - const filePath = resolve(managedAuthPath, filename) - try { - if (!isOwnedChildFile(managedAuthPath, filePath)) { - return null - } - return readFileSync(filePath, 'utf-8') - } catch { - return null - } -} - -export function writeClaudeManagedAuthFile( - managedAuthPath: string, - filename: '.credentials.json' | 'oauth-account.json', - contents: string -): void { - const filePath = resolve(managedAuthPath, filename) - if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { - throw new Error('Managed Claude auth child file is not owned by Orca.') - } - writeFileAtomically(filePath, contents, { mode: 0o600 }) -} - -function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean { - try { - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - !lstatSync(markerPath).isFile() - ) { - return false - } - return readFileSync(markerPath, 'utf-8').trim() === accountId - } catch { - return false - } -} - -function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean { - if ( - !existsSync(filePath) || - lstatSync(filePath).isSymbolicLink() || - !lstatSync(filePath).isFile() - ) { - return false - } - const canonicalAuthPath = realpathSync(managedAuthPath) - const canonicalFilePath = realpathSync(filePath) - return canonicalFilePath.startsWith(canonicalAuthPath + sep) -} diff --git a/src/main/claude-accounts/oauth-refresh.test.ts b/src/main/claude-accounts/oauth-refresh.test.ts deleted file mode 100644 index dd5e01c245a5..000000000000 --- a/src/main/claude-accounts/oauth-refresh.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - applyRefreshedToken, - isOauthTokenExpiring, - parseClaudeOauthBlob, - readRefreshToken, - refreshClaudeOauthCredentials -} from './oauth-refresh' - -const { netFetchMock } = vi.hoisted(() => ({ - netFetchMock: vi.fn() -})) - -vi.mock('electron', () => ({ - net: { fetch: netFetchMock }, - session: { defaultSession: {} } -})) - -vi.mock('../network/proxy-settings', () => ({ - ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue({ source: 'none' }) -})) - -const NOW = 1_700_000_000_000 - -function credentials(overrides: Record = {}): string { - return JSON.stringify({ - claudeAiOauth: { - accessToken: 'old-access', - refreshToken: 'old-refresh', - expiresAt: NOW + 60 * 60 * 1000, - scopes: ['user:inference', 'user:profile'], - ...overrides - } - }) -} - -describe('parseClaudeOauthBlob', () => { - it('returns the oauth block', () => { - expect(parseClaudeOauthBlob(credentials())?.accessToken).toBe('old-access') - }) - - it('returns null for non-JSON or missing block', () => { - expect(parseClaudeOauthBlob('not json')).toBeNull() - expect(parseClaudeOauthBlob('{}')).toBeNull() - expect(parseClaudeOauthBlob('{"claudeAiOauth":[]}')).toBeNull() - }) -}) - -describe('readRefreshToken', () => { - it('reads a present token', () => { - expect(readRefreshToken(credentials())).toBe('old-refresh') - }) - - it('returns null for blank or missing tokens', () => { - expect(readRefreshToken(credentials({ refreshToken: ' ' }))).toBeNull() - expect(readRefreshToken(credentials({ refreshToken: undefined }))).toBeNull() - }) -}) - -describe('isOauthTokenExpiring', () => { - it('is false when well within validity', () => { - expect(isOauthTokenExpiring(credentials(), NOW)).toBe(false) - }) - - it('is true within the 5-minute buffer', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: NOW + 60 * 1000 }), NOW)).toBe(true) - }) - - it('is true when already expired', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: NOW - 1000 }), NOW)).toBe(true) - }) - - it('treats missing/non-numeric expiry as expiring', () => { - expect(isOauthTokenExpiring(credentials({ expiresAt: undefined }), NOW)).toBe(true) - expect(isOauthTokenExpiring(credentials({ expiresAt: 'soon' }), NOW)).toBe(true) - }) - - it('is false for credentials without an oauth block', () => { - expect(isOauthTokenExpiring('{}', NOW)).toBe(false) - }) -}) - -describe('applyRefreshedToken', () => { - it('rotates access + refresh token and recomputes expiry', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'new-access', expires_in: 3600, refresh_token: 'new-refresh' }, - NOW - ) - const oauth = parseClaudeOauthBlob(updated!)! - expect(oauth.accessToken).toBe('new-access') - expect(oauth.refreshToken).toBe('new-refresh') - expect(oauth.expiresAt).toBe(NOW + 3600 * 1000) - }) - - it('keeps the existing refresh token when the server does not rotate it', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'new-access', expires_in: 3600 }, - NOW - ) - expect(parseClaudeOauthBlob(updated!)!.refreshToken).toBe('old-refresh') - }) - - it('preserves unrelated top-level fields', () => { - const raw = JSON.stringify({ - claudeAiOauth: { accessToken: 'a', refreshToken: 'r' }, - somethingElse: { keep: true } - }) - const updated = applyRefreshedToken(raw, { access_token: 'b' }, NOW) - expect(JSON.parse(updated!).somethingElse).toEqual({ keep: true }) - }) - - it('splits scope string into scopes array', () => { - const updated = applyRefreshedToken( - credentials(), - { access_token: 'b', scope: 'user:inference user:profile' }, - NOW - ) - expect(parseClaudeOauthBlob(updated!)!.scopes).toEqual(['user:inference', 'user:profile']) - }) - - it('returns null when the response lacks an access token', () => { - expect(applyRefreshedToken(credentials(), {}, NOW)).toBeNull() - expect(applyRefreshedToken('not json', { access_token: 'b' }, NOW)).toBeNull() - }) -}) - -describe('refreshClaudeOauthCredentials', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('returns null without a refresh token (no network call)', async () => { - const result = await refreshClaudeOauthCredentials( - credentials({ refreshToken: undefined }), - NOW - ) - expect(result).toBeNull() - expect(netFetchMock).not.toHaveBeenCalled() - }) - - it('posts a form-urlencoded refresh grant and persists the rotation', async () => { - netFetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }) - }) - - const result = await refreshClaudeOauthCredentials(credentials(), NOW) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - const [url, init] = netFetchMock.mock.calls[0] - expect(url).toBe('https://platform.claude.com/v1/oauth/token') - expect(init.method).toBe('POST') - expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') - const body = new URLSearchParams(init.body) - expect(body.get('grant_type')).toBe('refresh_token') - expect(body.get('refresh_token')).toBe('old-refresh') - expect(body.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e') - - const oauth = parseClaudeOauthBlob(result!)! - expect(oauth.accessToken).toBe('fresh-access') - expect(oauth.refreshToken).toBe('fresh-refresh') - }) - - it('returns null on a non-ok response and logs the status for diagnosability', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - netFetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }) - expect(await refreshClaudeOauthCredentials(credentials(), NOW)).toBeNull() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('429')) - warn.mockRestore() - }) - - it('returns null when the request throws (never rejects)', async () => { - netFetchMock.mockRejectedValue(new Error('network down')) - await expect(refreshClaudeOauthCredentials(credentials(), NOW)).resolves.toBeNull() - }) -}) diff --git a/src/main/claude-accounts/oauth-refresh.ts b/src/main/claude-accounts/oauth-refresh.ts deleted file mode 100644 index cc717ef51ee8..000000000000 --- a/src/main/claude-accounts/oauth-refresh.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { net, session } from 'electron' -import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' - -// Why: the OAuth client id and token endpoint are the public Claude Code -// values, verified against the installed `claude` binary (2.1.177) and the -// claude-swap reference tool. Orca owns the refresh so a single-use refresh -// token is rotated and persisted atomically, instead of being scraped back -// after the CLI rotates it (the lossy path that strands stale tokens). -const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token' -const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - -// Refresh slightly ahead of expiry so a token doesn't expire mid-launch. The -// CLI uses the same 5-minute skew for its own refresh decision. -const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000 -const REFRESH_TIMEOUT_MS = 10_000 - -type ClaudeOauthBlob = { - accessToken?: unknown - refreshToken?: unknown - expiresAt?: unknown - scopes?: unknown - [key: string]: unknown -} - -type ClaudeCredentials = { - claudeAiOauth?: ClaudeOauthBlob - [key: string]: unknown -} - -type TokenEndpointResponse = { - access_token?: unknown - expires_in?: unknown - refresh_token?: unknown - scope?: unknown -} - -/** - * Parse the `claudeAiOauth` object from a credentials JSON string. - * Returns null when the string is not parseable or lacks the OAuth block. - */ -export function parseClaudeOauthBlob(credentialsJson: string): ClaudeOauthBlob | null { - try { - const parsed = JSON.parse(credentialsJson) as ClaudeCredentials - const oauth = parsed?.claudeAiOauth - return oauth && typeof oauth === 'object' && !Array.isArray(oauth) ? oauth : null - } catch { - return null - } -} - -/** Read a stored refresh token, or null when absent/blank. */ -export function readRefreshToken(credentialsJson: string): string | null { - const oauth = parseClaudeOauthBlob(credentialsJson) - const token = oauth?.refreshToken - return typeof token === 'string' && token.trim() !== '' ? token.trim() : null -} - -/** - * Whether the stored access token is expired or within the refresh buffer. - * - * A missing/non-numeric `expiresAt` is treated as "needs refresh" so a blob - * with no usable expiry metadata still gets a proactive refresh attempt rather - * than being trusted indefinitely. `now` is injectable for tests. - */ -export function isOauthTokenExpiring(credentialsJson: string, now: number = Date.now()): boolean { - const oauth = parseClaudeOauthBlob(credentialsJson) - if (!oauth) { - return false - } - const expiresAt = oauth.expiresAt - if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { - return true - } - return now + OAUTH_EXPIRY_BUFFER_MS >= expiresAt -} - -/** - * Merge a token-endpoint response into the stored credentials, returning the - * updated credentials JSON. Preserves every field the caller already had - * (including the refresh token when the server does not rotate it) and only - * overwrites what the response provides. Returns null on malformed input. - */ -export function applyRefreshedToken( - credentialsJson: string, - response: TokenEndpointResponse, - now: number = Date.now() -): string | null { - let parsed: ClaudeCredentials - try { - parsed = JSON.parse(credentialsJson) as ClaudeCredentials - } catch { - return null - } - const accessToken = response.access_token - if (typeof accessToken !== 'string' || accessToken.trim() === '') { - return null - } - const oauth: ClaudeOauthBlob = { ...parsed.claudeAiOauth } - oauth.accessToken = accessToken - if (typeof response.expires_in === 'number' && Number.isFinite(response.expires_in)) { - oauth.expiresAt = now + response.expires_in * 1000 - } - // Rotation: keep the existing refresh token unless the server issued a new - // one. Single-use refresh tokens make persisting the rotated value the whole - // point of owning refresh. - if (typeof response.refresh_token === 'string' && response.refresh_token.trim() !== '') { - oauth.refreshToken = response.refresh_token - } - if (typeof response.scope === 'string' && response.scope.trim() !== '') { - oauth.scopes = response.scope.split(' ') - } - parsed.claudeAiOauth = oauth - return JSON.stringify(parsed) -} - -/** - * Refresh the OAuth token for a stored credentials blob. - * - * Returns the updated credentials JSON (with the rotated refresh token and new - * access token) on success, or null on any failure. Never throws — callers - * treat null as "keep the existing credentials", so a transient network error - * is never worse than today's behavior. - */ -export async function refreshClaudeOauthCredentials( - credentialsJson: string, - now: number = Date.now() -): Promise { - const refreshToken = readRefreshToken(credentialsJson) - if (!refreshToken) { - return null - } - - await ensureElectronProxyFromEnvironment({ - proxySession: session.defaultSession, - probeUrl: OAUTH_TOKEN_URL - }).catch(() => {}) - - try { - // Why: the `claude` CLI posts grant_type=refresh_token as - // application/x-www-form-urlencoded with the public client id. net.fetch - // routes through Chromium's stack so the env proxy bridge above applies. - const res = await net.fetch(OAUTH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: OAUTH_CLIENT_ID - }).toString(), - signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) - }) - if (!res.ok) { - // Why: surface the status (never the token) so a throttle (429) or a - // dead refresh token (400/401 invalid_grant) is diagnosable in the - // field, instead of a silent null that looks identical to success. - // Callers keep the existing credentials on null — a transient 429 just - // means the still-valid token is reused until the next attempt. - console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`) - return null - } - const data = (await res.json()) as TokenEndpointResponse - return applyRefreshedToken(credentialsJson, data, now) - } catch (error) { - console.warn( - '[claude-oauth-refresh] token refresh request failed:', - error instanceof Error ? error.message : error - ) - return null - } -} diff --git a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts deleted file mode 100644 index dbccd87cee5e..000000000000 --- a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' - -const testState = { - fakeHomeDir: '', - previousConfigDir: undefined as string | undefined -} - -vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } })) - -vi.mock('node:os', async () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const actual = await vi.importActual('node:os') - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - -beforeEach(() => { - testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-')) - testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR - delete process.env.CLAUDE_CONFIG_DIR -}) - -afterEach(() => { - rmSync(testState.fakeHomeDir, { recursive: true, force: true }) - if (testState.previousConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir - } - testState.fakeHomeDir = '' -}) - -describe('Claude runtime auth path materialization', () => { - it('does not create the config directory while preparing a background usage fetch', async () => { - const settings = { - ...getDefaultSettings(testState.fakeHomeDir), - disabledTuiAgents: ['claude'] as const, - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null - } - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn() - } - const service = new ClaudeRuntimeAuthService(store as never) - - const preparation = await service.prepareForRateLimitFetch() - - expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) - expect(preparation.provenance).toBe('system') - expect(existsSync(preparation.configDir)).toBe(false) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts deleted file mode 100644 index 249e403b075b..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('saves a verified file refresh but refuses to overwrite unreadable keychain state', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.throwScopedKeychainRead = true - testState.throwLegacyKeychainRead = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(originalCredentials) - expect(testState.legacyKeychainCredentials).toBe(originalCredentials) - warn.mockRestore() - }) - - it('captures a fresh system-default snapshot when re-entering managed mode', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') - const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8') - - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) - }) - - it('refreshes keychain and oauth snapshot surfaces when the credentials file is unchanged', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') - const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount1 = { accountUuid: 'system-account-1' } - const systemOauthAccount2 = { accountUuid: 'system-account-2' } - writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount1 })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials1 - testState.legacyKeychainCredentials = systemCredentials1 - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount2 })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials2 - testState.legacyKeychainCredentials = systemCredentials2 - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials1) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount2) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials2) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials2) - }) - - it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') - const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('switches accounts without persisting unverified live runtime credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a') - const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-c') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { - email: 'one@example.com', - organizationUuid: 'org-a' - }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'two@example.com', - organizationUuid: 'org-c' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = 'account-2' - - await service.syncForCurrentSelection() - } finally { - markClaudePtyExited('live-claude-pty') - } - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(account2Credentials) - } - }) - - it('routes refreshed Claude credentials to the matching managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') - const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Original - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - // A stale account-1 Claude process refreshed the shared runtime file after - // Orca selected account-2. Persist that refresh to account-1, then restore - // the selected account in the shared Claude runtime credentials. - writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - testState.scopedKeychainCredentials = account1Refreshed - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(account2Credentials) - expect(testState.legacyKeychainCredentials).toBe(account2Credentials) - } - }) - - it('rejects stale cold-start read-back for inactive matching account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1ManagedNewer = createClaudeCredentialsJson( - 'one@example.com', - 'one-managed-newer', - null, - 5_000 - ) - const account1RuntimeStale = createClaudeCredentialsJson( - 'one@example.com', - 'one-runtime-stale', - null, - 2_000 - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', null, 1_000) - writeFileSync(runtimeCredentialsPath, account1RuntimeStale, 'utf-8') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1ManagedNewer - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1ManagedNewer) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') - const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed') - const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - originalCredentials - ) - const managedAuthPath3 = createManagedClaudeAuth( - testState.userDataDir, - 'account-3', - activeCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }), - createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' }) - ], - activeClaudeManagedAccountId: 'account-3' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.scopedKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(activeCredentials) - expect(testState.legacyKeychainCredentials).toBe(activeCredentials) - } - }) - - it('rejects same-email read-back when another account needs organization proof', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') - const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') - const refreshedWithoutOrg = createClaudeCredentialsJson('same@example.com', 'refreshed') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - noOrgCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - orgCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'same@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedWithoutOrg, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) - }) - - it('ignores unrelated org-scoped accounts when reading back no-org credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account1RefreshedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed' - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-b') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'two@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) - - it('rejects same-email read-back with conflicting organization for no-org accounts', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') - const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') - const conflictingOrgCredentials = createClaudeCredentialsJson( - 'same@example.com', - 'conflicting-org', - 'org-c' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - noOrgCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - orgCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { - email: 'same@example.com', - organizationUuid: 'org-b' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, conflictingOrgCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts b/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts deleted file mode 100644 index efd650618b41..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('restores the system default after rejecting unverifiable managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const originalCredentials = createClaudeCredentialsWithoutEmail('original') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('restores system default after same-identity managed Claude refresh on deselect', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1') - const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - organizationUuid: 'org-1' - }) - ] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('preserves external stale Claude credentials without writing them to managed storage', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' }) - ] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not persist unverifiable stale Claude credentials into another active account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - }) - - it('materializes only the selected managed account into shared Claude runtime files', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one-token') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two-token') - const account1Oauth = '{"accountUuid":"account-1","emailAddress":"one@example.com"}\n' - const account2Oauth = '{"accountUuid":"account-2","emailAddress":"two@example.com"}\n' - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials, - account1Oauth - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials, - account2Oauth - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-1', - emailAddress: 'one@example.com' - }) - - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-2', - emailAddress: 'two@example.com' - }) - - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ - accountUuid: 'account-1', - emailAddress: 'one@example.com' - }) - - // Why: switching rewrites only the shared Claude runtime surface; the - // managed account files remain per-account sources of truth. - expect(readFileSync(join(managedAuthPath1, '.credentials.json'), 'utf-8')).toBe( - account1Credentials - ) - expect(readFileSync(join(managedAuthPath2, '.credentials.json'), 'utf-8')).toBe( - account2Credentials - ) - expect(readFileSync(join(managedAuthPath1, 'oauth-account.json'), 'utf-8')).toBe(account1Oauth) - expect(readFileSync(join(managedAuthPath2, 'oauth-account.json'), 'utf-8')).toBe(account2Oauth) - }) - - it('does not carry the reauth read-back skip across Claude account switches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const account2RefreshedCredentials = createClaudeCredentialsJson( - 'two@example.com', - 'two-refreshed' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - service.clearLastWrittenCredentialsJson() - settings.activeClaudeManagedAccountId = 'account-2' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe( - account2RefreshedCredentials - ) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials) - }) - - it('does not apply inactive-account Claude reauth skip to the active account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const account1RefreshedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed' - ) - const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - service.clearLastWrittenCredentialsJson('account-2') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) - - it('keeps external Claude logout when deselecting managed account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - rmSync(runtimeCredentialsPath, { force: true }) - testState.activeKeychainCredentials = null - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('preserves unknown runtime auth when invalid active account has no ownership proof', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - activeClaudeManagedAccountId: 'missing-account' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(store.updateSettings).toHaveBeenCalledWith({ - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - }) - expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) - expect(preparation.stripAuthEnv).toBe(false) - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('restores system snapshot when active account credentials are missing but runtime matches account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('keeps missing-managed selection until cleanup can retry after keychain failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - testState.throwScopedKeychainWrite = true - const failedPreparation = await service.prepareForClaudeLaunch() - - expect(failedPreparation.provenance).toBe('system') - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - - testState.throwScopedKeychainWrite = false - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('restores missing-managed oauth metadata when only keychain proves ownership', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: systemOauthAccount, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('preserves unknown runtime auth when invalid active account has no system snapshot', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - activeClaudeManagedAccountId: 'missing-account' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts deleted file mode 100644 index 7a80c5be002a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - expectedRuntimeConfigDir, - readManagedCredentialsForTest, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('reads back refreshed active keychain credentials on macOS', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('reads back refreshed legacy keychain credentials on old Claude Code builds', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = originalCredentials - testState.legacyKeychainCredentials = refreshedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) - expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) - }) - - it('rejects stale legacy keychain credentials after a fresher managed write', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed-newer', - null, - 2_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = staleCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - expect(testState.legacyKeychainCredentials).toBe(managedCredentials) - }) - - it('uses fresher file credentials when scoped keychain is stale', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed', - null, - 2_000 - ) - const refreshedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'refreshed', - null, - 3_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.scopedKeychainCredentials = staleCredentials - testState.legacyKeychainCredentials = managedCredentials - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) - expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) - }) - - it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalKeychainCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.scopedKeychainCredentials = externalKeychainCredentials - testState.legacyKeychainCredentials = externalKeychainCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalKeychainCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalKeychainCredentials) - }) - - it('restores unchanged scoped keychain while preserving external legacy keychain logout', async () => { - if (process.platform !== 'darwin') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('preserves external scoped keychain login while restoring unchanged legacy keychain', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalScopedCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { readActiveClaudeKeychainCredentials } = await import('./keychain') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(expectedRuntimeConfigDir()) - - testState.scopedKeychainCredentials = externalScopedCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores oauth metadata when credentials prove managed ownership', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - const externalOauthAccount = { accountUuid: 'external-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: externalOauthAccount })}\n`, - 'utf-8' - ) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata when external credentials change but metadata does not', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata when only keychain proves managed ownership', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('uses managed credentials as ownership baseline after restart with partial external changes', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalScopedCredentials = createClaudeCredentialsJson( - 'external@example.com', - 'external' - ) - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: null, - keychainCredentialsJson: systemCredentials, - scopedKeychainCredentialsJson: systemCredentials, - legacyKeychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = externalScopedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('preserves external file and scoped login while restoring unchanged legacy keychain', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - testState.scopedKeychainCredentials = externalCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores legacy keychain credentials from old system-default snapshots', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: systemCredentials, - configOauthAccount: null, - keychainCredentialsJson: systemCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not recapture managed file as system default after a partial keychain write failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('runtime keychain write failed') - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - testState.throwRuntimeKeychainWrite = false - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores scoped keychain after legacy runtime keychain write fails', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('keeps managed ownership baseline when keychain restore fails and retries', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - testState.throwScopedKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - testState.throwScopedKeychainWrite = false - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('preserves previous keychain snapshot after restart following partial restore failure', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - let service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - testState.throwScopedKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') - - testState.throwScopedKeychainWrite = false - service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not enter managed mode when keychain snapshot capture fails', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - testState.throwScopedKeychainRead = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'Cannot capture current Claude Keychain credentials' - ) - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - warn.mockRestore() - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts b/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts deleted file mode 100644 index 85d68d30e8b8..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - testState.managedKeychainCredentials.set('account-1', reauthedCredentials) - writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8') - service.clearLastWrittenCredentialsJson() - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials) - }) - - it('leaves host system-default credentials untouched before launch', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const expired = createClaudeCredentialsJson('system@example.com', 'system-expired', null, 1_000) - writeFileSync(runtimeCredentialsPath, expired, 'utf-8') - testState.scopedKeychainCredentials = expired - testState.legacyKeychainCredentials = expired - const settings = createSettings({ - activeClaudeManagedAccountId: null - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( - createClaudeCredentialsJson('system@example.com', 'system-refreshed') - ) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(isOauthTokenExpiring).not.toHaveBeenCalled() - expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() - expect(preparation.provenance).toBe('system') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(expired) - expect(testState.scopedKeychainCredentials).toBe(expired) - expect(testState.legacyKeychainCredentials).toBe(expired) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - }) - - it('proactively refreshes and persists an expiring account on switch-in', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1Stale = createClaudeCredentialsJson('one@example.com', 'one-stale', null, 1_000) - const account1Refreshed = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed', - null, - 9_999_999_999_999 - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Stale - ) - // Start on the system default (no active managed account), then switch in. - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - // Now switch into account-1: token is expiring, so the service must refresh - // and persist the rotation before materializing. - vi.mocked(isOauthTokenExpiring).mockReturnValueOnce(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValueOnce(account1Refreshed) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - - expect(refreshClaudeOauthCredentials).toHaveBeenCalledWith(account1Stale) - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Refreshed) - }) - - it('refreshes the active account with an expired token when no Claude PTY is live', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) - const refreshedCreds = createClaudeCredentialsJson( - 'one@example.com', - 'one-refreshed', - null, - 9_999_999_999_999 - ) - const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) - // account-1 is ALREADY the active account (seeded), so this is a re-sync of - // the active account, not a switch-in — the path that was previously missed. - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(refreshedCreds) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(refreshClaudeOauthCredentials).toHaveBeenCalled() - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(refreshedCreds) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCreds) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - }) - - it('does not refresh the active account while a Claude PTY is live', async () => { - const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000) - const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - vi.mocked(isOauthTokenExpiring).mockReturnValue(true) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue( - createClaudeCredentialsJson('one@example.com', 'should-not-be-used', null, 9_999_999_999_999) - ) - - const { markClaudePtySpawned, markClaudePtyExited } = await import('./live-pty-gate') - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - markClaudePtySpawned('pty-live-1') - try { - const preparation = await service.prepareForRateLimitFetch() - // A live Claude owns the credentials; refreshing here would race its - // rotation, so the proactive refresh must be skipped entirely. - expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() - expect(preparation.managedRefreshDeferredByLivePty).toBe(true) - } finally { - markClaudePtyExited('pty-live-1') - vi.mocked(isOauthTokenExpiring).mockReturnValue(false) - vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null) - } - }) - - it('adopts a rotated-refresh-token runtime credential on cold-start read-back', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - // Same expiry on both sides (cold start), but the runtime refresh token has - // rotated — proof the CLI refreshed. Must be read back into managed storage. - const managedCredentials = createClaudeCredentialsJson( - 'one@example.com', - 'one-old', - null, - 3_000 - ) - const runtimeRotated = `${JSON.stringify({ - claudeAiOauth: { - email: 'one@example.com', - accessToken: 'one-rotated', - refreshToken: 'one-rotated-refresh', - expiresAt: 3_000 - } - })}\n` - writeFileSync(runtimeCredentialsPath, runtimeRotated, 'utf-8') - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(runtimeRotated) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts deleted file mode 100644 index 94bdab9b4d82..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - expectedRuntimeConfigDir, - hostPlatform, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - chmodSync, - existsSync, - lstatSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { join } from 'node:path' -import type { GlobalSettings } from '../../shared/global-settings-types' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('creates and recreates the runtime directory when materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - - rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) - await service.prepareForClaudeLaunch() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.runtimeWriteConfigDir).toBe(expectedRuntimeConfigDir()) - }) - - it('restores system default instead of materializing corrupt managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{not-json') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('restores system default instead of materializing wrong-shaped managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{}\n') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.scopedKeychainCredentials).toBe(systemCredentials) - expect(testState.legacyKeychainCredentials).toBe(systemCredentials) - }) - - it('does not materialize managed credentials from unowned auth paths', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') - writeFileSync( - join(unownedAuthPath, 'oauth-account.json'), - `${JSON.stringify({ accountUuid: 'account-1' })}\n`, - 'utf-8' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', unownedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('adopts canonical legacy managed auth paths without existing markers', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') - writeFileSync( - join(managedAuthPath, 'oauth-account.json'), - '{"accountUuid":"account-1"}\n', - 'utf-8' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - const markerPath = join(managedAuthPath, '.orca-managed-claude-auth') - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(lstatSync(markerPath).isFile()).toBe(true) - expect(readFileSync(markerPath, 'utf-8')).toBe('account-1\n') - }) - - it('rejects symlinked managed credential children', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const escapedCredentials = createClaudeCredentialsJson('user@example.com', 'escaped') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - const escapedCredentialsPath = join(testState.fakeHomeDir, 'escaped-credentials.json') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - join(managedAuthPath, 'oauth-account.json'), - '{"accountUuid":"account-1"}\n', - 'utf-8' - ) - writeFileSync(escapedCredentialsPath, escapedCredentials, 'utf-8') - symlinkSync(escapedCredentialsPath, join(managedAuthPath, '.credentials.json')) - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - }) - - it('restores system auth when switching from an owned account to an unowned account', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') - const unownedCredentials = createClaudeCredentialsJson('unowned@example.com', 'unowned') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const ownedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - ownedCredentials - ) - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.credentials.json'), unownedCredentials, 'utf-8') - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }), - createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) - - store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores system auth when the previously synced account is no longer in settings', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const ownedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - ownedCredentials - ) - const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') - mkdirSync(unownedAuthPath, { recursive: true }) - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) - - store.updateSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) - ], - activeClaudeManagedAccountId: 'account-2' - }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores system auth when switching from an owned account to missing credentials', async () => { - setPlatform('linux') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1Credentials - ) - const managedAuthPath2 = join(testState.userDataDir, 'claude-accounts', 'account-2', 'auth') - mkdirSync(managedAuthPath2, { recursive: true }) - writeFileSync(join(managedAuthPath2, '.orca-managed-claude-auth'), 'account-2\n', 'utf-8') - writeFileSync( - join(managedAuthPath2, 'oauth-account.json'), - '{"accountUuid":"account-2"}\n', - 'utf-8' - ) - let settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), - createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) - ], - activeClaudeManagedAccountId: null - }) - const store = { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) - await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) - - store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) - await service.syncForCurrentSelection() - - expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - if (process.platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - } - }) - - it('preserves unreadable runtime credentials instead of overwriting unknown connector grants', async () => { - if (hostPlatform === 'win32') { - return - } - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const rotatedCredentials = createClaudeCredentialsJson('user@example.com', 'rotated') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - testState.managedKeychainCredentials.set('account-1', rotatedCredentials) - writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8') - chmodSync(runtimeCredentialsPath, 0o000) - try { - await expect(service.syncForCurrentSelection()).rejects.toMatchObject({ code: 'EACCES' }) - } finally { - if (existsSync(runtimeCredentialsPath)) { - chmodSync(runtimeCredentialsPath, 0o600) - } - warn.mockRestore() - } - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - }) - - it('tightens credential file permissions when unchanged content is already present', async () => { - if (hostPlatform === 'win32') { - return - } - - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - chmodSync(runtimeCredentialsPath, 0o644) - await service.syncForCurrentSelection() - - expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts b/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts deleted file mode 100644 index 2806d44e82a5..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createClaudeCredentialsWithoutEmail, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('reads back refreshed credentials when the Claude identity still matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rejects wrong-shaped refreshed credentials during read-back', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') - const malformedRefresh = `${JSON.stringify({ - claudeAiOauth: { - email: 'user@example.com', - expiresAt: Date.now() + 120_000 - } - })}\n` - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, malformedRefresh, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('reads back verified same-account credentials on first sync after restart', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'original', - null, - 1_000 - ) - const refreshedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'refreshed', - null, - 2_000 - ) - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rejects older same-account Claude credentials on first sync after restart', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const staleRuntimeCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'stale', - null, - 1_000 - ) - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed-newer', - null, - 2_000 - ) - writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - }) - - it('rejects runtime read-back from a different Claude identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects runtime read-back from the same Claude email in a different organization', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects same-email Claude read-back using stored managed organization identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials, - '{"organizationUuid":"org-b"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects no-email Claude read-back when organization identity conflicts', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') - const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - selectedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) - }) - - it('rejects no-email refreshed credentials even when organization identity matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('preserves rejected runtime refreshes while a Claude terminal is live on Windows', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('does not persist live runtime refreshes with conflicting organization identity', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - const conflictingCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-b') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, conflictingCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(conflictingCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - }) - - it('rematerializes managed credentials over a wiped runtime blob while a Claude terminal is live', async () => { - setPlatform('win32') - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') - // Why: Claude CLI wipes tokens in place (keeps identity fields) after an - // invalid_grant refresh — the exact blob shape this regression guards. - const parsedOriginal = JSON.parse(originalCredentials) as { - claudeAiOauth: Record - } - const wipedCredentials = `${JSON.stringify({ - claudeAiOauth: { - ...parsedOriginal.claudeAiOauth, - accessToken: '', - refreshToken: '', - expiresAt: 0 - } - })}\n` - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const { markClaudePtyExited, markClaudePtySpawned } = await import('./live-pty-gate') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - markClaudePtySpawned('live-claude-pty') - try { - writeFileSync(runtimeCredentialsPath, wipedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - } finally { - markClaudePtyExited('live-claude-pty') - } - }) - - it('rejects unverifiable refreshed runtime credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const originalCredentials = createClaudeCredentialsWithoutEmail('original') - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) - }) - - it('reads back identity-less refreshed credentials when the refresh token matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const refreshToken = 'same-managed-refresh-token' - const originalCredentials = createClaudeCredentialsWithoutEmail('original', null, { - expiresAt: 1_000, - refreshToken - }) - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { - expiresAt: 2_000, - refreshToken - }) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-from-account' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('reads back identity-less refreshed credentials when runtime oauth metadata matches', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const originalCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'original', - 'org-a', - 1_000 - ) - const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { - expiresAt: 2_000, - refreshToken: 'rotated-refresh-token' - }) - writeFileSync(runtimeConfigPath, '{}\n', 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - originalCredentials, - '{"accountUuid":"account-uuid-1","emailAddress":"user@example.com","organizationUuid":"org-a"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - email: 'user@example.com', - organizationUuid: 'org-a' - }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) - }) - - it('rules out other identity-less accounts with different refresh tokens', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const account1RefreshToken = 'account-1-refresh-token' - const account1OriginalCredentials = createClaudeCredentialsWithoutEmail('account-1', null, { - expiresAt: 1_000, - refreshToken: account1RefreshToken - }) - const account1RefreshedCredentials = createClaudeCredentialsWithoutEmail( - 'account-1-refreshed', - null, - { - expiresAt: 2_000, - refreshToken: account1RefreshToken - } - ) - const account2Credentials = createClaudeCredentialsWithoutEmail('account-2', null, { - refreshToken: 'account-2-refresh-token' - }) - const managedAuthPath1 = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - account1OriginalCredentials - ) - const managedAuthPath2 = createManagedClaudeAuth( - testState.userDataDir, - 'account-2', - account2Credentials - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath1), - createClaudeAccount('account-2', managedAuthPath2, { email: 'other@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') - await service.syncForCurrentSelection() - - expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( - account1RefreshedCredentials - ) - expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts deleted file mode 100644 index 4066063c5625..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createElectronMock, - createKeychainMock, - createOauthRefreshMock, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { - createSharedCredentialRuntime, - sharedFields, - withSharedFields -} from './runtime-auth-shared-credentials-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readFileSync, writeFileSync } from 'node:fs' - -vi.mock('electron', () => createElectronMock()) -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) -vi.mock('./keychain', () => createKeychainMock()) -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -describe('shared connector credential write failures', () => { - beforeEach(resetRuntimeAuthTestState) - afterEach(cleanupRuntimeAuthTestState) - - it('keeps the committed baseline across a partial rollback so a rotated grant can be retried', async () => { - const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - testState.scopedKeychainCredentials = withSharedFields(first, rotated) - settings.activeClaudeManagedAccountId = 'second' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - testState.throwLegacyRuntimeKeychainWrite = false - settings.activeClaudeManagedAccountId = 'first' - await service.forceMaterializeCurrentSelectionForRollback() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('first') - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - }) - - it('preserves disjoint live grants when the first switch fails after writing only the scoped item', async () => { - const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() - const figma = sharedFields.mcpOAuth.figma - testState.scopedKeychainCredentials = withSharedFields(system, { - ...sharedFields, - mcpOAuth: { figma } - }) - testState.legacyKeychainCredentials = withSharedFields(system, { - ...sharedFields, - mcpOAuth: { notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } } - }) - writeFileSync(runtimePath, system) - settings.activeClaudeManagedAccountId = 'first' - testState.throwLegacyRuntimeKeychainWrite = true - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'legacy runtime keychain write failed' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).mcpOAuth).toEqual({ - figma, - notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } - }) - testState.throwLegacyRuntimeKeychainWrite = false - await service.syncForCurrentSelection() - const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(runtime.mcpOAuth).toEqual({ - figma, - notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } - }) - expect(runtime.claudeAiOauth.accessToken).toBe('first') - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts deleted file mode 100644 index f3bb1f001378..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createOauthRefreshMock, - createStore, - readManagedCredentialsForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { - createSharedCredentialRuntime, - sharedFields, - withSharedFields -} from './runtime-auth-shared-credentials-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) -vi.mock('./keychain', () => createKeychainMock()) -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, homedir: () => testState.fakeHomeDir } -}) - -describe('shared Claude connector credentials', () => { - beforeEach(resetRuntimeAuthTestState) - afterEach(cleanupRuntimeAuthTestState) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'preserves connector grants stored only in %s when there is no previous Orca write', - async (surface) => { - const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() - testState.scopedKeychainCredentials = system - testState.legacyKeychainCredentials = system - writeFileSync(runtimePath, system) - if (surface === 'scoped') { - testState.scopedKeychainCredentials = withSharedFields(system) - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = withSharedFields(system) - } else { - writeFileSync(runtimePath, withSharedFields(system)) - } - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - } - ) - - it.each(['darwin', 'linux', 'win32'] as const)( - 'excludes connector secrets when capturing a managed account on %s', - async (platform) => { - const { firstPath, first } = await createSharedCredentialRuntime(platform) - const { ClaudeManagedAuthStorage } = await import('./claude-managed-auth-storage') - await new ClaudeManagedAuthStorage().writeCredentials( - 'first', - firstPath, - withSharedFields(first) - ) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(first) - ) - } - ) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'propagates connector revocations from the %s surface and does not resurrect frozen account grants', - async (surface) => { - const { service, settings, runtimePath, first, secondPath, second } = - await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - if (surface === 'scoped') { - testState.scopedKeychainCredentials = first - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = first - } else { - writeFileSync(runtimePath, first) - } - testState.managedKeychainCredentials.set('second', withSharedFields(second)) - writeFileSync(join(secondPath, '.credentials.json'), withSharedFields(second)) - settings.activeClaudeManagedAccountId = 'second' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(JSON.parse(second)) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(JSON.parse(second)) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(JSON.parse(second)) - } - ) - - it('preserves grants refreshed only in the keychain when returning to the system default', async () => { - const { service, settings, runtimePath, first, system } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated', refreshToken: 'rotated' } } - } - testState.legacyKeychainCredentials = withSharedFields(first, rotated) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - const nextRotation = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated-again', refreshToken: 'rotated-again' } } - } - testState.scopedKeychainCredentials = withSharedFields(system, nextRotation) - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(nextRotation) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(nextRotation) - }) - - it.each(['darwin', 'linux', 'win32'] as const)( - 'keeps connector grants through account switches, syncs, restart, and deselect on %s', - async (platform) => { - const state = await createSharedCredentialRuntime(platform) - const { service, settings, runtimePath, first, second, firstPath, secondPath } = state - for (const id of ['first', 'second', 'first']) { - settings.activeClaudeManagedAccountId = id - await service.syncForCurrentSelection() - await service.syncForCurrentSelection() - const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(runtime).toMatchObject(sharedFields) - expect(runtime.claudeAiOauth.accessToken).toBe(id) - if (platform === 'darwin') { - expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) - expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) - } - } - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(first) - ) - expect(JSON.parse(readManagedCredentialsForTest('second', secondPath) ?? '')).toEqual( - JSON.parse(second) - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } } - } - const live = withSharedFields(first, rotated) - writeFileSync(runtimePath, live) - testState.scopedKeychainCredentials = live - testState.legacyKeychainCredentials = live - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) - await restarted.syncForCurrentSelection() - settings.activeClaudeManagedAccountId = null - await restarted.syncForCurrentSelection() - const restored = JSON.parse(readFileSync(runtimePath, 'utf-8')) - expect(restored).toMatchObject(rotated) - expect(restored.claudeAiOauth.accessToken).toBe('system') - if (platform === 'darwin') { - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - } - } - ) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'preserves MCP rotations written only to the %s surface while adopting a Claude refresh', - async (surface) => { - const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const refreshed = createClaudeCredentialsJson( - 'first@example.com', - 'refreshed', - null, - Date.now() + 120_000 - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - const live = withSharedFields(refreshed, rotated) - if (surface === 'scoped') { - testState.scopedKeychainCredentials = live - } - if (surface === 'legacy') { - testState.legacyKeychainCredentials = live - } - if (surface === 'file') { - writeFileSync(runtimePath, live) - } - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(refreshed) - ) - } - ) - - it('preserves conflicting MCP grants after restart even when the Claude account token is newer', async () => { - const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const refreshed = createClaudeCredentialsJson( - 'first@example.com', - 'refreshed', - null, - Date.now() + 120_000 - ) - const rotated = { - ...sharedFields, - mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } - } - testState.legacyKeychainCredentials = withSharedFields(refreshed, rotated) - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) - await expect(restarted.syncForCurrentSelection()).rejects.toThrow( - 'live connector credentials conflict' - ) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(sharedFields) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) - expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( - JSON.parse(refreshed) - ) - }) - - it('leaves all credentials untouched when the active keychain cannot be read', async () => { - const { service, settings, runtimePath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const before = readFileSync(runtimePath, 'utf-8') - settings.activeClaudeManagedAccountId = 'second' - testState.throwScopedKeychainRead = true - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') - expect(readFileSync(runtimePath, 'utf-8')).toBe(before) - expect(testState.scopedKeychainCredentials).toBe(before) - expect(testState.legacyKeychainCredentials).toBe(before) - testState.throwScopedKeychainRead = false - await service.syncForCurrentSelection() - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('second') - warn.mockRestore() - }) - - it.each(['scoped', 'legacy', 'file'] as const)( - 'refuses to overwrite malformed live credentials in the %s surface', - async (surface) => { - const { service, settings, runtimePath } = await createSharedCredentialRuntime() - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - if (surface === 'scoped') { - testState.scopedKeychainCredentials = '{broken' - } else if (surface === 'legacy') { - testState.legacyKeychainCredentials = '{broken' - } else { - writeFileSync(runtimePath, '{broken') - } - const fileBefore = readFileSync(runtimePath, 'utf-8') - const scopedBefore = testState.scopedKeychainCredentials - const legacyBefore = testState.legacyKeychainCredentials - settings.activeClaudeManagedAccountId = 'second' - await expect(service.syncForCurrentSelection()).rejects.toThrow( - 'Cannot preserve malformed Claude runtime credentials' - ) - expect(readFileSync(runtimePath, 'utf-8')).toBe(fileBefore) - expect(testState.scopedKeychainCredentials).toBe(scopedBefore) - expect(testState.legacyKeychainCredentials).toBe(legacyBefore) - } - ) - - it('keeps newly authorized MCP grants when returning to a signed-out system default', async () => { - const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() - // A missing system credential is a signed-out default, with no connector grants yet. - rmSync(runtimePath) - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - settings.activeClaudeManagedAccountId = 'first' - await service.syncForCurrentSelection() - const live = withSharedFields(first) - writeFileSync(runtimePath, live) - testState.scopedKeychainCredentials = live - testState.legacyKeychainCredentials = live - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - expect(existsSync(runtimePath)).toBe(true) - expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(sharedFields) - expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(sharedFields) - expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(sharedFields) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts b/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts deleted file mode 100644 index 1df2430b8f6a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - readRuntimeOauthAccountForTest, - resetRuntimeAuthTestState, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('treats corrupt system-default snapshots as missing and clears owned runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync(snapshotPath, '{not-json', 'utf-8') - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats wrong-shaped system-default snapshots as missing and clears owned runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: { token: 'system' }, - keychainCredentialsJson: managedCredentials, - scopedKeychainCredentialsJson: { token: 'scoped' }, - legacyKeychainCredentialsJson: managedCredentials, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats snapshots missing all keychain credential fields as invalid', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - credentialsJson: null, - configOauthAccount: null, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') - testState.scopedKeychainCredentials = managedCredentials - testState.legacyKeychainCredentials = managedCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(snapshotPath)).toBe(false) - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - warn.mockRestore() - }) - - it('treats snapshots missing credentialsJson as invalid and clears missing-managed runtime auth', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const snapshotPath = join( - testState.userDataDir, - 'claude-runtime-auth', - 'system-default-auth.json' - ) - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) - writeFileSync( - snapshotPath, - `${JSON.stringify({ - configOauthAccount: null, - keychainCredentialsJson: null, - capturedAt: Date.now() - })}\n`, - 'utf-8' - ) - writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(existsSync(snapshotPath)).toBe(true) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - warn.mockRestore() - }) - - it('clears missing-managed oauth metadata when only keychain proves ownership', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = staleManagedCredentials - testState.legacyKeychainCredentials = staleManagedCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) - expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) - expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) - }) - - it('preserves missing-managed oauth metadata without credential ownership proof', async () => { - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - const managedOauthAccount = { accountUuid: 'account-1' } - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = externalCredentials - testState.legacyKeychainCredentials = externalCredentials - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) - ], - activeClaudeManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - await service.prepareForClaudeLaunch() - - expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalCredentials) - }) - - it('preserves invalid external runtime oauth metadata when deselecting', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - }) - - it('preserves invalid runtime config while materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - expect(testState.legacyKeychainCredentials).toBe(managedCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') - }) - - it('preserves non-object runtime config while materializing managed credentials', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '[]', 'utf-8') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('[]') - }) - - it('does not use skipped oauth writes as ownership proof on deselect', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') - const managedOauthAccount = { accountUuid: 'account-1' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - `${JSON.stringify(managedOauthAccount)}\n` - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = externalCredentials - testState.legacyKeychainCredentials = externalCredentials - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) - expect(testState.scopedKeychainCredentials).toBe(externalCredentials) - expect(testState.legacyKeychainCredentials).toBe(externalCredentials) - }) - - it('preserves external oauth logout when managed oauth metadata is null', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: { accountUuid: 'system-account' } })}\n`, - 'utf-8' - ) - testState.scopedKeychainCredentials = systemCredentials - testState.legacyKeychainCredentials = systemCredentials - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - 'null\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - rmSync(runtimeCredentialsPath, { force: true }) - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(existsSync(runtimeCredentialsPath)).toBe(false) - expect(readRuntimeOauthAccountForTest()).toBeNull() - expect(testState.scopedKeychainCredentials).toBeNull() - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('restores reordered owned oauth metadata using stable json equality', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account', emailAddress: 'system@example.com' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - '{"accountUuid":"account-1","emailAddress":"user@example.com"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - writeFileSync( - runtimeConfigPath, - '{"oauthAccount":{"emailAddress":"user@example.com","accountUuid":"account-1"}}\n', - 'utf-8' - ) - settings.activeClaudeManagedAccountId = null - await service.syncForCurrentSelection() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) - - it('restores owned oauth metadata during rollback after removing the added account', async () => { - const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') - const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') - const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') - const systemOauthAccount = { accountUuid: 'system-account' } - writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - writeFileSync( - runtimeConfigPath, - `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, - 'utf-8' - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials, - '{"accountUuid":"account-1"}\n' - ) - const settings = createSettings({ - claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - settings.activeClaudeManagedAccountId = 'account-1' - await service.syncForCurrentSelection() - - settings.activeClaudeManagedAccountId = null - settings.claudeManagedAccounts = [] - await service.forceMaterializeCurrentSelectionForRollback() - - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service-test-harness.ts b/src/main/claude-accounts/runtime-auth-service-test-harness.ts deleted file mode 100644 index 874e7534ff1a..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-test-harness.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { ClaudeManagedAccount } from '../../shared/managed-account-types' - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -export const hostPlatform = process.platform -export const testState = { - userDataDir: '', - fakeHomeDir: '', - activeKeychainCredentials: null as string | null, - scopedKeychainCredentials: null as string | null, - legacyKeychainCredentials: null as string | null, - throwScopedKeychainRead: false, - throwLegacyKeychainRead: false, - throwRuntimeKeychainWrite: false, - throwLegacyRuntimeKeychainWrite: false, - throwScopedKeychainWrite: false, - runtimeWriteConfigDir: null as string | null, - managedKeychainCredentials: new Map() -} - -export function expectedRuntimeConfigDir(): string { - return join(testState.fakeHomeDir, '.claude') -} - -export function createElectronMock() { - return { - app: { - getPath: () => testState.userDataDir - } - } -} - -// Why: these tests exercise materialize/read-back/snapshot logic, not the -// network OAuth refresh (covered by oauth-refresh.test.ts). Default the token -// to "not expiring" so the proactive switch-in refresh never fires here and -// existing expectations hold; individual tests can override these mocks. -export function createOauthRefreshMock() { - return { - isOauthTokenExpiring: vi.fn(() => false), - refreshClaudeOauthCredentials: vi.fn(async () => null) - } -} - -export function createKeychainMock() { - return { - readActiveClaudeKeychainCredentials: vi.fn(async (configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - return testState.legacyKeychainCredentials - } - return testState.scopedKeychainCredentials ?? testState.legacyKeychainCredentials - } - return testState.legacyKeychainCredentials - }), - writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string, configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - if (testState.throwScopedKeychainWrite) { - throw new Error('scoped keychain write failed') - } - testState.scopedKeychainCredentials = contents - } else { - testState.legacyKeychainCredentials = contents - } - testState.activeKeychainCredentials = contents - }), - deleteActiveClaudeKeychainCredentials: vi.fn(async () => { - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - testState.activeKeychainCredentials = null - }), - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { - if (configDir) { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - testState.scopedKeychainCredentials = null - } else { - testState.legacyKeychainCredentials = null - } - testState.activeKeychainCredentials = null - }), - readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => - configDir - ? (() => { - if (testState.throwScopedKeychainRead) { - throw new Error('scoped keychain read failed') - } - return configDir === expectedRuntimeConfigDir() - ? testState.scopedKeychainCredentials - : null - })() - : (() => { - if (testState.throwLegacyKeychainRead) { - throw new Error('legacy keychain read failed') - } - return testState.legacyKeychainCredentials - })() - ), - writeActiveClaudeKeychainCredentialsForRuntime: vi.fn( - async (contents: string, configDir: string) => { - if (configDir !== expectedRuntimeConfigDir()) { - throw new Error(`Unexpected Claude config dir: ${configDir}`) - } - if (testState.throwRuntimeKeychainWrite) { - throw new Error('runtime keychain write failed') - } - testState.runtimeWriteConfigDir = configDir - testState.scopedKeychainCredentials = contents - if (testState.throwLegacyRuntimeKeychainWrite) { - throw new Error('legacy runtime keychain write failed') - } - testState.legacyKeychainCredentials = contents - testState.activeKeychainCredentials = contents - } - ), - readManagedClaudeKeychainCredentials: vi.fn( - async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null - ), - writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { - testState.managedKeychainCredentials.set(accountId, contents) - }) - } -} - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -/** Shared `beforeEach` body: fresh platform, module registry, and temp homes. */ -export function resetRuntimeAuthTestState(): void { - setPlatform('darwin') - vi.resetModules() - vi.clearAllMocks() - testState.activeKeychainCredentials = null - testState.scopedKeychainCredentials = null - testState.legacyKeychainCredentials = null - testState.throwScopedKeychainRead = false - testState.throwLegacyKeychainRead = false - testState.throwRuntimeKeychainWrite = false - testState.throwLegacyRuntimeKeychainWrite = false - testState.throwScopedKeychainWrite = false - testState.runtimeWriteConfigDir = null - testState.managedKeychainCredentials.clear() - testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-')) - testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-')) - mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) -} - -/** Shared `afterEach` body. */ -export function cleanupRuntimeAuthTestState(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - rmSync(testState.userDataDir, { recursive: true, force: true }) - rmSync(testState.fakeHomeDir, { recursive: true, force: true }) -} - -export function createSettings(overrides: Partial = {}): GlobalSettings { - return { - ...getDefaultSettings(testState.fakeHomeDir), - ...overrides - } -} - -export function createStore(settings: GlobalSettings) { - return { - getSettings: vi.fn(() => settings), - updateSettings: vi.fn((updates: Partial) => { - settings = { - ...settings, - ...updates, - notifications: { - ...settings.notifications, - ...updates.notifications - } - } - return settings - }) - } -} - -export function createManagedClaudeAuth( - rootDir: string, - accountId: string, - credentialsJson: string, - oauthAccountJson = `{"accountUuid":"${accountId}"}\n` -): string { - const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') - writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') - testState.managedKeychainCredentials.set(accountId, credentialsJson) - return managedAuthPath -} - -export function createClaudeAccount( - id: string, - managedAuthPath: string, - overrides: Partial = {} -): ClaudeManagedAccount { - return { - id, - email: 'user@example.com', - managedAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: null, - organizationName: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1, - ...overrides - } -} - -export function createClaudeCredentialsJson( - email: string, - accessToken: string, - organizationUuid: string | null = null, - expiresAt = Date.now() + 60_000 -): string { - return `${JSON.stringify({ - claudeAiOauth: { - email, - ...(organizationUuid ? { organizationUuid } : {}), - accessToken, - refreshToken: `${accessToken}-refresh`, - expiresAt - } - })}\n` -} - -export function createClaudeCredentialsWithoutEmail( - accessToken: string, - organizationUuid: string | null = null, - options: { expiresAt?: number; refreshToken?: string } = {} -): string { - return `${JSON.stringify({ - claudeAiOauth: { - ...(organizationUuid ? { organizationUuid } : {}), - accessToken, - refreshToken: options.refreshToken ?? `${accessToken}-refresh`, - expiresAt: options.expiresAt ?? Date.now() + 60_000 - } - })}\n` -} - -export function readManagedCredentialsForTest( - accountId: string, - managedAuthPath: string -): string | null { - if (process.platform === 'darwin') { - return testState.managedKeychainCredentials.get(accountId) ?? null - } - return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') -} - -export function readRuntimeOauthAccountForTest(): unknown { - const configPath = join(testState.fakeHomeDir, '.claude.json') - if (!existsSync(configPath)) { - return null - } - return ( - (JSON.parse(readFileSync(configPath, 'utf-8')) as Record).oauthAccount ?? null - ) -} diff --git a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts deleted file mode 100644 index 61c3323b6327..000000000000 --- a/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - cleanupRuntimeAuthTestState, - createClaudeAccount, - createClaudeCredentialsJson, - createElectronMock, - createKeychainMock, - createManagedClaudeAuth, - createOauthRefreshMock, - createSettings, - createStore, - resetRuntimeAuthTestState, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type * as ClaudeProfileRouting from '../../shared/claude-profile-routing' - -vi.mock('electron', () => createElectronMock()) - -vi.mock('./oauth-refresh', () => createOauthRefreshMock()) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { - ...actual, - homedir: () => testState.fakeHomeDir - } -}) - -vi.mock('./keychain', () => createKeychainMock()) - -describe('ClaudeRuntimeAuthService', () => { - beforeEach(() => { - resetRuntimeAuthTestState() - }) - - afterEach(() => { - cleanupRuntimeAuthTestState() - }) - - it('uses account WSL runtime for untargeted Claude preparation instead of stale terminal WSL settings', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const settings = createSettings({ - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian', - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account' } - } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - ubuntuAuthPath - ) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', - provenance: 'managed:ubuntu-account:wsl:Ubuntu', - stripAuthEnv: true - }) - }) - - it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const settings = createSettings({ - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account' } - } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - provenance: 'managed:ubuntu-account:wsl:Ubuntu' - }) - }) - - it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => { - setPlatform('darwin') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const settings = createSettings({ - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'host', - wslDistro: null, - provenance: 'system', - stripAuthEnv: false - }) - }) - - it('keeps untargeted Claude preparation on host when account runtime is host', async () => { - setPlatform('win32') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => null, - toWindowsWslPath: (value: string) => value - })) - const settings = createSettings({ - localAccountRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch() - - expect(preparation).toMatchObject({ - runtime: 'host', - wslDistro: null, - provenance: 'system', - stripAuthEnv: false - }) - }) - - it('clears a selected WSL managed account when its credentials are missing', async () => { - const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('account-1', managedAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } - }) - const store = createStore(settings) - - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - - expect(store.updateSettings).toHaveBeenCalledWith({ - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(preparation.runtime).toBe('wsl') - expect(preparation.provenance).toBe('wsl:Ubuntu:system') - expect(preparation.stripAuthEnv).toBe(true) - }) - - it('uses the default distro selection for WSL-default Claude preparation', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => join(testState.userDataDir, 'wsl-home'), - toWindowsWslPath: (value: string) => value - })) - const ubuntuAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'ubuntu-account', - createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') - ) - const debianAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'debian-account', - createClaudeCredentialsJson('debian@example.com', 'debian-token') - ) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('ubuntu-account', ubuntuAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' - }), - createClaudeAccount('debian-account', debianAuthPath, { - managedAuthRuntime: 'wsl', - wslDistro: 'Debian', - wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' - }) - ], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } - } - }) - const store = createStore(settings) - - try { - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const service = new ClaudeRuntimeAuthService(store as never) - const preparation = await service.prepareForClaudeLaunch({ - runtime: 'wsl', - wslDistro: null - }) - - expect(preparation).toMatchObject({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', - provenance: 'managed:ubuntu-account:wsl:Ubuntu', - stripAuthEnv: true - }) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('routes a WSL distro through its guest router, and a failed guest publish never fails a select', async () => { - setPlatform('win32') - const wslRouter = { - prepareLaunch: vi.fn(async (distro: string) => ({ runtime: 'wsl', wslDistro: distro })), - publish: vi.fn(async () => { - throw new Error('distro is gone') - }), - runningDistros: vi.fn(async () => []) - } - vi.doMock('../../shared/claude-profile-routing', async (original) => ({ - ...(await original()), - claudeProfileRoutingEnabled: () => true - })) - vi.doMock('./claude-profile-wsl-router', () => ({ - ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { - return wslRouter - } - })) - try { - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - const store = createStore(createSettings()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only getSettings/updateSettings, which the harness store implements. - const service = new ClaudeRuntimeAuthService(store as never) - await expect( - service.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toMatchObject({ wslDistro: 'Ubuntu' }) - await expect( - service.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - ).resolves.toBeUndefined() - expect(wslRouter.publish).toHaveBeenCalledWith('Ubuntu') - } finally { - vi.doUnmock('../../shared/claude-profile-routing') - vi.doUnmock('./claude-profile-wsl-router') - } - }) -}) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts new file mode 100644 index 000000000000..25ecbab4a845 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' + +const fakes = vi.hoisted(() => ({ + host: { + prepareLaunch: async () => ({ runtime: 'host' }), + publish: () => {}, + systemDefaultHome: () => '/host/.claude' + }, + wsl: { + prepareLaunch: async (distro: string) => ({ runtime: 'wsl', wslDistro: distro }), + publish: async (_distro: string): Promise => {}, + runningDistros: async (): Promise => [] + } +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/data/orca' }) +})) +vi.mock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: (distro: string) => `/wsl/${distro}/home` +})) +vi.mock('./claude-profile-router', () => ({ + ClaudeProfileRouter: function ClaudeProfileRouter() { + return fakes.host + } +})) +vi.mock('./claude-profile-wsl-router', () => ({ + ClaudeWslProfileRouter: function ClaudeWslProfileRouter() { + return fakes.wsl + } +})) +vi.mock('./claude-profile-installed-router', () => ({ + installClaudeProfileRouter: () => {}, + installClaudeWslProfileRouter: () => {} +})) + +import { ClaudeRuntimeAuthService } from './runtime-auth-service' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +function service(platformName: NodeJS.Platform, overrides: Partial = {}) { + Object.defineProperty(process, 'platform', { configurable: true, value: platformName }) + const settings = { ...getDefaultSettings('/home/user'), ...overrides } + return new ClaudeRuntimeAuthService({ getSettings: () => settings }) +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe('ClaudeRuntimeAuthService', () => { + it('keeps a stale WSL account setting on the host off Windows', async () => { + const auth = service('darwin', { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }) + expect(auth.getRuntimeConfigDir()).toBe('/host/.claude') + await expect(auth.prepareForClaudeLaunch()).resolves.toEqual({ runtime: 'host' }) + }) + + it('routes a WSL target that names no distro to the default distro', async () => { + const auth = service('win32') + await expect(auth.prepareForClaudeLaunch({ runtime: 'wsl', wslDistro: null })).resolves.toEqual( + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + expect(auth.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: null })).toBe( + '/wsl/Ubuntu/home/.claude' + ) + }) + + it('never fails a select over a failed guest publish, and publishes one pass at a time', async () => { + const auth = service('win32') + vi.spyOn(fakes.wsl, 'publish').mockRejectedValue(new Error('distro is gone')) + await expect( + auth.syncForCurrentSelection({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeUndefined() + + const order: string[] = [] + let release = () => {} + const gate = new Promise((resolve) => (release = resolve)) + vi.spyOn(fakes.host, 'publish').mockImplementation(() => order.push('host')) + vi.spyOn(fakes.wsl, 'runningDistros') + .mockImplementationOnce(async () => { + await gate + order.push('first guests') + return [] + }) + .mockResolvedValue([]) + const first = auth.publishAll() + const second = auth.publishAll() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(order).toEqual(['host']) + release() + await Promise.all([first, second]) + expect(order).toEqual(['host', 'first guests', 'host']) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index adbbce30da01..66659ddb53fc 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,164 +1,159 @@ +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' +import { toWindowsWslPath } from '../../shared/wsl-paths' +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import type { Store } from '../persistence' +import { getDefaultWslDistro, getWslHome } from '../wsl' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import { - getClaudeProfileRouter, - installClaudeProfileRouter + installClaudeProfileRouter, + installClaudeWslProfileRouter } from './claude-profile-installed-router' -import type { Store } from '../persistence' -import { - getSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from './runtime-selection' -import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' +import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { - return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() -} +/** `configDir` is the CLAUDE_CONFIG_DIR value; `readPath` is where this host reads it (UNC for WSL). */ +export type ClaudeAccountFolder = { configDir: string; readPath: string } -export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { +/** Routes Claude launches on this host and its WSL distros to the selected account folder. */ +export class ClaudeRuntimeAuthService { + readonly router: ClaudeProfileRouter private readonly wslRouter?: ClaudeWslProfileRouter - - constructor(store: Store) { - super(store) - if (claudeProfileRoutingEnabled()) { - const args = { - getSettings: () => store.getSettings(), - dataRoot: getAppEnvironment().getPath('userData') - } - installClaudeProfileRouter(new ClaudeProfileRouter(args)) - this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined - } - this.initializeLastSyncedState() - void this.safeSyncForCurrentSelection() + private readonly dataRoot = getAppEnvironment().getPath('userData') + private mutationQueue: Promise = Promise.resolve() + + constructor(private readonly store: Pick) { + const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } + this.router = new ClaudeProfileRouter(args) + installClaudeProfileRouter(this.router) + this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined + installClaudeWslProfileRouter(this.wslRouter) + void this.publishAll().catch((error: unknown) => { + console.warn('[claude-runtime-auth] Failed to publish the Claude account selection:', error) + }) } - async prepareForClaudeLaunch( + prepareForClaudeLaunch( target?: ClaudeAccountSelectionTarget ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - if (wsl) { - return wsl.router.prepareLaunch(wsl.distro) - } - const router = routerFor(effectiveTarget) - if (router) { - return router.prepareLaunch() - } - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) + const wsl = this.wslRouteFor(target) + return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() } async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - if (wsl) { - return wsl.router.preparation(wsl.distro) - } - const router = routerFor(effectiveTarget) - if (router) { - return router.preparation() + const wsl = this.wslRouteFor(target) + try { + return await (wsl ? wsl.router.preparation(wsl.distro) : this.router.preparation()) + } catch (error) { + // Why not thrown: one usage cycle polls every provider, so one account must not stop it. + return { + configDir: '', + envPatch: {}, + provenance: 'profile:unavailable', + usageError: error instanceof Error ? error.message : String(error) + } } - await this.syncForCurrentSelection(effectiveTarget) - return this.getPreparation(effectiveTarget) } + /** Rewrites the which-account file for `target` after its selection changed. */ async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { await this.serializeMutation(async () => { - const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() - const wsl = this.wslRouteFor(effectiveTarget) - const router = routerFor(effectiveTarget) + const wsl = this.wslRouteFor(target) if (wsl) { await this.publishWsl(wsl.router, wsl.distro) - } else if (router) { - router.publish() } else { - await this.doSyncForCurrentSelection(effectiveTarget) + this.router.publish() } }) } - /** Null when the target is not a WSL distro routed by account folders. */ - private wslRouteFor( - target: ClaudeAccountSelectionTarget - ): { router: ClaudeWslProfileRouter; distro: string } | null { - const distro = - target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null - return this.wslRouter && distro ? { router: this.wslRouter, distro } : null - } - - // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must - // not block removing its accounts. The next select, or a start while it runs, rewrites it. - private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { - await router.publish(distro).catch((error: unknown) => { - console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + /** Startup and rollback republish only running distros: neither may boot a stopped one. */ + async publishAll(): Promise { + await this.serializeMutation(async () => { + this.router.publish() + const router = this.wslRouter + if (router) { + const distros = await router.runningDistros() + await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + } }) } - /** Startup and rollback republish only running distros: neither may boot a stopped one. */ - private async publishRunningWslDistros(): Promise { - const router = this.wslRouter - if (router) { - const distros = await router.runningDistros() - await Promise.all(distros.map((distro) => this.publishWsl(router, distro))) + /** Creates and sets up an account's folder so Claude can sign in to it. */ + async prepareAccountFolder( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const wsl = this.wslRouteFor(target) + if (!wsl) { + const home = await this.router.prepareAccount(accountId) + return { configDir: home, readPath: home } } + const home = await wsl.router.prepareAccount(wsl.distro, accountId) + return { configDir: home, readPath: toWindowsWslPath(home, wsl.distro) } } - async forceMaterializeCurrentSelectionForRollback(): Promise { - await this.serializeMutation(async () => { - const router = getClaudeProfileRouter() - if (router) { - router.publish() - await this.publishRunningWslDistros() - return - } - const settings = this.store.getSettings() - if (!settings.activeClaudeManagedAccountId) { - const previousAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - this.lastSyncedAccountId - ) - await this.restoreSystemDefaultSnapshot( - previousAccount ? await this.readManagedCredentials(previousAccount) : null, - previousAccount ? await this.readManagedOauthAccount(previousAccount) : undefined - ) - this.lastSyncedAccountId = null - return - } - await this.doSyncForCurrentSelection() + async removeAccountFolder( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const wsl = this.wslRouteFor(target) + if (!wsl) { + await this.router.removeAccount(accountId) + return + } + // Why never thrown: a deleted distro must not block removing its accounts. + await wsl.router.removeAccount(wsl.distro, accountId).catch((error: unknown) => { + console.warn( + `[claude-profile] Could not delete the Claude account folder in WSL ${wsl.distro}:`, + error + ) }) } + /** System default's folder: skills and plugins install there and are linked into accounts. */ getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.getPreparation(target).configDir + const effective = this.resolveTarget(target) + const home = + effective.runtime === 'wsl' && effective.wslDistro ? getWslHome(effective.wslDistro) : null + return home ? join(home, '.claude') : this.router.systemDefaultHome() } - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must + // not block removing its accounts. The next select, or a start while it runs, rewrites it. + private async publishWsl(router: ClaudeWslProfileRouter, distro: string): Promise { + await router.publish(distro).catch((error: unknown) => { + console.warn(`[claude-profile] Could not update the Claude account in WSL ${distro}:`, error) + }) } - private async safeSyncForCurrentSelection(): Promise { - try { - const router = getClaudeProfileRouter() - if (!router) { - await this.syncForCurrentSelection() - return - } - // Why serialized: an account change during startup must not be overwritten by this older read. - await this.serializeMutation(async () => { - router.publish() - await this.publishRunningWslDistros() - }) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) + /** Null when the target is not a WSL distro this host can route. */ + private wslRouteFor( + target?: ClaudeAccountSelectionTarget + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const effective = this.resolveTarget(target) + const distro = effective.runtime === 'wsl' ? effective.wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null + } + + /** A WSL target that names no distro means the default distro, as Claude launches it there. */ + private resolveTarget(target?: ClaudeAccountSelectionTarget): ClaudeAccountSelectionTarget { + const effective = + target ?? + // Why: stale cross-platform WSL pins must stay on the host off Windows. + (process.platform === 'win32' + ? resolveLocalAccountRuntimeTarget(this.store.getSettings()) + : { runtime: 'host' as const }) + if (effective.runtime !== 'wsl' || effective.wslDistro?.trim()) { + return effective } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : effective } private serializeMutation(fn: () => Promise): Promise { @@ -166,14 +161,4 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { this.mutationQueue = next.catch(() => {}) return next } - - // Why: re-auth/add-account write fresh managed tokens; skip the next read-back so stale runtime tokens can't overwrite them. - clearLastWrittenCredentialsJson( - accountId = this.store.getSettings().activeClaudeManagedAccountId - ): void { - if (accountId === this.store.getSettings().activeClaudeManagedAccountId) { - this.lastWrittenCredentialsJson = null - } - this.skipNextReadBackForAccountId = accountId - } } diff --git a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts deleted file mode 100644 index 8cb00420dc65..000000000000 --- a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - createClaudeAccount, - createClaudeCredentialsJson, - createManagedClaudeAuth, - createSettings, - createStore, - setPlatform, - testState -} from './runtime-auth-service-test-harness' -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' - -export const sharedFields = { - mcpOAuth: { figma: { accessToken: 'mcp-access', refreshToken: 'mcp-refresh' } }, - mcpOAuthClientConfig: { figma: { clientId: 'figma-client' } }, - mcpXaaIdp: { token: 'idp-token' }, - mcpXaaIdpConfig: { issuer: 'idp-issuer' }, - pluginSecrets: { plugin: 'secret' } -} - -export function withSharedFields( - credentials: string, - fields: Record = sharedFields -): string { - return JSON.stringify({ ...JSON.parse(credentials), ...fields }) -} - -export async function createSharedCredentialRuntime(platform: NodeJS.Platform = 'darwin') { - setPlatform(platform) - const runtimePath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - const system = createClaudeCredentialsJson('system@example.com', 'system') - const first = createClaudeCredentialsJson('first@example.com', 'first') - const second = createClaudeCredentialsJson('second@example.com', 'second') - const firstPath = createManagedClaudeAuth(testState.userDataDir, 'first', first) - const secondPath = createManagedClaudeAuth(testState.userDataDir, 'second', second) - writeFileSync(runtimePath, withSharedFields(system)) - testState.scopedKeychainCredentials = withSharedFields(system) - testState.legacyKeychainCredentials = withSharedFields(system) - const settings = createSettings({ - claudeManagedAccounts: [ - createClaudeAccount('first', firstPath, { email: 'first@example.com' }), - createClaudeAccount('second', secondPath, { email: 'second@example.com' }) - ] - }) - const store = createStore(settings) - const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. - const service = new ClaudeRuntimeAuthService(store as never) - await service.syncForCurrentSelection() - return { service, settings, runtimePath, first, second, system, firstPath, secondPath } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts deleted file mode 100644 index d029ae0d6992..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage' -import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' -import type { - ClaudeAuthIdentity, - ClaudeReadBackMatch, - ClaudeRefreshTokenComparison -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage { - protected accountCredentialFieldsEqual(left: string | null, right: string | null): boolean { - if (left === right) { - return true - } - if (left === null || right === null) { - return false - } - try { - const leftAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(left))) - const rightAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(right))) - return ( - leftAccount !== null && - rightAccount !== null && - this.jsonValuesEqual(leftAccount, rightAccount) - ) - } catch { - return false - } - } - - protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { - let parsed: Record - try { - parsed = JSON.parse(credentialsJson) as Record - } catch { - return null - } - const oauth = this.asRecord(parsed.claudeAiOauth) - return { - accountUuid: this.normalizeField( - this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') - ), - email: this.normalizeField(this.readString(oauth, 'email')), - organizationUuid: this.normalizeField( - this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') - ) - } - } - - protected isValidCredentialsJsonObject(credentialsJson: string): boolean { - try { - const parsed = this.asRecord(JSON.parse(credentialsJson)) - const oauth = this.asRecord(parsed?.claudeAiOauth) - return this.normalizeField(this.readString(oauth, 'accessToken')) !== null - } catch { - return false - } - } - - protected runtimeCredentialsAreFresher( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): boolean { - const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) - const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) - return ( - runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness - ) - } - - protected runtimeCredentialsAreOlder( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): boolean { - const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) - const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) - return ( - runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness < managedFreshness - ) - } - - protected chooseFreshestReadBackCandidate( - candidates: { - credentialsJson: string - match: Extract - }[] - ): { - credentialsJson: string - match: Extract - } { - return candidates.reduce((freshest, candidate) => { - const candidateFreshness = this.readFreshnessFromCredentials(candidate.credentialsJson) - const freshestFreshness = this.readFreshnessFromCredentials(freshest.credentialsJson) - if ( - candidateFreshness !== null && - (freshestFreshness === null || candidateFreshness > freshestFreshness) - ) { - return candidate - } - return freshest - }) - } - - protected readFreshnessFromCredentials(credentialsJson: string): number | null { - let parsed: Record - try { - parsed = JSON.parse(credentialsJson) as Record - } catch { - return null - } - const oauth = this.asRecord(parsed.claudeAiOauth) - return ( - this.readNumber(oauth, 'expiresAt') ?? - this.readNumber(oauth, 'expires_at') ?? - this.readNumber(oauth, 'expiry') ?? - this.readNumber(oauth, 'expires') - ) - } - - protected compareRefreshTokens( - runtimeCredentialsJson: string, - managedCredentialsJson: string - ): ClaudeRefreshTokenComparison { - const runtimeRefreshToken = this.readRefreshTokenFromCredentials(runtimeCredentialsJson) - const managedRefreshToken = this.readRefreshTokenFromCredentials(managedCredentialsJson) - if (!runtimeRefreshToken || !managedRefreshToken) { - return 'missing' - } - return runtimeRefreshToken === managedRefreshToken ? 'same' : 'different' - } - - protected readRefreshTokenFromCredentials(credentialsJson: string): string | null { - try { - const parsed = JSON.parse(credentialsJson) as Record - const oauth = this.asRecord(parsed.claudeAiOauth) - return this.normalizeField(this.readString(oauth, 'refreshToken')) - } catch { - return null - } - } - - protected readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { - const oauth = this.asRecord(oauthAccount) - return { - accountUuid: this.normalizeField( - this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId') - ), - email: this.normalizeField( - this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email') - ), - organizationUuid: this.normalizeField( - this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') - ) - } - } - - protected asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - return value as Record - } - - protected readString(value: Record | null, key: string): string | null { - const candidate = value?.[key] - return typeof candidate === 'string' ? candidate : null - } - - protected readNumber(value: Record | null, key: string): number | null { - const candidate = value?.[key] - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return candidate - } - if (typeof candidate === 'string') { - const parsed = Number(candidate) - return Number.isFinite(parsed) ? parsed : null - } - return null - } - - protected normalizeField(value: string | null | undefined): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed === '' ? null : trimmed - } - - protected jsonValuesEqual(left: unknown, right: unknown): boolean { - return ( - JSON.stringify(this.sortJsonValue(left ?? null)) === - JSON.stringify(this.sortJsonValue(right ?? null)) - ) - } - - protected sortJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => this.sortJsonValue(item)) - } - const record = this.asRecord(value) - if (!record) { - return value - } - return Object.fromEntries( - Object.entries(record) - .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - .map(([key, nestedValue]) => [key, this.sortJsonValue(nestedValue)]) - ) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts deleted file mode 100644 index 390ec57a8b2f..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { ClaudeRuntimeAuthRuntimeState } from './runtime-auth-runtime-state' -import type { ClaudeReadBackMatch } from './runtime-auth-types' - -export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntimeState { - protected async findManagedAccountForRuntimeCredentials( - runtimeCredentialsJson: string, - runtimeOauthAccount: unknown - ): Promise { - const matches: { account: ClaudeManagedAccount; managedCredentialsJson: string }[] = [] - let unverifiableCount = 0 - for (const account of this.store.getSettings().claudeManagedAccounts) { - const managedCredentialsJson = await this.readManagedCredentials(account) - if (!managedCredentialsJson) { - continue - } - const match = this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - runtimeOauthAccount, - account, - managedCredentialsJson, - await this.readManagedOauthAccount(account) - ) - if (match === 'match') { - matches.push({ account, managedCredentialsJson }) - } else if (match === 'unverifiable') { - unverifiableCount += 1 - } - } - - if (matches.length === 1 && unverifiableCount === 0) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 && unverifiableCount === 0 ? 'none' : 'ambiguous' } - } - - protected runtimeCredentialsMatchAccount( - runtimeCredentialsJson: string, - runtimeOauthAccount: unknown, - account: ClaudeManagedAccount, - managedCredentialsJson: string, - managedOauthAccount: unknown - ): 'match' | 'mismatch' | 'unverifiable' { - const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) - if (!identity) { - return 'mismatch' - } - const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) - const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const runtimeOauthIdentity = this.readIdentityFromOauthAccount(runtimeOauthAccount) - const credentialOauthConflict = - (identity.accountUuid && - runtimeOauthIdentity.accountUuid && - identity.accountUuid !== runtimeOauthIdentity.accountUuid) || - (identity.email && - runtimeOauthIdentity.email && - identity.email !== runtimeOauthIdentity.email) || - (identity.organizationUuid && - runtimeOauthIdentity.organizationUuid && - identity.organizationUuid !== runtimeOauthIdentity.organizationUuid) - if (credentialOauthConflict) { - return 'mismatch' - } - - // Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them. - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? - managedIdentity?.organizationUuid ?? - managedOauthIdentity.organizationUuid - ) - const oauthAccountMatches = - Boolean(managedOauthIdentity.accountUuid) && - managedOauthIdentity.accountUuid === runtimeOauthIdentity.accountUuid && - Boolean(runtimeOauthIdentity.email || runtimeOauthIdentity.organizationUuid) - const runtimeEmail = identity.email ?? runtimeOauthIdentity.email - const runtimeOrganizationUuid = - identity.organizationUuid ?? runtimeOauthIdentity.organizationUuid - const refreshTokenComparison = this.compareRefreshTokens( - runtimeCredentialsJson, - managedCredentialsJson - ) - if (!runtimeEmail) { - if (refreshTokenComparison === 'same') { - return 'match' - } - if (identity.organizationUuid) { - if (selectedOrganizationUuid && selectedOrganizationUuid !== identity.organizationUuid) { - return 'mismatch' - } - return 'unverifiable' - } - if (oauthAccountMatches) { - return 'match' - } - if (!runtimeOrganizationUuid && refreshTokenComparison === 'different') { - return 'mismatch' - } - return 'unverifiable' - } - if (account.email && this.normalizeField(account.email) !== runtimeEmail) { - return 'mismatch' - } - if (selectedOrganizationUuid && !runtimeOrganizationUuid) { - return refreshTokenComparison === 'same' || oauthAccountMatches ? 'match' : 'unverifiable' - } - if ( - selectedOrganizationUuid && - runtimeOrganizationUuid && - selectedOrganizationUuid !== runtimeOrganizationUuid - ) { - return 'mismatch' - } - if (!selectedOrganizationUuid && runtimeOrganizationUuid) { - return refreshTokenComparison === 'same' ? 'match' : 'unverifiable' - } - - return 'match' - } - - protected liveRuntimeCredentialsCanUpdateActiveAccount( - runtimeCredentialsJson: string, - account: ClaudeManagedAccount, - managedCredentialsJson: string, - managedOauthAccount: unknown - ): boolean { - const match = this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - this.readRuntimeOauthAccount(), - account, - managedCredentialsJson, - managedOauthAccount - ) - if (match === 'match') { - return true - } - const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) - const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) - const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const runtimeOauthIdentity = this.readIdentityFromOauthAccount(this.readRuntimeOauthAccount()) - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? - managedIdentity?.organizationUuid ?? - managedOauthIdentity.organizationUuid - ) - return ( - match === 'unverifiable' && - Boolean(selectedOrganizationUuid) && - (identity?.organizationUuid ?? runtimeOauthIdentity.organizationUuid) === - selectedOrganizationUuid - ) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts deleted file mode 100644 index 8aa33a88c609..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { app } from 'electron' -import { writeFileAtomically } from '../../codex-accounts/fs-utils' -import { ClaudeRuntimeAuthState } from './runtime-auth-state' - -export class ClaudeRuntimeAuthFileStorage extends ClaudeRuntimeAuthState { - protected writeRuntimeCredentials(contents: string): void { - const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - mkdirSync(dirname(credentialsPath), { recursive: true }) - // Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it. - if ( - this.lastWrittenCredentialsJson === contents && - this.fileContentsEqual(credentialsPath, contents) - ) { - this.ensureOwnerOnlyMode(credentialsPath) - return - } - if (this.fileContentsEqual(credentialsPath, contents)) { - this.ensureOwnerOnlyMode(credentialsPath) - this.lastWrittenCredentialsJson = contents - return - } - writeFileAtomically(credentialsPath, contents, { mode: 0o600 }) - this.lastWrittenCredentialsJson = contents - } - - protected writeJson(targetPath: string, value: unknown): void { - const serialized = `${JSON.stringify(value, null, 2)}\n` - mkdirSync(dirname(targetPath), { recursive: true }) - // Why: same Windows contention reason as writeRuntimeCredentials. - if (this.fileContentsEqual(targetPath, serialized)) { - return - } - writeFileAtomically(targetPath, serialized, { mode: 0o600 }) - } - - protected fileContentsEqual(targetPath: string, contents: string): boolean { - try { - return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents - } catch { - return false - } - } - - protected ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - protected readJsonObject(targetPath: string): Record | null { - if (!existsSync(targetPath)) { - return {} - } - try { - const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record - } - } catch { - // Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings. - return null - } - return null - } - - protected getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - protected getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts deleted file mode 100644 index ef4c54acab83..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../keychain' -import { ClaudeRuntimeAuthManagedCredentials } from './runtime-auth-managed-credentials' -import type { - ClaudeKeychainReadResult, - ClaudeKeychainSnapshotValue, - ClaudeSystemDefaultSnapshot -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManagedCredentials { - protected isSystemDefaultSnapshot(value: unknown): value is ClaudeSystemDefaultSnapshot { - const snapshot = this.asRecord(value) - return ( - snapshot !== null && - Object.hasOwn(snapshot, 'credentialsJson') && - this.isOptionalNullableString(snapshot.credentialsJson) && - this.isOptionalNullableString(snapshot.keychainCredentialsJson) && - this.isOptionalNullableString(snapshot.scopedKeychainCredentialsJson) && - this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) && - this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) && - this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) && - this.hasValidKeychainSnapshotValue(snapshot, 'scoped') && - this.hasValidKeychainSnapshotValue(snapshot, 'legacy') && - (snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number') - ) - } - - protected isOptionalNullableString(value: unknown): boolean { - return value === undefined || value === null || typeof value === 'string' - } - - protected isOptionalBoolean(value: unknown): boolean { - return value === undefined || typeof value === 'boolean' - } - - protected snapshotKeychainCredentials( - credentialsJson: string | null, - previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined, - service: 'scoped' | 'legacy', - managedCredentialsJson: string | undefined - ): string | null { - if ( - managedCredentialsJson && - this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) && - previousSnapshot - ) { - const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) - if (previousValue.status === 'captured') { - return previousValue.credentialsJson - } - } - return credentialsJson - } - - protected hasValidKeychainSnapshotValue( - snapshot: Record, - service: 'scoped' | 'legacy' - ): boolean { - const capturedKey = - service === 'scoped' - ? 'scopedKeychainCredentialsCaptured' - : 'legacyKeychainCredentialsCaptured' - if (snapshot[capturedKey] === false) { - return true - } - const credentialsKey = - service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' - return ( - Object.hasOwn(snapshot, credentialsKey) || Object.hasOwn(snapshot, 'keychainCredentialsJson') - ) - } - - protected readKeychainSnapshotValue( - snapshot: ClaudeSystemDefaultSnapshot | null, - service: 'scoped' | 'legacy' - ): ClaudeKeychainSnapshotValue { - if (!snapshot) { - return { status: 'captured', credentialsJson: null } - } - const capturedKey = - service === 'scoped' - ? 'scopedKeychainCredentialsCaptured' - : 'legacyKeychainCredentialsCaptured' - if (snapshot[capturedKey] === false) { - return { status: 'unknown' } - } - const credentialsKey = - service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' - if (Object.hasOwn(snapshot, credentialsKey)) { - return { - status: 'captured', - credentialsJson: snapshot[credentialsKey] ?? null - } - } - return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson } - } - - protected async readAggregateClaudeKeychainCredentialsBestEffort( - configDir: string - ): Promise { - try { - return await readActiveClaudeKeychainCredentials(configDir) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return null - } - } - - protected async readActiveClaudeKeychainCredentialsBestEffort( - configDir?: string - ): Promise { - try { - return await readActiveClaudeKeychainCredentialsStrict(configDir) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return null - } - } - - protected async readActiveClaudeKeychainCredentialsForSnapshot( - configDir?: string - ): Promise { - try { - return { - status: 'captured', - credentialsJson: await readActiveClaudeKeychainCredentialsStrict(configDir) - } - } catch (error) { - console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) - return { status: 'failed' } - } - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts deleted file mode 100644 index 3a93f63d4dd6..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { existsSync } from 'node:fs' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { parseWslUncPath } from '../../../shared/wsl-paths' -import { toWindowsWslPath } from '../../wsl' -import { runWslProcess } from '../../wsl/wsl-runner' -import { - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from '../managed-auth-path' -import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from '../oauth-refresh' -import { - readManagedClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../keychain' -import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity' -import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' - -const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout' - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCredentialIdentity { - protected async readManagedCredentials(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - return null - } - if (process.platform === 'darwin') { - const credentials = await readManagedClaudeKeychainCredentials(account.id) - return credentials === null ? null : stripSharedClaudeCredentialFields(credentials) - } - const credentials = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') - return credentials === null || account.managedAuthRuntime === 'wsl' - ? credentials - : stripSharedClaudeCredentialFields(credentials) - } - - protected async writeManagedCredentials( - account: ClaudeManagedAccount, - credentialsJson: string - ): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - if (account.managedAuthRuntime !== 'wsl') { - credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) - } - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) - return - } - writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson) - } - - /** - * Proactively refresh an account's OAuth token and persist the rotation to - * managed storage. Returns the refreshed credentials JSON, or null when no - * refresh happened (token valid, no refresh token, or network failure). - * - * Caller guarantees this account isn't the live/active one and runs inside the - * serialized mutation queue, so the single-use refresh token can't rotate concurrently. - */ - protected async refreshManagedAccountTokenIfNeeded( - account: ClaudeManagedAccount, - credentialsJson: string - ): Promise { - if (!isOauthTokenExpiring(credentialsJson)) { - return null - } - const refreshed = await refreshClaudeOauthCredentials(credentialsJson) - if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) { - return null - } - try { - await this.writeManagedCredentials(account, refreshed) - } catch (error) { - console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error) - return null - } - return refreshed - } - - protected async readManagedOauthAccount(account: ClaudeManagedAccount): Promise { - const managedAuthPath = await this.getOwnedManagedAuthPath(account) - if (!managedAuthPath) { - return null - } - try { - const contents = readClaudeManagedAuthFile(managedAuthPath, 'oauth-account.json') - return contents ? (JSON.parse(contents) as unknown) : null - } catch { - return null - } - } - - protected async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise { - const wslInfo = parseWslUncPath(account.managedAuthPath) - if (wslInfo) { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - return null - } - if (process.platform === 'win32') { - try { - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - if (owned.timedOut) { - throw new Error(OWNERSHIP_PROBE_TIMEOUT) - } - if (owned.code !== 0) { - return null - } - const canonicalLinuxPath = owned.stdout.trim() - return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null - } catch (error) { - // Why rethrow a timeout: null means "not owned by Orca", and the - // caller persists that -- clearing the user's account selection. A - // slow distro must not decide ownership. Swallowing it here is what - // made the previous guard dead code. - if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) { - throw error - } - return null - } - } - return existsSync(account.managedAuthPath) ? account.managedAuthPath : null - } - return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts deleted file mode 100644 index dabcd9d472f3..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ /dev/null @@ -1,119 +0,0 @@ -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, - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' -import { ClaudeRuntimeAuthSnapshotRestore } from './runtime-auth-snapshot-restore' -import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' - -export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapshotRestore { - protected getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { - const settings = this.store.getSettings() - const paths = this.pathResolver.getRuntimePaths() - const normalizedTarget = this.resolveWslDefaultTarget( - target ?? this.getDefaultAccountSelectionTarget(settings) - ) - const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) - const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) - if ( - normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && - activeAccount?.managedAuthRuntime === 'wsl' && - activeAccount.wslLinuxAuthPath - ) { - return { - configDir: activeAccount.managedAuthPath, - runtime: 'wsl', - wslDistro: activeAccount.wslDistro ?? null, - wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` - } - } - if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { - const distro = - normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() - const wslHome = distro ? getWslHome(distro) : null - const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null - if (distro && wslHome && wslHomeInfo) { - const windowsConfigDir = join(wslHome, '.claude') - const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` - return { - configDir: windowsConfigDir, - runtime: 'wsl', - wslDistro: distro, - wslLinuxConfigDir: linuxConfigDir, - envPatch: {}, - stripAuthEnv: true, - provenance: `wsl:${distro}:system` - } - } - return { - configDir: paths.configDir, - runtime: 'wsl', - wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` - } - } - return { - configDir: paths.configDir, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: paths.envPatch, - stripAuthEnv: shouldStripClaudeAuthEnvForAccount( - settings.claudeManagedAccounts, - activeAccountId - ), - managedRefreshDeferredByLivePty: Boolean( - activeAccountId && - activeAccount?.managedAuthRuntime !== 'wsl' && - this.managedRefreshDeferredByLivePtyAccountId === activeAccountId - ), - provenance: - activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' - ? `managed:${activeAccountId}` - : 'system' - } - } - - protected getActiveAccount( - accounts: ClaudeManagedAccount[], - activeAccountId: string | null - ): ClaudeManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - protected getDefaultAccountSelectionTarget( - settings = this.store.getSettings() - ): ClaudeAccountSelectionTarget { - // Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host. - const resolved = resolveLocalAccountRuntimeTarget(settings) - if (process.platform === 'win32' && resolved.runtime === 'wsl') { - return { runtime: 'wsl', wslDistro: resolved.wslDistro } - } - return { runtime: 'host' } - } - - protected resolveWslDefaultTarget( - target?: ClaudeAccountSelectionTarget - ): ClaudeAccountSelectionTarget { - if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target ?? { runtime: 'host' } - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts deleted file mode 100644 index 848ee093dfbc..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching' -import type { - ClaudeReadBackMatch, - ClaudeReadBackResult, - ClaudeRuntimeCredentialCandidate -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatching { - protected async readBackRefreshedTokens( - baselineCredentialsJson: string, - options: { updateLastWrittenCredentialsJson: boolean } - ): Promise { - try { - const candidates = - await this.readRuntimeCredentialCandidatesForReadBack(baselineCredentialsJson) - if (candidates.length === 0) { - return { status: 'unchanged' } - } - const changedCandidates = - this.lastWrittenCredentialsJson === null - ? candidates - : candidates.filter( - (candidate) => - !this.accountCredentialFieldsEqual( - candidate.credentialsJson, - this.lastWrittenCredentialsJson - ) - ) - if (changedCandidates.length === 0) { - return { status: 'unchanged' } - } - - const acceptedCandidates: { - credentialsJson: string - match: Extract - }[] = [] - const ambiguousCandidates: string[] = [] - let sawAmbiguousCandidate = false - let sawValidChangedCandidate = false - for (const runtimeContents of changedCandidates) { - if (!this.isValidCredentialsJsonObject(runtimeContents.credentialsJson)) { - continue - } - sawValidChangedCandidate = true - const match = await this.findManagedAccountForRuntimeCredentials( - runtimeContents.credentialsJson, - runtimeContents.runtimeOauthAccount - ) - if (match.kind === 'ambiguous') { - sawAmbiguousCandidate = true - ambiguousCandidates.push(runtimeContents.credentialsJson) - continue - } - if (match.kind !== 'matched') { - continue - } - // Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed. - if (this.lastWrittenCredentialsJson === null) { - const fresher = this.runtimeCredentialsAreFresher( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - const refreshTokenRotated = - this.compareRefreshTokens( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) === 'different' - const older = this.runtimeCredentialsAreOlder( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - if (!fresher && !(refreshTokenRotated && !older)) { - continue - } - } else if ( - this.runtimeCredentialsAreOlder( - runtimeContents.credentialsJson, - match.managedCredentialsJson - ) - ) { - continue - } - acceptedCandidates.push({ credentialsJson: runtimeContents.credentialsJson, match }) - } - if (acceptedCandidates.length === 0) { - if (sawAmbiguousCandidate) { - console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') - } - return { - status: 'rejected', - runtimeCredentialsChanged: true, - hasValidChangedRuntimeCredentials: sawValidChangedCandidate, - runtimeCredentialsJson: - ambiguousCandidates.length === 1 ? ambiguousCandidates[0] : undefined - } - } - const { credentialsJson: runtimeContents, match } = - this.chooseFreshestReadBackCandidate(acceptedCandidates) - - await this.writeManagedCredentials(match.account, runtimeContents) - if (options.updateLastWrittenCredentialsJson) { - const merged = await this.mergeLiveRuntimeSharedCredentials(runtimeContents) - this.writeRuntimeCredentials(merged) - if (process.platform === 'darwin') { - const paths = this.pathResolver.getRuntimePaths() - await writeActiveClaudeKeychainCredentialsForRuntime(merged, paths.configDir) - } - this.lastWrittenSharedCredentialsJson = merged - } - return { status: 'persisted' } - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle). - console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) - return { - status: 'rejected', - runtimeCredentialsChanged: - this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson), - // Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state. - hasValidChangedRuntimeCredentials: true - } - } - } - - protected async readRuntimeCredentialCandidatesForReadBack( - baselineCredentialsJson: string - ): Promise { - const paths = this.pathResolver.getRuntimePaths() - const fileCredentials = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const runtimeOauthAccount = this.readRuntimeOauthAccount() - const candidates: ClaudeRuntimeCredentialCandidate[] = [] - const pushCandidate = (credentialsJson: string | null): void => { - if ( - credentialsJson && - !candidates.some((candidate) => candidate.credentialsJson === credentialsJson) - ) { - candidates.push({ credentialsJson, runtimeOauthAccount }) - } - } - if (process.platform === 'darwin') { - const scopedKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort( - paths.configDir - ) - const legacyKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort() - if (this.lastWrittenCredentialsJson === null) { - pushCandidate(scopedKeychainCredentials) - pushCandidate(legacyKeychainCredentials) - pushCandidate(fileCredentials) - return candidates.filter( - (candidate) => - !this.accountCredentialFieldsEqual(candidate.credentialsJson, baselineCredentialsJson) - ) - } - pushCandidate(scopedKeychainCredentials) - pushCandidate(legacyKeychainCredentials) - } - pushCandidate(fileCredentials) - return candidates - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts deleted file mode 100644 index 40d4d45395a2..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - writeActiveClaudeKeychainCredentials -} from '../keychain' -import { - mergeSharedClaudeCredentialFields, - reconcileSharedClaudeCredentialFields -} from '../shared-credential-fields' -import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots' -import { - RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, - type ClaudeKeychainSnapshotValue -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots { - protected async mergeLiveRuntimeSharedCredentials(credentialsJson: string): Promise { - const paths = this.pathResolver.getRuntimePaths() - const candidates: string[] = [] - if (process.platform === 'darwin') { - // A failed read must stop the switch before any shared tokens are overwritten. - const scoped = await readActiveClaudeKeychainCredentialsStrict(paths.configDir) - const legacy = await readActiveClaudeKeychainCredentialsStrict() - if (scoped !== null) { - candidates.push(scoped) - } - if (legacy !== null) { - candidates.push(legacy) - } - } - const file = this.readRuntimeCredentialsFile() - if (file !== null) { - candidates.push(file) - } - const shared = reconcileSharedClaudeCredentialFields( - candidates, - this.lastWrittenSharedCredentialsJson - ) - return mergeSharedClaudeCredentialFields(credentialsJson, shared) - } - - protected readRuntimeCredentialsFile(): string | null { - const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null - } - - protected runtimeCredentialsBelongToAccount( - credentialsJson: string | null, - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): boolean { - if (!credentialsJson) { - return false - } - const identity = this.readIdentityFromCredentials(credentialsJson) - if ( - !identity?.email || - (account.email && this.normalizeField(account.email) !== identity.email) - ) { - return false - } - const oauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) - const selectedOrganizationUuid = this.normalizeField( - account.organizationUuid ?? oauthIdentity.organizationUuid - ) - if (selectedOrganizationUuid) { - return identity.organizationUuid === selectedOrganizationUuid - } - return !identity.organizationUuid - } - - protected clearLastWrittenRuntimeState(): void { - this.lastWrittenCredentialsJson = null - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - this.hasMaterializedRuntimeAuth = false - } - - protected hasUnchangedRuntimeCredentials( - previouslyWrittenCredentialsJson: string | null - ): boolean { - if (previouslyWrittenCredentialsJson === null) { - return false - } - const paths = this.pathResolver.getRuntimePaths() - const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - return this.accountCredentialFieldsEqual( - currentCredentialsJson, - previouslyWrittenCredentialsJson - ) - } - - protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean { - const paths = this.pathResolver.getRuntimePaths() - try { - const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - return ( - currentCredentialsJson !== null && - currentCredentialsJson !== (this.lastWrittenCredentialsJson ?? baselineCredentialsJson) - ) - } catch { - return false - } - } - - protected restoreRuntimeCredentials( - credentialsJson: string | null, - sharedCredentialsJson?: string - ): void { - const paths = this.pathResolver.getRuntimePaths() - const restored = mergeSharedClaudeCredentialFields( - credentialsJson ?? '{}', - sharedCredentialsJson ?? this.readRuntimeCredentialsFile() - ) - if (credentialsJson !== null || restored !== '{}') { - this.writeRuntimeCredentials(restored) - } else { - rmSync(paths.credentialsPath, { force: true }) - } - } - - protected restoreRuntimeOauthAccountIfOwned( - oauthAccount: unknown, - ownedOauthAccount: unknown, - options: { allowCredentialSurfaceOwnership: boolean } - ): void { - const currentOauthAccount = this.readRuntimeOauthAccount() - if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { - return - } - if (options.allowCredentialSurfaceOwnership) { - this.writeRuntimeOauthAccount(oauthAccount) - return - } - if ( - (ownedOauthAccount === null || ownedOauthAccount === undefined) && - !options.allowCredentialSurfaceOwnership - ) { - return - } - if (!this.jsonValuesEqual(currentOauthAccount, ownedOauthAccount)) { - return - } - this.writeRuntimeOauthAccount(oauthAccount) - } - - protected async hasUnchangedActiveClaudeKeychainCredentials( - snapshotValue: ClaudeKeychainSnapshotValue, - previouslyWrittenCredentialsJson: string | null, - configDir?: string - ): Promise { - if (snapshotValue.status === 'unknown') { - return false - } - const currentCredentialsJson = - await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) - return ( - previouslyWrittenCredentialsJson !== null && - this.accountCredentialFieldsEqual(currentCredentialsJson, previouslyWrittenCredentialsJson) - ) - } - - protected async restoreActiveClaudeKeychainCredentials( - credentialsJson: string | null, - configDir?: string, - sharedCredentialsJson?: string - ): Promise { - const live = - sharedCredentialsJson ?? (await readActiveClaudeKeychainCredentialsStrict(configDir)) - const restored = mergeSharedClaudeCredentialFields(credentialsJson ?? '{}', live) - await (credentialsJson !== null || restored !== '{}' - ? writeActiveClaudeKeychainCredentials(restored, configDir) - : deleteActiveClaudeKeychainCredentialsStrict(configDir)) - } - - protected async hasActiveKeychainCredentialsForAccount( - account: ClaudeManagedAccount, - managedOauthAccount: unknown, - configDir?: string - ): Promise { - const currentCredentialsJson = - await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) - return this.runtimeCredentialsBelongToAccount( - currentCredentialsJson, - account, - managedOauthAccount - ) - } - - protected readRuntimeOauthAccount(): unknown { - const configPath = this.pathResolver.getRuntimePaths().configPath - if (!existsSync(configPath)) { - return null - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown - const record = this.asRecord(parsed) - if (!record) { - return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR - } - return record.oauthAccount ?? null - } catch { - return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR - } - } - - protected runtimeOauthAccountMatches(managedOauthAccount: unknown): boolean { - if (managedOauthAccount === null || managedOauthAccount === undefined) { - return false - } - const currentOauthAccount = this.readRuntimeOauthAccount() - if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { - return false - } - return this.jsonValuesEqual(currentOauthAccount, managedOauthAccount) - } - - protected writeRuntimeOauthAccount(oauthAccount: unknown): boolean { - const configPath = this.pathResolver.getRuntimePaths().configPath - const existing = this.readJsonObject(configPath) - if (existing === null) { - return false - } - if (oauthAccount === null || oauthAccount === undefined) { - delete existing.oauthAccount - } else { - existing.oauthAccount = oauthAccount - } - this.writeJson(configPath, existing) - return true - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts deleted file mode 100644 index e184ec4e7517..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { ClaudeRuntimeAuthReadback } from './runtime-auth-readback' -import { - RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, - type ClaudeSystemDefaultSnapshot -} from './runtime-auth-types' - -export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback { - protected async captureSystemDefaultSnapshotForManagedEntry( - runtimeCredentialsJson: string | null, - managedCredentialsJson: string - ): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) { - await this.captureSystemDefaultSnapshot({ - force: true, - previousSnapshot: existingSnapshot, - managedCredentialsJson - }) - return - } - if (existingSnapshot) { - await this.captureSystemDefaultSnapshot({ - force: true, - credentialsJsonOverride: existingSnapshot.credentialsJson, - previousSnapshot: existingSnapshot, - managedCredentialsJson - }) - return - } - await this.captureSystemDefaultSnapshot({ force: false }) - } - - protected async captureSystemDefaultSnapshot(options: { - force: boolean - credentialsJsonOverride?: string | null - previousSnapshot?: ClaudeSystemDefaultSnapshot | null - managedCredentialsJson?: string - }): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const paths = this.pathResolver.getRuntimePaths() - const credentialsJson = - options.credentialsJsonOverride !== undefined - ? options.credentialsJsonOverride - : existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( - paths.configDir - ) - const scopedKeychainCredentials = - process.platform === 'darwin' - ? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir) - : ({ status: 'captured', credentialsJson: null } as const) - const legacyKeychainCredentialsJson = - process.platform === 'darwin' - ? await this.readActiveClaudeKeychainCredentialsForSnapshot() - : ({ status: 'captured', credentialsJson: null } as const) - if ( - scopedKeychainCredentials.status === 'failed' || - legacyKeychainCredentialsJson.status === 'failed' - ) { - throw new Error('Cannot capture current Claude Keychain credentials') - } - const scopedKeychainCredentialsJson = - scopedKeychainCredentials.status === 'captured' - ? this.snapshotKeychainCredentials( - scopedKeychainCredentials.credentialsJson, - options.previousSnapshot, - 'scoped', - options.managedCredentialsJson - ) - : undefined - const legacyKeychainSnapshotJson = - legacyKeychainCredentialsJson.status === 'captured' - ? this.snapshotKeychainCredentials( - legacyKeychainCredentialsJson.credentialsJson, - options.previousSnapshot, - 'legacy', - options.managedCredentialsJson - ) - : undefined - const configOauthAccount = this.readRuntimeOauthAccount() - const snapshot: ClaudeSystemDefaultSnapshot = { - credentialsJson, - configOauthAccount: - configOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR ? null : configOauthAccount, - keychainCredentialsJson, - scopedKeychainCredentialsJson, - legacyKeychainCredentialsJson: legacyKeychainSnapshotJson, - scopedKeychainCredentialsCaptured: scopedKeychainCredentials.status === 'captured', - legacyKeychainCredentialsCaptured: legacyKeychainCredentialsJson.status === 'captured', - capturedAt: Date.now() - } - this.writeJson(snapshotPath, snapshot) - } - - protected readSystemDefaultSnapshot(snapshotPath: string): ClaudeSystemDefaultSnapshot | null { - if (!existsSync(snapshotPath)) { - return null - } - try { - const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown - if (this.isSystemDefaultSnapshot(parsed)) { - return parsed - } - throw new Error('Invalid Claude system-default auth snapshot shape') - } catch (error) { - console.warn('[claude-runtime-auth] Ignoring invalid system-default auth snapshot:', error) - rmSync(snapshotPath, { force: true }) - return null - } - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts deleted file mode 100644 index bb2158d9464b..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture' -import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types' - -export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotCapture { - protected async restoreSystemDefaultSnapshot( - ownedCredentialsJson?: string | null, - ownedOauthAccount?: unknown - ): Promise { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const paths = this.pathResolver.getRuntimePaths() - const previouslyWrittenCredentialsJson = - this.lastWrittenCredentialsJson ?? ownedCredentialsJson ?? null - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - - const fileCredentialsOwned = this.hasUnchangedRuntimeCredentials( - previouslyWrittenCredentialsJson - ) - let hasCredentialSurfaceOwnership = fileCredentialsOwned - // Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry. - this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson - let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null - let legacySnapshot: ClaudeKeychainSnapshotValue | null = null - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') - legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') - scopedKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( - scopedSnapshot, - previouslyWrittenCredentialsJson, - paths.configDir - ) - legacyKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( - legacySnapshot, - previouslyWrittenCredentialsJson - ) - hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - } - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - snapshot?.configOauthAccount ?? null, - this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership), - { allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, - undefined, - sharedCredentialsJson - ) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned - ) - this.lastWrittenCredentialsJson = null - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - this.hasMaterializedRuntimeAuth = false - } - - protected getOwnedRuntimeOauthBaseline( - ownedOauthAccount: unknown, - hasCredentialSurfaceOwnership: boolean - ): unknown { - if (this.hasLastWrittenOauthAccount) { - return this.lastWrittenOauthAccount - } - // Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership. - if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) { - return ownedOauthAccount - } - return null - } - - private recordRestoredSharedCredentials( - credentialsJson: string | undefined, - fileRestored: boolean, - scopedRestored: boolean, - legacyRestored: boolean - ): void { - if ( - credentialsJson !== undefined && - fileRestored && - (process.platform !== 'darwin' || (scopedRestored && legacyRestored)) - ) { - this.lastWrittenSharedCredentialsJson = credentialsJson - } - } - - protected async clearRuntimeAuthForAccount( - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): Promise { - const paths = this.pathResolver.getRuntimePaths() - const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( - this.readRuntimeCredentialsFile(), - account, - managedOauthAccount - ) - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount, - paths.configDir - ) - legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount - ) - } - const hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - null, - this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), - { - allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership - } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(null, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - null, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials(null, undefined, sharedCredentialsJson) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedKeychainOwned, - legacyKeychainOwned - ) - } - - protected async restoreSystemDefaultSnapshotForMissingManagedCredentials( - account: ClaudeManagedAccount, - managedOauthAccount: unknown - ): Promise { - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - if (!snapshot) { - await this.clearRuntimeAuthForAccount(account, managedOauthAccount) - this.clearLastWrittenRuntimeState() - return - } - const paths = this.pathResolver.getRuntimePaths() - const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( - this.readRuntimeCredentialsFile(), - account, - managedOauthAccount - ) - let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null - let legacySnapshot: ClaudeKeychainSnapshotValue | null = null - let scopedKeychainOwned = false - let legacyKeychainOwned = false - if (process.platform === 'darwin') { - scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') - legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') - scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount, - paths.configDir - ) - legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( - account, - managedOauthAccount - ) - } - const hasCredentialSurfaceOwnership = - fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned - const sharedCredentialsJson = hasCredentialSurfaceOwnership - ? await this.mergeLiveRuntimeSharedCredentials('{}') - : undefined - this.restoreRuntimeOauthAccountIfOwned( - snapshot.configOauthAccount, - this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), - { - allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership - } - ) - if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson) - } - if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, - paths.configDir, - sharedCredentialsJson - ) - } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, - undefined, - sharedCredentialsJson - ) - } - } - this.recordRestoredSharedCredentials( - sharedCredentialsJson, - fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned - ) - this.clearLastWrittenRuntimeState() - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts deleted file mode 100644 index 6d6e5e47a489..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Store } from '../../persistence' -import { ClaudeRuntimePathResolver } from '../runtime-paths' - -export class ClaudeRuntimeAuthState { - protected readonly pathResolver = new ClaudeRuntimePathResolver() - protected mutationQueue: Promise = Promise.resolve() - protected lastSyncedAccountId: string | null = null - // Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default. - protected lastWrittenCredentialsJson: string | null = null - // Connector revocations require a baseline that reached every runtime store, not a partial file write. - protected lastWrittenSharedCredentialsJson: string | null = null - protected hasMaterializedRuntimeAuth = false - protected hasLastWrittenOauthAccount = false - protected lastWrittenOauthAccount: unknown = null - protected skipNextReadBackForAccountId: string | null = null - protected managedRefreshDeferredByLivePtyAccountId: string | null = null - - protected constructor(protected readonly store: Store) {} -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts deleted file mode 100644 index 23e9112d7ed7..000000000000 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - normalizeClaudeRuntimeSelection, - setSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' -import { hasLiveClaudePtys } from '../live-pty-gate' -import { isOauthTokenExpiring } from '../oauth-refresh' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' - -export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { - protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { - const settings = this.store.getSettings() - const effectiveTarget = this.resolveWslDefaultTarget(target) - const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) - const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) - const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) - const previousAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - this.lastSyncedAccountId - ) - this.managedRefreshDeferredByLivePtyAccountId = null - const previousManagedCredentialsJson = previousAccount - ? await this.readManagedCredentials(previousAccount) - : null - const previousManagedOauthAccount = previousAccount - ? await this.readManagedOauthAccount(previousAccount) - : null - if (previousAccount && previousAccount.id !== activeAccount?.id) { - if (previousManagedCredentialsJson) { - const outgoingReadBackResult = await this.readBackRefreshedTokens( - previousManagedCredentialsJson, - { - updateLastWrittenCredentialsJson: true - } - ) - if ( - outgoingReadBackResult.status === 'rejected' && - outgoingReadBackResult.runtimeCredentialsChanged && - hasLiveClaudePtys() - ) { - if ( - outgoingReadBackResult.runtimeCredentialsJson && - this.liveRuntimeCredentialsCanUpdateActiveAccount( - outgoingReadBackResult.runtimeCredentialsJson, - previousAccount, - previousManagedCredentialsJson, - previousManagedOauthAccount - ) - ) { - // Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials. - await this.writeManagedCredentials( - previousAccount, - outgoingReadBackResult.runtimeCredentialsJson - ) - } else { - // Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account. - console.warn( - '[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts' - ) - } - } - } - } - if (!activeAccount) { - if (activeAccountId) { - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - } - if (normalizedTarget.runtime === 'wsl') { - return - } - if (this.lastSyncedAccountId !== null) { - await (previousAccount - ? this.restoreSystemDefaultSnapshot( - previousManagedCredentialsJson, - previousManagedOauthAccount - ) - : this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)) - this.lastSyncedAccountId = null - } - return - } - - if (activeAccount.managedAuthRuntime === 'wsl') { - if (!(await this.getOwnedManagedAuthPath(activeAccount))) { - console.warn( - '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' - ) - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - return - } - const credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - console.warn( - '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' - ) - const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(settings), - null, - normalizedTarget - ) - this.store.updateSettings({ - activeClaudeManagedAccountId: - normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - return - } - // Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores. - this.clearLastWrittenRuntimeState() - return - } - - if (!(await this.getOwnedManagedAuthPath(activeAccount))) { - console.warn( - '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' - ) - if (this.lastSyncedAccountId !== null) { - if ( - previousAccount && - (previousAccount.id !== activeAccount.id || - this.hasMaterializedRuntimeAuth || - this.runtimeOauthAccountMatches(await this.readManagedOauthAccount(previousAccount))) - ) { - await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( - previousAccount, - previousManagedOauthAccount - ) - } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { - await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) - } - } - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - this.lastSyncedAccountId = null - return - } - - let credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - console.warn( - '[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default' - ) - if (this.lastSyncedAccountId !== null) { - if ( - previousAccount && - (previousAccount.id !== activeAccount.id || - this.hasMaterializedRuntimeAuth || - this.runtimeOauthAccountMatches(previousManagedOauthAccount)) - ) { - await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( - previousAccount, - previousManagedOauthAccount - ) - } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { - await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) - } - } - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - this.lastSyncedAccountId = null - return - } - - if (this.lastSyncedAccountId === null) { - const paths = this.pathResolver.getRuntimePaths() - const runtimeCredentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - await this.captureSystemDefaultSnapshotForManagedEntry( - runtimeCredentialsJson, - credentialsJson - ) - } - - // Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting. - if (this.lastSyncedAccountId === activeAccount.id) { - if (this.skipNextReadBackForAccountId === activeAccount.id) { - this.skipNextReadBackForAccountId = null - } else { - const readBackResult = await this.readBackRefreshedTokens(credentialsJson, { - updateLastWrittenCredentialsJson: true - }) - if (readBackResult.status === 'persisted') { - const updatedCredentialsJson = await this.readManagedCredentials(activeAccount) - if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) { - credentialsJson = updatedCredentialsJson - } - } else if ( - readBackResult.status === 'rejected' && - readBackResult.runtimeCredentialsChanged && - // Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session. - readBackResult.hasValidChangedRuntimeCredentials && - hasLiveClaudePtys() - ) { - if ( - readBackResult.runtimeCredentialsJson && - this.liveRuntimeCredentialsCanUpdateActiveAccount( - readBackResult.runtimeCredentialsJson, - activeAccount, - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - ) { - // Why: this Claude launched under the active managed account, but persistence still needs positive account proof. - await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson) - credentialsJson = readBackResult.runtimeCredentialsJson - } else { - // Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out. - console.warn( - '[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running' - ) - this.lastSyncedAccountId = activeAccount.id - this.hasMaterializedRuntimeAuth = true - return - } - } - } - } - - if (this.lastSyncedAccountId !== activeAccount.id) { - this.skipNextReadBackForAccountId = null - } - - // Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead. - const liveClaudePtys = hasLiveClaudePtys() - if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) { - this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id - } - if (!liveClaudePtys) { - const refreshed = await this.refreshManagedAccountTokenIfNeeded( - activeAccount, - credentialsJson - ) - if (refreshed) { - credentialsJson = refreshed - } - } - - const paths = this.pathResolver.getRuntimePaths() - const runtimeCredentialsJson = await this.mergeLiveRuntimeSharedCredentials(credentialsJson) - this.writeRuntimeCredentials(runtimeCredentialsJson) - if (process.platform === 'darwin') { - // Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both. - try { - await writeActiveClaudeKeychainCredentialsForRuntime( - runtimeCredentialsJson, - paths.configDir - ) - } catch (error) { - await this.restoreSystemDefaultSnapshot( - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - throw error - } - } - this.lastWrittenSharedCredentialsJson = runtimeCredentialsJson - const managedOauthAccount = await this.readManagedOauthAccount(activeAccount) - if (this.writeRuntimeOauthAccount(managedOauthAccount)) { - this.lastWrittenOauthAccount = managedOauthAccount - this.hasLastWrittenOauthAccount = true - } else { - this.lastWrittenOauthAccount = null - this.hasLastWrittenOauthAccount = false - } - this.lastSyncedAccountId = activeAccount.id - this.hasMaterializedRuntimeAuth = true - } -} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 77f0394e5719..73c2a04a50f5 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -1,4 +1,3 @@ -import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import type { ClaudeEnvPatch } from '../environment' export type ClaudeRuntimeAuthPreparation = { @@ -7,49 +6,7 @@ export type ClaudeRuntimeAuthPreparation = { wslDistro?: string | null wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch - stripAuthEnv: boolean - managedRefreshDeferredByLivePty?: boolean provenance: string + /** Usage only: why the selected account cannot be read; usage polling must not throw for it. */ + usageError?: string } - -export type ClaudeSystemDefaultSnapshot = { - credentialsJson: string | null - configOauthAccount: unknown - keychainCredentialsJson: string | null - scopedKeychainCredentialsJson?: string | null - legacyKeychainCredentialsJson?: string | null - scopedKeychainCredentialsCaptured?: boolean - legacyKeychainCredentialsCaptured?: boolean - capturedAt: number -} - -export type ClaudeAuthIdentity = { - accountUuid: string | null - email: string | null - organizationUuid: string | null -} - -export type ClaudeReadBackResult = - | { status: 'unchanged' | 'persisted' } - | { - status: 'rejected' - runtimeCredentialsChanged: boolean - hasValidChangedRuntimeCredentials: boolean - runtimeCredentialsJson?: string - } -export type ClaudeReadBackMatch = - | { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string } - | { kind: 'none' | 'ambiguous' } -export type ClaudeKeychainReadResult = - | { status: 'captured'; credentialsJson: string | null } - | { status: 'failed' } -export type ClaudeKeychainSnapshotValue = - | { status: 'captured'; credentialsJson: string | null } - | { status: 'unknown' } -export type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' -export type ClaudeRuntimeCredentialCandidate = { - credentialsJson: string - runtimeOauthAccount: unknown -} - -export const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') diff --git a/src/main/claude-accounts/runtime-paths.test.ts b/src/main/claude-accounts/runtime-paths.test.ts index cd1607e5a319..aa2f168b6eac 100644 --- a/src/main/claude-accounts/runtime-paths.test.ts +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -79,12 +79,6 @@ describe('ClaudeRuntimePathResolver', () => { expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited }) }) - it('resolves credentials next to the config directory', () => { - const paths = new ClaudeRuntimePathResolver().getRuntimePaths() - - expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json')) - }) - it('falls back to the home config file when no colocated config exists', () => { const paths = new ClaudeRuntimePathResolver().getRuntimePaths() diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts index d9ff5701f262..c164df91d9c6 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -5,7 +5,6 @@ import type { ClaudeEnvPatch } from './environment' export type ClaudeRuntimePaths = { configDir: string - credentialsPath: string configPath: string envPatch: ClaudeEnvPatch } @@ -18,7 +17,6 @@ export class ClaudeRuntimePathResolver { return { configDir, - credentialsPath: join(configDir, '.credentials.json'), configPath: this.resolveConfigPath(configDir, inheritedConfigDir), envPatch: inheritedConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {} } diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts index eda5b271cb5d..fe2d1373ac46 100644 --- a/src/main/claude-accounts/runtime-selection.ts +++ b/src/main/claude-accounts/runtime-selection.ts @@ -145,3 +145,10 @@ function normalizeWslDistro(wslDistro: string | null | undefined): string | null const trimmed = wslDistro?.trim() return trimmed ? trimmed : null } + +export function findClaudeAccount( + settings: Pick, + accountId: string +): ClaudeManagedAccount | undefined { + return settings.claudeManagedAccounts.find((account) => account.id === accountId) +} diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index d1c79eaf17d5..9d64480cfd4a 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,73 +1,39 @@ -import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { Store } from '../persistence' -import type { RateLimitService } from '../rate-limits/service' +import type { + ClaudeAccountSignIn, + ClaudeRateLimitAccountsState, + ClaudeSignInRequest +} from '../../shared/managed-account-types' import { ClaudeAccountRegistration } from './claude-account-registration' -import { ClaudeAccountSelection } from './claude-account-selection' import { - captureClaudeAuthFromConfigDir, - captureClaudeAuthFromExistingConfigDir, - readCapturedClaudeCredentials, - type CapturedClaudeAuth -} from './claude-auth-capture' -import { - runClaudeCommandProcess, - type ClaudeCommandConfig, - type ClaudeCommandOptions -} from './claude-command-process' + ClaudeAccountSelection, + type ClaudeAccountRuntime, + type ClaudeAccountStore, + type ClaudeAccountUsage +} from './claude-account-selection' +import { runClaudeCommandProcess } from './claude-command-process' import { runClaudeLoginSession } from './claude-login-session' -import { - ClaudeManagedAuthStorage, - type ClaudeManagedAuthLocation -} from './claude-managed-auth-storage' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import type { ClaudeAccountSelectionTarget } from './runtime-selection' -export type ClaudeAccountAddTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { - previousLegacyCredentialsSha256?: string | null -} - export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null - private readonly storage = new ClaudeManagedAuthStorage() private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration constructor( - store: Store, - rateLimits: RateLimitService, - private readonly runtimeAuth: ClaudeRuntimeAuthService + store: ClaudeAccountStore, + rateLimits: ClaudeAccountUsage, + private readonly runtimeAuth: ClaudeAccountRuntime & + Pick, + private readonly runLoginCommand = runClaudeCommandProcess ) { - this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => - this.safeRemoveManagedAuth(accountId, path) - ) + this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth) this.registration = new ClaudeAccountRegistration({ store, rateLimits, runtimeAuth, - selection: this.selection, - createManagedAuth: (accountId, target) => this.storage.create(accountId, target), - assertManagedAuth: (path, accountId) => this.storage.assertOwned(path, accountId), - removeManagedAuth: (accountId, path) => this.safeRemoveManagedAuth(accountId, path), - writeManagedAuth: (accountId, path, captured) => - this.writeManagedAuth(accountId, path, captured), - writeCredentials: (accountId, path, value) => - this.storage.writeCredentials(accountId, path, value), - writeOauth: (accountId, path, value) => - this.storage.writeOauthAccount(accountId, path, value), - readSnapshot: (accountId, path) => this.storage.readSnapshot(accountId, path), - restoreCredentials: (accountId, path, snapshot) => - this.storage.restoreCredentials(accountId, path, snapshot), - restoreOauth: (accountId, path, snapshot) => - this.storage.restoreOauth(accountId, path, snapshot), - login: (location) => this.runClaudeLoginAndCapture(location), - captureExisting: (configDir, previousDigest) => - this.captureFromExistingConfigDir(configDir, previousDigest) + selection: this.selection }) } @@ -75,34 +41,45 @@ export class ClaudeAccountService { return this.selection.list() } - async addAccount(target?: ClaudeAccountAddTarget): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => this.registration.add(target)) + /** A hidden `claude auth login` opens the browser and writes into the new account's folder. */ + addAccount(target: ClaudeAccountSelectionTarget = {}): Promise { + return this.signInHidden({ runtime: target.runtime, wslDistro: target.wslDistro }) + } + + reauthenticateAccount(accountId: string): Promise { + return this.signInHidden({ accountId }) } - async addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions + cancelPendingLogin(): boolean { + return this.cancelPendingClaudeLogin?.() ?? false + } + + beginSignIn(request: ClaudeSignInRequest = {}): Promise { + return this.serializeMutation(() => this.registration.begin(request)) + } + + finishSignIn( + signIn: Omit ): Promise { - return this.serializeMutation(() => this.registration.addFromConfigDir(configDir, options)) + return this.serializeMutation(() => this.registration.finish(signIn)) } - async reauthenticateAccount(accountId: string): Promise { - this.supersedePendingLogin() - return this.serializeMutation(() => this.registration.reauthenticate(accountId)) + /** Deletes the folder of a sign-in that never became an account; a saved account's is kept. */ + cancelSignIn(signIn: Omit): Promise { + return this.serializeMutation(() => this.registration.cancel(signIn)) } - async removeAccount(accountId: string): Promise { + removeAccount(accountId: string): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.remove(accountId)) } - async selectAccount(accountId: string | null): Promise { + selectAccount(accountId: string | null): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.select(accountId)) } - async selectAccountForTarget( + selectAccountForTarget( accountId: string | null, target?: ClaudeAccountSelectionTarget ): Promise { @@ -110,13 +87,26 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.selection.select(accountId, target)) } - cancelPendingLogin(): boolean { - return this.cancelPendingClaudeLogin?.() ?? false + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { + return this.runtimeAuth.getRuntimeConfigDir(target) } - // Why before the queue, not inside it: the abandoned login owns the queue slot - // every later account action waits for. Called from the four the user drives, - // never from serializeMutation, which background work also uses. + private signInHidden(request: ClaudeSignInRequest): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => + this.registration.signIn(request, (folder) => + runClaudeLoginSession(folder, { + runCommand: this.runLoginCommand, + setCancel: (cancel) => { + this.cancelPendingClaudeLogin = cancel + } + }) + ) + ) + } + + // Why before the queue, not inside it: the abandoned login owns the queue slot every later + // account action waits for. Only user-driven actions supersede it. private supersedePendingLogin(): void { if (this.cancelPendingLogin()) { console.info( @@ -125,91 +115,9 @@ export class ClaudeAccountService { } } - getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.runtimeAuth.getRuntimeConfigDir(target) - } - private serializeMutation(operation: () => Promise): Promise { const next = this.mutationQueue.then(operation, operation) this.mutationQueue = next.catch(() => {}) return next } - - private runClaudeLoginAndCapture( - location: ClaudeManagedAuthLocation = { - managedAuthPath: '', - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - ): Promise { - return runClaudeLoginSession(location, { - runCommand: (args, config, timeoutMs, options) => - this.runClaudeCommand(args, config, timeoutMs, options), - capture: (configDir, status, previousLegacy) => - this.captureAuthFromConfigDir(configDir, status, previousLegacy), - setCancel: (cancel) => { - this.cancelPendingClaudeLogin = cancel - } - }) - } - - private runClaudeCommand( - args: string[], - configDir: ClaudeCommandConfig, - timeoutMs: number, - options?: ClaudeCommandOptions - ): Promise { - return runClaudeCommandProcess(args, configDir, timeoutMs, options) - } - - private captureFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return captureClaudeAuthFromExistingConfigDir( - configDir, - previousLegacyCredentialsSha256, - (args, config, timeoutMs, options) => this.runClaudeCommand(args, config, timeoutMs, options) - ) - } - - private captureAuthFromConfigDir( - configDir: string, - statusOutput: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return captureClaudeAuthFromConfigDir( - configDir, - statusOutput, - previousLegacyKeychain, - previousLegacyCredentialsSha256, - (path, previous, digest) => this.readCapturedCredentials(path, previous, digest) - ) - } - - private readCapturedCredentials( - configDir: string, - previousLegacyKeychain: string | null, - previousLegacyCredentialsSha256?: string | null - ): Promise { - return readCapturedClaudeCredentials( - configDir, - previousLegacyKeychain, - previousLegacyCredentialsSha256 - ) - } - - private writeManagedAuth( - accountId: string, - managedAuthPath: string, - captured: CapturedClaudeAuth - ): Promise { - return this.storage.writeAuth(accountId, managedAuthPath, captured) - } - - private safeRemoveManagedAuth(accountId: string, path: string): Promise { - return this.storage.remove(accountId, path) - } } diff --git a/src/main/claude-accounts/shared-credential-fields.test.ts b/src/main/claude-accounts/shared-credential-fields.test.ts deleted file mode 100644 index 097e1725d128..000000000000 --- a/src/main/claude-accounts/shared-credential-fields.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - mergeSharedClaudeCredentialFields, - SHARED_CLAUDE_CREDENTIAL_KEYS -} from './shared-credential-fields' - -describe('mergeSharedClaudeCredentialFields', () => { - it('merges the live credential shared fields into the target credential', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' }, - pluginSecrets: { s: 1 } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.claudeAiOauth).toEqual({ accessToken: 'target-token' }) - expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) - expect(result.pluginSecrets).toEqual({ s: 1 }) - }) - - it('is absence-authoritative: a shared key missing on live is not carried from the target', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { stale: 'rotated-out' } - }) - const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.mcpOAuth).toBeUndefined() - }) - - it('leaves account-scoped sibling keys on the target untouched', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - trustedDeviceToken: 'target-device-token' - }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.trustedDeviceToken).toBe('target-device-token') - expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) - }) - - it('returns the target unchanged when there is no live credential to merge from', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - expect(mergeSharedClaudeCredentialFields(target, null)).toBe(target) - }) - - it('returns the target unchanged when the live value is not a JSON object (e.g. a raw managed API key)', () => { - const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) - expect(mergeSharedClaudeCredentialFields(target, 'sk-ant-api-not-json')).toBe(target) - }) - - it('returns the target unchanged when the target itself is not a Claude OAuth credential object (managed API key)', () => { - const target = 'sk-ant-api-a-raw-managed-key' - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when the target JSON is malformed', () => { - const target = '{not valid json' - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when claudeAiOauth is null rather than an object', () => { - const target = JSON.stringify({ claudeAiOauth: null }) - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target unchanged when claudeAiOauth is a primitive rather than an object', () => { - const target = JSON.stringify({ claudeAiOauth: 'not-an-object' }) - const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('returns the target byte-for-byte unchanged when neither side has any shared key (no-op merge)', () => { - // Why: a no-op reformat (e.g. dropped trailing newline) reads as an external refresh downstream. - const target = `${JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token', refreshToken: 'target-refresh' } - })}\n` - const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('covers every documented shared key, not just mcpOAuth', () => { - // Why: pins the actual key names, so removing one from the constant fails this test. - expect(SHARED_CLAUDE_CREDENTIAL_KEYS).toEqual([ - 'mcpOAuth', - 'mcpOAuthClientConfig', - 'mcpXaaIdp', - 'mcpXaaIdpConfig', - 'pluginSecrets' - ]) - const target = JSON.stringify({ claudeAiOauth: {} }) - const liveObj: Record = { claudeAiOauth: {} } - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - liveObj[key] = { present: true } - } - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, JSON.stringify(liveObj))) - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - expect(result[key]).toEqual({ present: true }) - } - }) - - it('returns the target byte-for-byte unchanged when an existing shared key keeps its value (interleaved order)', () => { - // Why: rebuilding via key-order iteration used to move an existing shared key to the - // end even when its value did not change, causing a spurious formatting-only rewrite. - const target = `${JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { conn1: 'v1' }, - trustedDeviceToken: 'target-device-token' - })}\n` - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'v1' } - }) - expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) - }) - - it('still updates an interleaved shared key in place when its live value actually differs', () => { - const target = JSON.stringify({ - claudeAiOauth: { accessToken: 'target-token' }, - mcpOAuth: { conn1: 'stale' }, - trustedDeviceToken: 'target-device-token' - }) - const live = JSON.stringify({ - claudeAiOauth: { accessToken: 'live-token' }, - mcpOAuth: { conn1: 'fresh' } - }) - const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) - expect(result.mcpOAuth).toEqual({ conn1: 'fresh' }) - expect(result.trustedDeviceToken).toBe('target-device-token') - }) -}) diff --git a/src/main/claude-accounts/shared-credential-fields.ts b/src/main/claude-accounts/shared-credential-fields.ts deleted file mode 100644 index e0014f25c4b8..000000000000 --- a/src/main/claude-accounts/shared-credential-fields.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' - -export const SHARED_CLAUDE_CREDENTIAL_KEYS = [ - 'mcpOAuth', - 'mcpOAuthClientConfig', - 'mcpXaaIdp', - 'mcpXaaIdpConfig', - 'pluginSecrets' -] as const - -function parseCredentialObject(credentialsJson: string | null): Record | null { - if (!credentialsJson) { - return null - } - let parsed: unknown - try { - parsed = JSON.parse(credentialsJson) - } catch { - return null - } - return isCredentialObject(parsed) ? parsed : null -} - -function isCredentialObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function stripSharedClaudeCredentialFields(credentialsJson: string): string { - const credential = parseCredentialObject(credentialsJson) - if (!credential) { - return credentialsJson - } - let changed = false - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - if (Object.hasOwn(credential, key)) { - delete credential[key] - changed = true - } - } - return changed ? JSON.stringify(credential) : credentialsJson -} - -// Shared connector state follows the live runtime, including revocations, rather than frozen account snapshots. -export function mergeSharedClaudeCredentialFields( - targetCredentialsJson: string, - liveCredentialsJson: string | null -): string { - const target = parseCredentialObject(targetCredentialsJson) - const live = parseCredentialObject(liveCredentialsJson) - if ( - !target || - !live || - (Object.hasOwn(target, 'claudeAiOauth') && !isCredentialObject(target.claudeAiOauth)) - ) { - return targetCredentialsJson - } - - let changed = false - const merged: Record = { ...target } - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - const targetHasKey = Object.hasOwn(target, key) - if (Object.hasOwn(live, key)) { - if (!targetHasKey || JSON.stringify(live[key]) !== JSON.stringify(target[key])) { - merged[key] = live[key] - changed = true - } - } else if (targetHasKey) { - delete merged[key] - changed = true - } - } - return changed ? JSON.stringify(merged) : targetCredentialsJson -} - -type CredentialField = { present: boolean; value: unknown } - -function credentialField(record: Record, key: string): CredentialField { - const present = Object.hasOwn(record, key) - return { present, value: present ? record[key] : undefined } -} - -function resolveCredentialField( - candidates: CredentialField[], - baseline: CredentialField | null -): CredentialField { - const changes = candidates.filter((candidate) => - baseline === null ? candidate.present : !isDeepStrictEqual(candidate, baseline) - ) - const first = changes[0] - if (first && changes.some((candidate) => !isDeepStrictEqual(candidate, first))) { - throw new Error( - 'Cannot switch Claude accounts: live connector credentials conflict; existing authorizations were preserved' - ) - } - return first ?? baseline ?? { present: false, value: undefined } -} - -function resolveServerGrants( - sources: Record[], - baseline: Record | null, - key: string -): CredentialField | null { - const fields = sources.map((source) => credentialField(source, key)) - const previous = baseline === null ? null : credentialField(baseline, key) - if ( - fields.some((field) => field.present && !isCredentialObject(field.value)) || - (previous?.present && !isCredentialObject(previous.value)) - ) { - return null - } - const maps = fields.map((field) => (isCredentialObject(field.value) ? field.value : {})) - const previousMap = - previous === null ? null : isCredentialObject(previous.value) ? previous.value : {} - const names = new Set([ - ...maps.flatMap((map) => Object.keys(map)), - ...Object.keys(previousMap ?? {}) - ]) - const entries: [string, unknown][] = [] - for (const name of names) { - // Keep a server's access/refresh token pair atomic while combining independent server updates. - const grant = resolveCredentialField( - maps.map((map) => credentialField(map, name)), - previousMap === null ? null : credentialField(previousMap, name) - ) - if (grant.present) { - entries.push([name, grant.value]) - } - } - const present = - entries.length > 0 || - (fields.some((field) => field.present) && - !(baseline !== null && fields.some((field) => !field.present))) - return { present, value: present ? Object.fromEntries(entries) : undefined } -} - -export function reconcileSharedClaudeCredentialFields( - liveCredentials: string[], - lastWrittenCredentials: string | null -): string { - const sources = liveCredentials.map((credentials) => { - const parsed = parseCredentialObject(credentials) - if (!parsed) { - throw new Error('Cannot preserve malformed Claude runtime credentials') - } - return parsed - }) - if (sources.length === 0) { - return '{}' - } - const baseline = parseCredentialObject(lastWrittenCredentials) - const entries: [string, unknown][] = [] - for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { - const field = - (key === 'mcpOAuth' || key === 'mcpOAuthClientConfig' - ? resolveServerGrants(sources, baseline, key) - : null) ?? - resolveCredentialField( - sources.map((source) => credentialField(source, key)), - baseline === null ? null : credentialField(baseline, key) - ) - if (field.present) { - entries.push([key, field.value]) - } - } - return JSON.stringify(Object.fromEntries(entries)) -} diff --git a/src/main/claude-accounts/shared-credential-reconciliation.test.ts b/src/main/claude-accounts/shared-credential-reconciliation.test.ts deleted file mode 100644 index f772b5634c99..000000000000 --- a/src/main/claude-accounts/shared-credential-reconciliation.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { reconcileSharedClaudeCredentialFields } from './shared-credential-fields' - -const original = { accessToken: 'access', refreshToken: 'refresh' } -const rotated = { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } -const baseline = JSON.stringify({ - mcpOAuth: { figma: original }, - pluginSecrets: { plugin: 'secret' } -}) - -describe('live Claude connector reconciliation', () => { - it('combines independent server grants on startup without importing account identity', () => { - const sources = [ - JSON.stringify({ claudeAiOauth: { accessToken: 'account' }, mcpOAuth: { figma: original } }), - JSON.stringify({ - mcpOAuth: { notion: rotated }, - mcpOAuthClientConfig: { notion: { clientId: 'client' } } - }), - '{}' - ] - const expected = { - mcpOAuth: { figma: original, notion: rotated }, - mcpOAuthClientConfig: { notion: { clientId: 'client' } } - } - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, null))).toEqual(expected) - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), null))).toEqual( - expected - ) - }) - - it('combines independent rotations and additions against the previous write', () => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: rotated }, pluginSecrets: { plugin: 'secret' } }), - JSON.stringify({ - mcpOAuth: { figma: original, notion: original }, - pluginSecrets: { plugin: 'new-secret' } - }), - baseline - ] - const expected = { - mcpOAuth: { figma: rotated, notion: original }, - pluginSecrets: { plugin: 'new-secret' } - } - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual(expected) - expect( - JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), baseline)) - ).toEqual(expected) - }) - - it('keeps a revocation while another store adds an unrelated server', () => { - const sources = [ - JSON.stringify({ mcpOAuth: {}, pluginSecrets: { plugin: 'secret' } }), - JSON.stringify({ - mcpOAuth: { figma: original, notion: rotated }, - pluginSecrets: { plugin: 'secret' } - }), - baseline - ] - expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual({ - mcpOAuth: { notion: rotated }, - pluginSecrets: { plugin: 'secret' } - }) - }) - - it.each([null, baseline])( - 'rejects conflicting server token pairs with baseline %s', - (previous) => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: rotated } }), - JSON.stringify({ - mcpOAuth: { figma: { accessToken: 'other-access', refreshToken: 'other-refresh' } } - }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, previous)).toThrow( - 'live connector credentials conflict' - ) - expect(() => reconcileSharedClaudeCredentialFields(sources.toReversed(), previous)).toThrow( - 'live connector credentials conflict' - ) - } - ) - - it('does not recombine access and refresh tokens from different writes', () => { - const sources = [ - JSON.stringify({ mcpOAuth: { figma: { ...original, accessToken: 'new-access' } } }), - JSON.stringify({ mcpOAuth: { figma: { ...original, refreshToken: 'new-refresh' } } }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, baseline)).toThrow( - 'live connector credentials conflict' - ) - }) - - it('does not infer MCP freshness from Claude account-token expiry', () => { - const sources = [ - JSON.stringify({ claudeAiOauth: { expiresAt: 1 }, mcpOAuth: { figma: original } }), - JSON.stringify({ claudeAiOauth: { expiresAt: 9999999999999 }, mcpOAuth: { figma: rotated } }) - ] - expect(() => reconcileSharedClaudeCredentialFields(sources, null)).toThrow( - 'live connector credentials conflict' - ) - }) - - it('rejects conflicting non-server fields instead of combining their secrets', () => { - expect(() => - reconcileSharedClaudeCredentialFields( - [ - JSON.stringify({ pluginSecrets: { plugin: 'one' } }), - JSON.stringify({ pluginSecrets: { plugin: 'two' } }) - ], - null - ) - ).toThrow('live connector credentials conflict') - }) - - it('does not resurrect the last-written grants when every live store is missing', () => { - expect(reconcileSharedClaudeCredentialFields([], baseline)).toBe('{}') - }) - - it('treats an explicit null as an authoritative field removal after a known write', () => { - expect( - JSON.parse( - reconcileSharedClaudeCredentialFields( - [JSON.stringify({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }), baseline], - baseline - ) - ) - ).toEqual({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }) - }) -}) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index acbd988c6141..c34a827fcac3 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -150,7 +150,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, - resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolveAuthPolicy: () => ({ account: 'managed' }), resolvePermissionMode: () => permissionMode })({ identity: { sessionId: record.sessionId } as never }) } diff --git a/src/main/claude/claude-folder-trust-file.test.ts b/src/main/claude/claude-folder-trust-file.test.ts index 456e8938cb6f..4a3f2af6ba1a 100644 --- a/src/main/claude/claude-folder-trust-file.test.ts +++ b/src/main/claude/claude-folder-trust-file.test.ts @@ -274,7 +274,6 @@ describe('resolveLocalClaudeTrustConfig', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/u/.claude', envPatch: {}, - stripAuthEnv: true, provenance: 'wsl:Ubuntu:system' } diff --git a/src/main/claude/claude-hook-event-versions.ts b/src/main/claude/claude-hook-event-versions.ts index 2e980cd04775..2f12556444f1 100644 --- a/src/main/claude/claude-hook-event-versions.ts +++ b/src/main/claude/claude-hook-event-versions.ts @@ -90,3 +90,17 @@ export async function probeClaudeCliVersion( return null } } + +const RECENT_VERSION_MS = 5 * 60_000 +const recentVersions = new Map }>() + +/** Why remembered: account folders refresh before every launch, and a hook plan needs only a recent version. */ +export function probeRecentClaudeCliVersion(executablePath: string): Promise { + const recent = recentVersions.get(executablePath) + if (recent && Date.now() - recent.at <= RECENT_VERSION_MS) { + return recent.value + } + const value = probeClaudeCliVersion(executablePath) + recentVersions.set(executablePath, { at: Date.now(), value }) + return value +} diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index e6a337a70a65..065d7657455c 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -18,7 +18,7 @@ import { type ListingCall = Parameters -const AUTH_POLICY = { stripAuthEnv: false } as const +const AUTH_POLICY = { account: 'system' } as const const HOME = (path: string): { variable: string; path: string } => ({ variable: 'CLAUDE_CONFIG_DIR', @@ -198,7 +198,7 @@ describe('Claude catalog availability', () => { const runListing = vi.fn(async () => LISTED) const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - resolveAuthPolicy: () => ({ stripAuthEnv: managed }), + resolveAuthPolicy: () => ({ account: managed ? ('managed' as const) : ('system' as const) }), runListing }) const result = await probe(HOME('/homes/a')) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 58ef1050a93e..29b9b52433b6 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -23,7 +23,6 @@ export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { - authSwitchSettleTimeoutMs?: number /** Test seam; production runs the shared supervised listing. */ runListing?: typeof runAgentModelCatalogListing } diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts index 35db45340b0c..177d1c6a8855 100644 --- a/src/main/claude/claude-profile-hooks.test.ts +++ b/src/main/claude/claude-profile-hooks.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, symlinkSync, writeFileSync } from 'node:fs' @@ -111,12 +112,31 @@ describe('Claude hooks at an explicit profile', () => { await sync(f) expect(stop(f.profile)[0]).toEqual(hook('notify-v2')) expect(orcaCount(f.profile)).toBe(1) + // The default home is the master copy: an edit made in the profile lasts until the next refresh. setStop(f.profile, ['profile-only']) setStop(f.defaultDir, ['notify-v3']) await sync(f) - expect(stop(f.profile)[0]).toEqual(hook('profile-only')) + expect(stop(f.profile)[0]).toEqual(hook('notify-v3')) expect(orcaCount(f.profile)).toBe(1) }) + it.each(['with', 'without'])( + 'leaves the profile settings untouched by a refresh that changes nothing, %s Orca hooks in the default home', + async (variant) => { + const f = fixture() + writeFileSync(join(f.defaultDir, 'settings.json'), JSON.stringify({ model: 'opus' })) + if (variant === 'with') { + f.service.install(CURRENT) + } + await sync(f) + const file = join(f.profile, 'settings.json') + const before = { text: readFileSync(file, 'utf8'), stat: statSync(file) } + await sync(f) + await sync(f) + expect(readFileSync(file, 'utf8')).toBe(before.text) + expect(statSync(file).mtimeMs).toBe(before.stat.mtimeMs) + expect(statSync(file).ino).toBe(before.stat.ino) + } + ) it('carries a default-home statusline opt-out and an old-Claude retire to the profile', async () => { const f = fixture() f.service.install(CURRENT) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index e26e02263031..276328b009cd 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' -import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' import type { ProcessSpec } from '../../shared/child-process/process-spec' import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' import { @@ -761,85 +760,3 @@ describe('Claude stream-json connection', () => { await expect(connection.close()).resolves.toBe(true) }, 20_000) }) - -// A structured Claude child owns the account's credentials while it runs, exactly as -// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth -// refresh instead of rotating the single-use token out from under a live session, and -// structured sessions used to be invisible to it. -describe('the managed-auth live gate', () => { - it('holds while a structured child runs and releases when it ends', async () => { - // The gate is a process-wide singleton and a sibling test's release lands on its - // child's 'close' event, which can settle after that test's close() resolved. - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - const connection = await open(launchFor(scenario)) - - expect(hasLiveClaudePtys()).toBe(true) - - await connection.close() - - await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') - expect(hasLiveClaudePtys()).toBe(false) - }, 30_000) - - it('releases when the child dies on its own rather than through close()', async () => { - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - await open(launchFor(scenario)) - expect(hasLiveClaudePtys()).toBe(true) - - spawnedChildren.at(-1)?.kill('SIGKILL') - - await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') - expect(hasLiveClaudePtys()).toBe(false) - }, 30_000) - - // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never - // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of - // the process. Entering the gate only after the release handlers are attached makes - // that unreachable regardless of what the setup in between does. - it('leaks no gate entry when setup throws between spawn and handler attachment', async () => { - await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') - const scenario = scriptScenario([ - { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, - { wait: HOLD_OPEN } - ]) - let started: SpawnedProcess | null = null - - try { - await expect( - openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => { - const child = spawnProcess(spec) - started = child - const attach = child.stderr.on.bind(child.stderr) - // Measured attach order: the SDK binds stderr 'data' from inside query(), - // before the child is even assigned. The SECOND bind is this connection's own - // armTreeOnOutput — the first statement that runs after the child exists and - // before its 'exit'/'close' release handlers. Throwing on the first is - // vacuous: it escapes before any gate entry could have happened. - let dataAttaches = 0 - child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => { - if (event === 'data') { - dataAttaches += 1 - if (dataAttaches === 2) { - throw new Error('stderr listener attach failed') - } - } - return attach(event, listener) - }) as typeof child.stderr.on - return child - }) - ).rejects.toThrow('stderr listener attach failed') - - expect(hasLiveClaudePtys()).toBe(false) - } finally { - ;(started as SpawnedProcess | null)?.kill('SIGKILL') - } - }, 30_000) -}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 5f39b7acb48f..4d7aeeb237a7 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,12 +1,7 @@ -import { randomUUID } from 'node:crypto' import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' -import { - markClaudeStructuredChildExited, - markClaudeStructuredChildSpawned -} from '../claude-accounts/live-pty-gate' import { buildClaudeChildProcessEnv } from './claude-child-process-environment' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import { @@ -160,12 +155,6 @@ export async function openClaudeStreamJsonConnection( if (!child || !managed) { throw new Error('the claude agent SDK returned without spawning a child') } - // This child owns the account's credentials for as long as it runs, exactly as a - // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate - // the single-use token out from under it mid-turn. Entered below, once a release - // path exists. - const authGateKey = randomUUID() - const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) let exitStatus: ExitStatus | null = null let closing = false let terminalError: Error | null = null @@ -264,7 +253,6 @@ export async function openClaudeStreamJsonConnection( managed.onExit((exit) => { exitStatus = exit - releaseAuthGate() handleUnexpectedEnd() }) child.on('error', (error) => { @@ -274,7 +262,6 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) }) child.on('close', () => { - releaseAuthGate() handleUnexpectedEnd() }) child.stdin.on('error', (error) => { @@ -283,13 +270,6 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) } }) - // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so - // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the - // managed OAuth refresh for the life of the process. Entering only after 'exit' and - // 'close' are attached makes that unreachable — any later throw still leaves a - // listener that releases. Nothing between spawn and here can yield, so the child - // cannot end before the gate is entered. - markClaudeStructuredChildSpawned(authGateKey) const send: ClaudeStreamJsonConnection['send'] = (message, beforeDispatch) => { if ( diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 227fb117914c..c2bee84dfc9f 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,20 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } 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 { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import { - PROVIDER_SESSION_ID, - adapterFor, - fakeClaude, - identityFor -} from './claude-structured-session-test-support' -import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-auth' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -37,51 +24,19 @@ function record(): AgentSessionRecord { } function resolverFor(options: { - stripAuthEnv: boolean + account: 'managed' | 'system' overlay?: Record - authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), - authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, + resolveAuthPolicy: () => ({ account: options.account }), ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) }) } -/** - * An adapter driven by the REAL launch resolver, not the stub in the shared test - * support — the stub has no auth guard at all, so a teardown-window test built on it - * would pass whatever the guard did. - */ -function realResolverAdapter( - claude: ReturnType, - authSwitchSettleTimeoutMs: number -): ClaudeStructuredSessionAdapter { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only the chain head's handle from this partial record. - const resumable = { - ...record(), - providerHandleChain: [{ handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }] - } as unknown as AgentSessionRecord - return new ClaudeStructuredSessionAdapter({ - resolveLaunch: createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), - authSwitchSettleTimeoutMs - }), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {} - }) -} - function withAmbientAuth(value: string, run: () => Promise): Promise { const restore = process.env.ANTHROPIC_API_KEY process.env.ANTHROPIC_API_KEY = value @@ -95,157 +50,31 @@ function withAmbientAuth(value: string, run: () => Promise): Promise { } 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 + // Like terminals, a chat keeps the shell's Anthropic auth on every account: a proxy's key must + // travel with its ANTHROPIC_BASE_URL. + for (const account of ['managed', 'system'] as const) { + it(`passes an ambient Anthropic key through on a ${account} account`, async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ account })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + expect(launch.account).toBe(account) }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - reason: 'managedAccountEnvOverride' }) - }) - - 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.toMatchObject({ - message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - reason: 'managedAccountEnvOverride' - }) - }) - - 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 when an account switch never settles', async () => { - beginClaudeAuthSwitch() - - await expect( - resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - }) - - it('waits a settling account switch out rather than refusing a resolved launch', async () => { - beginClaudeAuthSwitch() - setTimeout(() => endClaudeAuthSwitch(), 20) - - const launch = await resolverFor({ - stripAuthEnv: true, - authSwitchSettleTimeoutMs: 5_000 - })({ identity: IDENTITY }) - - expect(launch.claudeConfigDir).toBe('/home/work/.claude') - }) - - 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.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' - }) - // Nothing was spawned, so the refusal must not have opened a connection. - expect(claude.connections).toHaveLength(0) - }) - - // The teardown between the entry guard and launch resolution closes the live child - // and proves its tree — seconds, not milliseconds. A switch that begins inside it - // has already cost the user their session, so refusing there produces exactly the - // outcome the entry guard advertises against: a dead chat and no replacement. - it('replaces the session when a switch begins inside the acquire teardown', async () => { - const claude = fakeClaude() - const adapter = realResolverAdapter(claude, 5_000) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const live = claude.connections[0]! - const closeWithSwitch = live.close - live.close = async () => { - beginClaudeAuthSwitch() - setTimeout(() => endClaudeAuthSwitch(), 20) - return closeWithSwitch() - } - - await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } }) - expect(live.closed).toBe(true) - // The replacement child exists: the user's chat came back. - expect(claude.connections).toHaveLength(2) - expect(claude.connections[1]!.closed).toBe(false) - await adapter.closeAll() - }) - - it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => { - const claude = fakeClaude() - const adapter = realResolverAdapter(claude, 20) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const live = claude.connections[0]! - const closeWithSwitch = live.close - live.close = async () => { - beginClaudeAuthSwitch() - return closeWithSwitch() - } - - await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - reason: 'accountSwitchInProgress' + it(`lets an explicit auth overlay through on a ${account} account`, async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ + account, + overlay: { + ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test') + }) }) - // No replacement child was opened, so nothing is left running unowned. - expect(claude.connections).toHaveLength(1) - await adapter.closeAll() - }) + } }) diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 5f787ca60604..8c9cc21cfc8d 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -42,22 +42,16 @@ export async function resolveClaudeChildEnvSources( // 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 when a managed account owns the credential; a system-auth -// user's own key is their sign-in and must reach the child. +// derives PATH from what it is handed. The shell's Anthropic auth reaches the child on +// every account, as it does in a terminal. export function claudeChildEnv( sources: ClaudeChildEnvSources, - stripAuthEnv: boolean, decorateEnv: (env: Record) => Record = (env) => env ): Record { return withCliRuntimeOnPath( sources.command, decorateEnv({ - ...applyClaudeEnvPatch( - withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), - {}, - { stripAuthEnv, platform: process.platform } - ), + ...withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), ...sources.overlay }), { platform: process.platform } @@ -69,7 +63,7 @@ export function claudeChildEnv( * a version needs none of. Everything else is what this same binary receives at launch anyway. */ export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { return applyClaudeEnvPatch( - claudeChildEnv(sources, true), + claudeChildEnv(sources), {}, { stripAuthEnv: true, diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 73b871ae2c9c..d6162b77f16c 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -137,6 +137,8 @@ function restingRecord(options: Record): AgentSessionRecord { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, location: { wslDistro: null }, + // Why released: a chat at rest has no live claim, so its next start reads the current account. + lease: { claimStatus: 'released' }, options } as unknown as AgentSessionRecord } diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts index 031559a44387..eda02246f191 100644 --- a/src/main/claude/claude-structured-launch-account-routing.test.ts +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -4,8 +4,8 @@ import { join, resolve as resolvePath } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from '../../shared/claude-profile-routing' import { - CLAUDE_PROFILE_MISSING_MESSAGE, ClaudeProfileRouter, type ClaudeProfileRouterSettings } from '../claude-accounts/claude-profile-router' @@ -32,7 +32,10 @@ it('launches each acquisition under the current selection, not the account it wa getSettings: () => settings, dataRoot: root, userHome: join(root, 'personal'), - env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') }, + runSetup: async () => { + throw new Error('setup refused') + } }) installClaudeProfileRouter(router) const record = { @@ -46,7 +49,7 @@ it('launches each acquisition under the current selection, not the account it wa resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), - resolveAuthPolicy: () => ({ stripAuthEnv: false }) + resolveAuthPolicy: () => ({ account: 'system' }) }) const identity = { sessionId: record.sessionId, @@ -56,7 +59,12 @@ it('launches each acquisition under the current selection, not the account it wa providerHandle: claudeProviderHandle('unused', null) } - await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + // A missing folder is set up first; typed when that fails, so the chat names the situation. + await expect(resolve({ identity })).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'claudeAccountSetupFailed', + message: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + }) const home = join(root, 'claude-profiles', 'a', 'home') mkdirSync(home, { recursive: true }) @@ -107,7 +115,7 @@ function routedResumeFixture() { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), hasTranscript: async ({ claudeConfigDir }) => transcriptHomes.has(claudeConfigDir) }) const identity = { diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts index 0df1b2f599c1..14afad9b5c43 100644 --- a/src/main/claude/claude-structured-launch-home.ts +++ b/src/main/claude/claude-structured-launch-home.ts @@ -32,7 +32,7 @@ export async function resolveClaudeStructuredLaunchHome( } /** Whether Claude wrote a transcript for this id under the given config folder. */ -export async function claudeTranscriptExists(input: { +async function claudeTranscriptExists(input: { providerSessionId: string claudeConfigDir: string }): Promise { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 5ada28257e16..963df6ce7699 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -5,9 +5,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { CLAUDE_DEFAULT_SETTING_SOURCES, CLAUDE_SESSION_STATE_EVENTS_ENV, @@ -18,7 +15,6 @@ import { } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' import { CLAUDE_THINKING_DISPLAY_FLAG, type ClaudeCliFlag } from './claude-cli-flag-support' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' @@ -60,7 +56,7 @@ function makeExecutable(path: string): void { function resolverFor( value: AgentSessionRecord | null, resolveEnv?: () => Record, - stripAuthEnv = false, + account: 'managed' | 'system' = 'system', // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, hasTranscript: () => Promise = async () => true, @@ -71,7 +67,7 @@ function resolverFor( store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv }), + resolveAuthPolicy: () => ({ account }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), @@ -80,33 +76,6 @@ function resolverFor( }) } -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the - * prune drops the WSL account out of the host slot and persists that. */ -const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - const RESUMABLE = record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ @@ -167,7 +136,7 @@ describe('claude structured launch resolution', () => { const launch = await resolverFor( value, undefined, - false, + 'system', undefined, hasTranscript )({ identity: identityAt('old-leaf') }) @@ -184,7 +153,7 @@ describe('claude structured launch resolution', () => { const retry = await resolverFor( value, undefined, - false, + 'system', undefined, hasTranscript )({ identity: IDENTITY }) @@ -192,7 +161,7 @@ describe('claude structured launch resolution', () => { const next = await resolverFor( { ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } }, undefined, - false, + 'system', undefined, async () => false )({ identity: IDENTITY }) @@ -291,7 +260,7 @@ describe('claude structured launch resolution', () => { ] as AgentSessionRecord['providerHandleChain'] }), undefined, - false, + 'system', { claude: '' }, hasTranscript )({ identity: identityAt(null) }) @@ -318,7 +287,9 @@ describe('claude structured launch resolution', () => { ['--dangerously-skip-permissions --model Opus'], ['--model Opus --dangerously-skip-permissions'] ])('starts a Yolo session in bypassPermissions for args %s', async (claude) => { - const launch = await resolverFor(record(), undefined, false, { claude })({ identity: IDENTITY }) + const launch = await resolverFor(record(), undefined, 'system', { claude })({ + identity: IDENTITY + }) expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null, @@ -332,7 +303,7 @@ describe('claude structured launch resolution', () => { // default for the key it did not write is the bypass flag — the posture the terminal has // always given these users. it('starts a session that never opened Agent settings in bypassPermissions', async () => { - const launch = await resolverFor(record(), undefined, false, {})({ identity: IDENTITY }) + const launch = await resolverFor(record(), undefined, 'system', {})({ identity: IDENTITY }) expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null, @@ -344,7 +315,7 @@ describe('claude structured launch resolution', () => { it.each([[''], ['--model Opus']])( 'leaves a Manual session prompting for args %s', async (claude) => { - const launch = await resolverFor(record(), undefined, false, { claude })({ + const launch = await resolverFor(record(), undefined, 'system', { claude })({ identity: IDENTITY }) @@ -382,7 +353,7 @@ describe('claude structured launch resolution', () => { const resolve = resolverFor( record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }), undefined, - false, + 'system', { claude: '' }, async () => true, () => args @@ -438,45 +409,11 @@ describe('claude structured launch resolution', () => { expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') }) - // 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, - CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, - ORCA_LAUNCH_RESOLUTION_MARKER: process.env.ORCA_LAUNCH_RESOLUTION_MARKER - } - process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' - process.env.ANTHROPIC_AUTH_TOKEN = 'tok-SHELL-LEAK' - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK' - process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited' - try { - const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY }) - - expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() - expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() - expect(launch.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() - // The inherited env is still the base — only auth is removed from it. - expect(launch.env?.ORCA_LAUNCH_RESOLUTION_MARKER).toBe('inherited') - expect(launch.env?.PATH ?? launch.env?.Path).toBeTruthy() - } finally { - for (const [key, value] of Object.entries(restore)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - } - }) - it("lets the agent read the host's chat attachment store, outside the workspace", async () => { const launch = await resolverFor( record(), undefined, - false, + 'system', { claude: '' }, async () => true, undefined, @@ -491,7 +428,7 @@ describe('claude structured launch resolution', () => { const launch = await resolverFor( record(), undefined, - false, + 'system', { claude: '' }, async () => true, () => ['--add-dir', '/extra'], @@ -510,7 +447,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveInheritedEnv: async () => ({ PATH: '/shell/bin', SHELL_ONLY_MARKER: 'from-shell' }) })({ identity: IDENTITY }) @@ -523,7 +460,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveInheritedEnv: async () => ({ PATH: '/shell/bin', CLAUDE_CONFIG_DIR: '/shell/claude', @@ -542,7 +479,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveEnv: () => ({ CLAUDE_CONFIG_DIR: '/accounts/selected/home' }), resolveInheritedEnv: async () => ({ PATH: '/shell/bin', CLAUDE_CONFIG_DIR: '/shell/claude' }) })({ identity: IDENTITY }) @@ -550,17 +487,24 @@ describe('claude structured launch resolution', () => { expect(launch.env?.CLAUDE_CONFIG_DIR).toBe('/accounts/selected/home') }) - it('still strips an inherited auth key under a managed account', async () => { + it("keeps the shell's auth key under a managed account, beside its proxy address", async () => { const launch = await createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - resolveInheritedEnv: async () => ({ PATH: '/shell/bin', ANTHROPIC_API_KEY: 'listed-key' }) + resolveAuthPolicy: () => ({ account: 'managed' }), + resolveInheritedEnv: async () => ({ + PATH: '/shell/bin', + ANTHROPIC_API_KEY: 'listed-key', + ANTHROPIC_BASE_URL: 'https://proxy.example.test' + }) })({ identity: IDENTITY }) - expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(launch.env).toMatchObject({ + ANTHROPIC_API_KEY: 'listed-key', + ANTHROPIC_BASE_URL: 'https://proxy.example.test' + }) }) it('lets an explicit Claude env overlay override ambient auth under system auth', async () => { @@ -594,7 +538,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => claudeCommand, - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveEnv: () => ({ PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/selected/home' @@ -626,66 +570,11 @@ describe('claude structured launch resolution', () => { }) ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) }) - - /** The account state can change while a session lives, and a reacquire after an unexpected child - * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the - * account state has become. */ - describe('managed-account gate on every acquisition', () => { - function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { - return createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude', - // Derived, not a literal: the gate and the policy must read the SAME account state, so a - // hardcoded value could assert a pairing production cannot produce. - resolveAuthPolicy: () => { - const settings = read() - if (!settings) { - throw new Error('the gate refuses before the auth policy is computed') - } - return claudeStructuredAuthPolicyForSettings(settings) - }, - readManagedAccountGate: read - }) - } - - it('refuses a reacquire once the account state becomes the refused shape', async () => { - let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED - const resolve = resolverWithGate(() => gate) - - // Created while supported: the launch resolves and would spawn. - await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ - providerSessionId: 'provider-current' - }) - - gate = WSL_ONLY_NORMALIZED - - // Reacquire after the account state changed: refused before anything spawns, naming the - // account shape a person can change. - const refused = resolve({ identity: identityAt('leaf-current') }) - await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) - await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) - }) - - it('fails closed when the account state cannot be read, naming no situation', async () => { - const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) - await expect(refused).rejects.toMatchObject({ reason: undefined }) - }) - - it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { - await expect( - resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) - ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) - }) - }) }) describe('readable Claude thinking', () => { const launchWith = ( cliFlags?: ClaudeStructuredLaunchResolverDeps['cliFlags'], - authSwitchSettleTimeoutMs?: number, command = '/usr/local/bin/claude', launchArgs: string[] = [] ) => @@ -694,11 +583,10 @@ describe('readable Claude thinking', () => { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveLaunchArgs: () => launchArgs, resolveCommand: () => command, - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), hasTranscript: async () => false, - ...(cliFlags ? { cliFlags } : {}), - ...(authSwitchSettleTimeoutMs === undefined ? {} : { authSwitchSettleTimeoutMs }) + ...(cliFlags ? { cliFlags } : {}) })({ identity: IDENTITY }) // Whether the CLI's directory holds a `node` decides if the runtime pairing puts that directory @@ -721,7 +609,7 @@ describe('readable Claude thinking', () => { if (sibling) { makeExecutable(join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')) } - const launch = await launchWith({ supports }, undefined, command) + const launch = await launchWith({ supports }, command) const asked = supports.mock.calls[0]?.[1] expect(asked).toMatchObject({ command, cwd: '/repos/workspace-1' }) const segments = (env: Record | undefined) => @@ -753,7 +641,6 @@ describe('readable Claude thinking', () => { const launch = await launchWith( { supports: async (flag) => flag === CLAUDE_THINKING_DISPLAY_FLAG }, undefined, - undefined, ['--effort', 'high', '--thinking-display', 'omitted'] ) expect(launch.options.extraArgs).toEqual({ @@ -762,21 +649,4 @@ describe('readable Claude thinking', () => { 'thinking-display': 'summarized' }) }) - - it('still rechecks an account switch that began while the probe ran', async () => { - try { - const launch = launchWith( - { - supports: async () => { - beginClaudeAuthSwitch() - return false - } - }, - 10 - ) - await expect(launch).rejects.toMatchObject({ reason: 'accountSwitchInProgress' }) - } finally { - endClaudeAuthSwitch() - } - }) }) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 1eba82721d24..eb2cf04ae97b 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -10,22 +10,7 @@ import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provi import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - hasClaudeAuthEnvConflict -} from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { - CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, - whenClaudeAuthSwitchSettles -} from '../claude-accounts/live-pty-gate' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - hasWslBoundClaudeAccount, - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' import { claudeChildEnv, claudeProbeEnv, @@ -130,19 +115,11 @@ export type ClaudeStructuredLaunchResolverDeps = { | undefined /** The env the child inherits before auth stripping; absent inherits Orca's own process env. */ resolveInheritedEnv?: () => Promise> - /** - * Required, and deliberately not defaulted. `stripAuthEnv` used to be a literal - * `true` here, so a missing dependency could not under-strip. Now it can, and the - * failure is silent — so every caller states the account's policy rather than - * inherit a guess. Build it with claudeStructuredAuthPolicyForSettings. - */ + /** Required, so every caller states which login a failed sign-in names rather than inherit a + * guess. Build it with claudeStructuredAuthPolicyForSettings. */ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ resolvePermissionMode?: () => Promise | PermissionMode - /** How long an in-flight account switch may hold a launch before it is refused. */ - authSwitchSettleTimeoutMs?: number - /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ - readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null /** The host's chat attachment store: files a client attached live there, outside the workspace, * and the agent reads them without asking. */ attachmentDirectory?: string @@ -171,48 +148,18 @@ export type ClaudeStructuredInvocation = { * drift there heals on the next refresh. */ export async function resolveClaudeStructuredInvocation( - deps: ClaudeEnvDeps & - Pick & { - authSwitchSettleTimeoutMs?: number - }, + deps: ClaudeEnvDeps & Pick, decorateEnv: (env: Record) => Record = (env) => env, /** Already resolved by a caller that needed them earlier; read again otherwise. */ resolvedSources?: ClaudeChildEnvSources ): Promise { const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps)) const auth = await deps.resolveAuthPolicy() - // A switch can begin while the policy and overlay resolve, exactly as it can - // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. - await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) - // 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(sources.overlay)) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { - reason: 'managedAccountEnvOverride' - }) - } + // Why never strip: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, on every account. return { command: sources.command, - env: claudeChildEnv(sources, auth.stripAuthEnv, decorateEnv), - account: auth.stripAuthEnv ? 'managed' : 'system' - } -} - -/** - * Wait a running account switch out, and refuse only if it never settles. - * - * Launch resolution is reached from `acquireClaudeSession` *after* the old child has - * been closed and proved, so a plain refusal here would leave the user with a dead - * chat and no replacement — the very harm the acquire-entry guard exists to prevent. - * The entry guard still refuses outright, because nothing has been torn down yet. - */ -export async function assertClaudeAuthSwitchSettled( - timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS -): Promise { - if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { - reason: 'accountSwitchInProgress' - }) + env: claudeChildEnv(sources, decorateEnv), + account: auth.account } } @@ -220,7 +167,6 @@ export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { return async ({ identity }) => { - await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) const record = deps.store.getRecord(identity.sessionId) if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) @@ -241,18 +187,7 @@ export function createClaudeStructuredLaunchResolver( if (accountHome.variable !== pinned) { throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`) } - // Every acquisition, not just the first: the account state can change under a live session, and - // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. - // Codex has no gate here — it resolves its account on a different path. const router = getClaudeProfileRouter() - const gate = router ? undefined : deps.readManagedAccountGate?.() - if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { - // Unreadable account state names no situation a person can act on, so only the log reads it. - throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account', - gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} - ) - } // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. const active = activeProviderContext(record) const head = active.head?.handle ?? null diff --git a/src/main/claude/claude-structured-launch-visuals.test.ts b/src/main/claude/claude-structured-launch-visuals.test.ts index 0411523b191c..b75967036017 100644 --- a/src/main/claude/claude-structured-launch-visuals.test.ts +++ b/src/main/claude/claude-structured-launch-visuals.test.ts @@ -39,7 +39,7 @@ function launch(options: { store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveLaunchArgs: () => options.launchArgs ?? [], resolveEnv: () => options.env ?? {}, hasTranscript: async () => false, diff --git a/src/main/claude/claude-structured-real-cli-visuals.test.ts b/src/main/claude/claude-structured-real-cli-visuals.test.ts index a74677ddacf6..7e3ff7c5c957 100644 --- a/src/main/claude/claude-structured-real-cli-visuals.test.ts +++ b/src/main/claude/claude-structured-real-cli-visuals.test.ts @@ -51,7 +51,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { resolveCommand: () => realClaudeCommand, resolveLaunchArgs: () => [], resolveEnv: () => realClaudeLaunchHome().env, - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), hasTranscript: async () => false, cliFlags: createClaudeCliFlagSupport(), prepareVisuals: createNativeChatVisualsDelivery({ stateDirectory, logger }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 83fc04ba2fb5..a518e13d561c 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,10 +1,7 @@ -import { - AgentSessionPreSpawnError, - type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -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 { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' @@ -61,13 +58,6 @@ export async function acquireClaudeSession({ exits: Map 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), { - reason: 'accountSwitchInProgress' - }) - } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const { previous, attempt } = acquisitions.start(sessionId, prompts) diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index f4538da4b07b..f24757cd6ab5 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -143,7 +143,7 @@ describe('Claude durable resume point at turn end', () => { store: { getRecord: () => store.record } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }) + resolveAuthPolicy: () => ({ account: 'system' }) }) const launch = await resolve({ identity: { diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index e71fd3b56739..58a62d797e6e 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -200,7 +200,7 @@ export class ClaudeHookService { } else { writeManagedScript(scriptPath, payload) } - // Why: a profile's statusLine arrives with the settings merge from the default home. + // Why: a profile's statusLine arrives with the settings copy from the default home. if (options.configDir === undefined && plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) } else if (options.configDir === undefined && plan.statusLine === 'retire') { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 0f96932ecb85..965bde775cd3 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -13,7 +13,7 @@ export { copyFileWithWindowsRetry, renameFileWithWindowsRetry, renameFileWithWin export function writeFileAtomically( targetPath: string, - contents: string, + contents: string | Uint8Array, options?: { mode?: number } ): void { const tmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp` diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index ad7c1edabc83..3356d1a4cda1 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(43) + expect(PROTOCOL_VERSION).toBe(44) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 423ee9ee34f2..2de5fd5be84e 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -125,26 +125,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) - it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { - const mod = await importFresh() - // Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's. - const gate = await import('../claude-accounts/live-pty-gate') - defaultListSessionsSessions.push({ sessionId: 'claude-alive' }) - gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead']) - try { - await mod.initDaemonPtyProvider() - - expect(gate.hasLiveClaudePtys()).toBe(true) - - gate.markClaudePtyExited('claude-alive') - // Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone. - expect(gate.hasLiveClaudePtys()).toBe(false) - } finally { - gate.markClaudePtyExited('claude-alive') - gate.markClaudePtyExited('claude-dead') - } - }) - it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => { const mod = await importFresh() let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 540b84945493..2504b90232b7 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,8 +1,11 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v43 drops v42's `claude` account function (reverted #24434); v41 stages long startup commands as -// sourced scripts; v40 rolled the #25130/#24636 shell-wrapper changes and the wider agent list into -// a fresh daemon; older owners stay attachable. -export const PROTOCOL_VERSION = 43 +// v44 gives every pane the `claude` function that follows the selected Claude account (v42 had it, +// v43 dropped it with the revert); v41 stages long startup commands as sourced scripts; v40 rolled +// the #25130/#24636 shell-wrapper changes and the wider agent list into a fresh daemon; older owners +// stay attachable. +export const PROTOCOL_VERSION = 44 +export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 +export const CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION = 43 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. @@ -37,7 +40,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 22b6510e47f6..dd2d2f951368 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,8 +1,4 @@ import { getLocalPtyProvider, rebindLocalProviderListeners } from '../ipc/pty' -import { - confirmSeededClaudeLivePtys, - hasSeededUnconfirmedClaudePtys -} from '../claude-accounts/live-pty-gate' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { checkDaemonHealth } from './daemon-health' import { collectPinnedDaemonVersions, pruneOldDaemonHosts } from './daemon-host-relocation' @@ -22,7 +18,7 @@ import { createOutOfProcessLauncher, type DaemonLaunchPolicy } from './daemon-out-of-process-launcher' -import { listEveryDaemonGeneration, type DaemonProvider } from './daemon-provider-routing' +import type { DaemonProvider } from './daemon-provider-routing' import { installDaemonProvider } from './daemon-provider-state' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { trackDaemonAdopted } from './daemon-adoption-telemetry-event' @@ -163,7 +159,6 @@ export async function initDaemonPtyProvider( if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) } - await reconcileSeededClaudeLivePtys(routedAdapter) } // Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. @@ -190,23 +185,3 @@ async function reportDaemonAdoption( // Best-effort measurement only. } } - -// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. -async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { - if (!hasSeededUnconfirmedClaudePtys()) { - return - } - try { - const ids = await listEveryDaemonGeneration(provider, async (a) => - (await a.listSessions()).map((s) => s.sessionId) - ) - if (!ids) { - console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') - return - } - confirmSeededClaudeLivePtys(ids) - } catch (error) { - // Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart. - console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error) - } -} diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index 18f6ad6cf496..14b76e2af7a7 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -3,7 +3,11 @@ import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { readDaemonPidRecord as readDaemonPidRecordAt } from './daemon-endpoint-incarnation' import type { ParsedDaemonPid } from './daemon-pid-file-parse' -import { listEveryDaemonGeneration, type DaemonProvider } from './daemon-provider-routing' +import { + getLegacyDaemonAdapters, + listEveryDaemonGeneration, + type DaemonProvider +} from './daemon-provider-routing' import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' import type { DaemonSpawner } from './daemon-spawner' import { @@ -79,6 +83,18 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } +/** True for a terminal on a kept-alive older daemon whose protocol matches; never for an SSH pane. */ +export function isTerminalOnLegacyDaemon( + ptyId: string, + matches: (protocolVersion: number) => boolean +): boolean { + return adapter + ? getLegacyDaemonAdapters(adapter).some( + (legacy) => matches(legacy.protocolVersion) && legacy.hasPty(ptyId) + ) + : false +} + // Why: computed from the pid record on demand (not cached at adoption) so the Settings // remedy surface always reflects the daemon actually serving terminals right now. export async function getCurrentDaemonMacTccAttributionHealth(): Promise { diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index e29fff5330ab..e3c1638ff1fe 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -34,6 +34,10 @@ import { type RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from '../../../shared/claude-profile-routing' import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' import { resolveWslSessionContext } from '../wsl-session-context' @@ -169,8 +173,8 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env.ORCA_CLAUDE_PROFILE_POINTER) { - addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + if (env[CLAUDE_PROFILE_POINTER_ENV]) { + addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index f88f094e46b3..181164a46468 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,10 +1,10 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountService } from '../claude-accounts/service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountSelectionTarget) => claudeAccounts.addAccount(args) ) ipcMain.handle('claudeAccounts:cancelPendingLogin', () => claudeAccounts.cancelPendingLogin()) @@ -16,11 +16,9 @@ export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountServi ) ipcMain.handle( 'claudeAccounts:select', - (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { - if (!args.runtime) { - return claudeAccounts.selectAccount(args.accountId) - } - return claudeAccounts.selectAccountForTarget(args.accountId, args) - } + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => + args.runtime + ? claudeAccounts.selectAccountForTarget(args.accountId, args) + : claudeAccounts.selectAccount(args.accountId) ) } diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index d8b836c91a15..c37f33bc0914 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -393,7 +393,7 @@ describe('registerFilesystemHandlers', () => { it('prepares the selected Claude auth environment before local generation', async () => { const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = 'do-not-leak-managed-auth-conflict' + process.env.ANTHROPIC_API_KEY = 'shell-proxy-key' const context = { branch: 'feature/ai', stagedSummary: 'M\tREADME.md', @@ -412,7 +412,6 @@ describe('registerFilesystemHandlers', () => { prepareForClaudeLaunch: async () => ({ configDir: '/managed/claude', envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' }, - stripAuthEnv: true, provenance: 'managed:account-1' }) }) @@ -429,7 +428,8 @@ describe('registerFilesystemHandlers', () => { CLAUDE_CONFIG_DIR: '/managed/claude' }) ) - expect(target?.env?.ANTHROPIC_API_KEY).toBeUndefined() + // The shell's own key stays with the account, as on System default. + expect(target?.env?.ANTHROPIC_API_KEY).toBe('shell-proxy-key') } finally { if (previousAnthropicApiKey === undefined) { delete process.env.ANTHROPIC_API_KEY @@ -461,7 +461,6 @@ describe('registerFilesystemHandlers', () => { prepareForClaudeLaunch: async () => ({ configDir: '/home/me/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'system' }) }) diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index 73154bddab51..a4c4a17d7f8d 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -168,7 +168,6 @@ describe('registerFilesystemHandlers', () => { const prepareForClaudeLaunch = vi.fn().mockResolvedValue({ configDir: '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.claude', envPatch: { CLAUDE_CONFIG_DIR: '/home/tester/.claude' }, - stripAuthEnv: true, provenance: 'managed:account-1' }) const folderStore = { diff --git a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts index 7e4178f35d3e..470582f35615 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -8,7 +8,6 @@ import { } from './pty-ipc-mock-registry' import { posixOnlyIt, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import * as livePtyGate from '../claude-accounts/live-pty-gate' import { registerPtyHandlers, setLocalPtyProvider, getLocalPtyProvider } from './pty' import { join } from 'node:path' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. @@ -367,7 +366,6 @@ describe('registerPtyHandlers', () => { } spawnMock.mockReturnValue(proc) const isRecoveryReloadInFlight = vi.fn(() => true) - const markClaudePtyExitedSpy = vi.spyOn(livePtyGate, 'markClaudePtyExited') registerPtyHandlers( mainWindow as never, @@ -396,11 +394,8 @@ describe('registerPtyHandlers', () => { expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(markClaudePtyExitedSpy).not.toHaveBeenCalled() const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(true) - - markClaudePtyExitedSpy.mockRestore() }) // Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index bdfd41181823..3dcc6cbe85b2 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -186,7 +186,6 @@ describe('registerPtyHandlers', () => { let releaseAuth!: (value: { configDir: string envPatch: Record - stripAuthEnv: false provenance: string }) => void const prepareClaudeAuth = vi.fn( @@ -194,7 +193,6 @@ describe('registerPtyHandlers', () => { new Promise<{ configDir: string envPatch: Record - stripAuthEnv: false provenance: string }>((resolve) => { releaseAuth = resolve @@ -233,7 +231,6 @@ describe('registerPtyHandlers', () => { releaseAuth({ configDir: '/tmp/claude', envPatch: {}, - stripAuthEnv: false, provenance: 'managed:test' }) await new Promise((resolve) => setImmediate(resolve)) diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index d530a9cd1947..36c02a0f2346 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,14 +1,13 @@ import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' -import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' +import { registerPtyHandlers, buildPtyHostEnv } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' import { selectShellStartupFeatures } from '../shell-startup-features' @@ -320,49 +319,6 @@ describe('registerPtyHandlers', () => { id: expect.any(String) }) }) - it('marks local Claude launches live until the PTY is killed', async () => { - let exitCb: ((info: { exitCode: number }) => void) | undefined - spawnMock.mockReturnValue({ - onData: vi.fn(() => makeDisposable()), - onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { - exitCb = cb - return makeDisposable() - }), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => exitCb?.({ exitCode: -1 })), - process: 'zsh', - pid: 12345 - }) - const prepareClaudeAuth = vi.fn(async () => ({ - configDir: '/tmp/claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'managed:account-1' - })) - registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, prepareClaudeAuth) - - const spawnResult = (await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - command: 'claude' - })) as { id: string } - - expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) - expect(hasLiveClaudePtys()).toBe(true) - - await handlers.get('pty:kill')!(null, { id: spawnResult.id }) - - expect(hasLiveClaudePtys()).toBe(false) - }) - it('clears Claude live-PTY tracking from shared provider teardown', () => { - markClaudePtySpawned('ssh-claude-pty') - expect(hasLiveClaudePtys()).toBe(true) - - clearProviderPtyState('ssh-claude-pty') - - expect(hasLiveClaudePtys()).toBe(false) - }) it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => { const env = await spawnAndGetEnv(undefined, { LANG: undefined }) expect(env.LANG).toBe('en_US.UTF-8') diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts new file mode 100644 index 000000000000..0164b4a79521 --- /dev/null +++ b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: { id: unknown; target?: unknown }) => Promise + +type Mocks = { + wslRunsAnotherAccount: boolean | null + handlers: Map + legacyDaemons: Map> + startup: ReturnType Promise | undefined>> + runsAnotherAccount: boolean | null + router: boolean +} + +const mocks = vi.hoisted((): Mocks => ({ + handlers: new Map(), + legacyDaemons: new Map(), + startup: vi.fn<() => Promise | undefined>(), + runsAnotherAccount: true, + wslRunsAnotherAccount: true, + router: true +})) +vi.mock('../../pty-host-bindings', () => ({ + getPtyIpc: () => ({ + handle: (channel: string, handler: Handler) => mocks.handlers.set(channel, handler) + }) +})) +vi.mock('../../../daemon/daemon-provider-state', () => ({ + isTerminalOnLegacyDaemon: (id: string, matches: (protocolVersion: number) => boolean) => + [...mocks.legacyDaemons].some(([version, ids]) => matches(version) && ids.has(id)) +})) +vi.mock('../../../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + mocks.router ? { systemDefaultRunsAnotherAccount: () => mocks.runsAnotherAccount } : undefined, + getClaudeWslProfileRouter: () => + mocks.router + ? { + systemDefaultRunsAnotherAccount: async (distro: string) => + distro === 'Ubuntu' ? mocks.wslRunsAnotherAccount : null + } + : undefined +})) + +import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' + +beforeEach(() => { + mocks.handlers.clear() + // v41 predates the claude function, v42 had it as v44 does, and v43 (the revert) dropped it. + mocks.legacyDaemons = new Map([ + [41, new Set(['old-1'])], + [42, new Set(['v42-1'])], + [43, new Set(['v43-1'])] + ]) + mocks.runsAnotherAccount = true + mocks.wslRunsAnotherAccount = true + mocks.router = true + installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) +}) + +const HOST = { runtime: 'host' } + +it('answers true only for a pane whose daemon lacks the claude function', async () => { + const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(true) + expect(await ask({}, { id: 'v43-1', target: HOST })).toBe(true) + expect(await ask({}, { id: 'v42-1', target: HOST })).toBe(false) + // A pane on this build's daemon, or an SSH pane, which no local daemon owns. + expect(await ask({}, { id: 'new-1', target: HOST })).toBe(false) + expect(await ask({}, { id: 'ssh:conn-1:pty-1', target: HOST })).toBe(false) + expect(await ask({}, { id: 42, target: HOST })).toBe(false) + expect(mocks.startup).toHaveBeenCalled() +}) + +it("judges a pane by its own runtime's selection, and hides it when that is unknown", async () => { + const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! + mocks.runsAnotherAccount = false + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) + // A WSL pane asks its distro's router, not the host selection. + const ubuntu = { runtime: 'wsl', wslDistro: 'Ubuntu' } + expect(await ask({}, { id: 'old-1', target: ubuntu })).toBe(true) + mocks.wslRunsAnotherAccount = false + expect(await ask({}, { id: 'old-1', target: ubuntu })).toBe(false) + // No account selected for the pane's runtime, no router, or no runtime at all: hidden. + mocks.runsAnotherAccount = null + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) + expect(await ask({}, { id: 'old-1', target: { runtime: 'wsl', wslDistro: 'Debian' } })).toBe( + false + ) + expect(await ask({}, { id: 'old-1' })).toBe(false) + mocks.router = false + mocks.runsAnotherAccount = true + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) +}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts new file mode 100644 index 000000000000..04ad8c58a022 --- /dev/null +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -0,0 +1,60 @@ +import { + getClaudeProfileRouter, + getClaudeWslProfileRouter +} from '../../../claude-accounts/claude-profile-installed-router' +import { getPtyIpc } from '../../pty-host-bindings' +import { + CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION, + CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION +} from '../../../daemon/daemon-protocol-version' +import { isTerminalOnLegacyDaemon } from '../../../daemon/daemon-provider-state' + +// Why not every older daemon: v42 shipped the same claude function and pointer; only v43, the revert, lacked them. +function lacksClaudeFunction(protocolVersion: number): boolean { + return ( + protocolVersion < CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION || + protocolVersion === CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION + ) +} + +type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } + +/** Whether a pane's daemon predates the claude function, so its claude runs another account. */ +export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { + getPtyIpc().handle( + 'pty:openedBeforeClaudeAccounts', + async (_event, args: { id: unknown; target?: unknown }): Promise => { + const target = paneTarget(args?.target) + if (typeof args?.id !== 'string' || !target) { + return false + } + // Why: the pre-swap provider does not own restored daemon ids. + await deps.getLocalPtyProviderStartupPromise() + if (!isTerminalOnLegacyDaemon(args.id, lacksClaudeFunction)) { + return false + } + // Why the pane's own runtime: claude there runs that runtime's System default. + const runsAnother = + target.runtime === 'wsl' + ? await getClaudeWslProfileRouter() + ?.systemDefaultRunsAnotherAccount(target.wslDistro) + .catch(() => null) + : getClaudeProfileRouter()?.systemDefaultRunsAnotherAccount() + return runsAnother === true + } + ) +} + +function paneTarget( + value: unknown +): { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } | null { + if (typeof value !== 'object' || value === null || !('runtime' in value)) { + return null + } + if (value.runtime === 'host') { + return { runtime: 'host' } + } + return value.runtime === 'wsl' && 'wslDistro' in value && typeof value.wslDistro === 'string' + ? { runtime: 'wsl', wslDistro: value.wslDistro } + : null +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 24df59c1201c..ffcc5d158bb3 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,6 +1,5 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' @@ -125,9 +124,6 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } - if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - 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. // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 8adec688a678..55cce7903408 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,7 +1,6 @@ import { getAppEnvironment } from '../../../../shared/app-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' @@ -39,11 +38,7 @@ export async function buildPtyIpcSpawnOptions( ctx.preAllocatedHandle ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } : ctx.env, ctx.deps.getSettings?.() ) - const envToDelete = ctx.claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined ctx.combinedEnvToDelete = mergePtyEnvDeletions( - envToDelete, args.envToDelete ?? [], // Persistent daemons and older SSH hosts must not resurrect a parent Pi's ownership. PI_PROCESS_OWNER_ENV_KEYS, diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index b6a50c43bbbf..4b8d71c04417 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,8 +4,6 @@ import { resolveLocalWindowsTerminalRuntimeOptions } 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' @@ -194,9 +192,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId ? resolveLocalWindowsTerminalRuntimeOptions({ diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 31bacec82f7f..ec13ad0d36e2 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -1,4 +1,3 @@ -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { @@ -92,7 +91,6 @@ export function bindProviderListeners(session: PtyIpcSession): void { if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) if (syntheticExit) { session.runtime?.markPtyStopRequested(payload.id) } diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 8a362b74aaf2..5c4265bdc6ea 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -10,7 +10,6 @@ import { isSshPtyProvenExitedOnRelayError } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { ptyIncarnationById, ptyOwnership } from './ownership-state' import { getRelayPtyId } from './registry' import { @@ -179,6 +178,5 @@ export function finishPtyShutdown( store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') } ptyOwnership.delete(id) - markClaudePtyExited(id) return incarnationId } diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 7a494522ec01..eeb5fef028c5 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -10,7 +10,6 @@ import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, @@ -126,7 +125,6 @@ export function configureLocalPtyProvider(args: { } clearProviderPtyState(id) ptyOwnership.delete(id) - markClaudePtyExited(id) runtime?.onPtyExit(id, code, incarnationId, { providerExitObserved: true, ...(cause ? { cause } : {}) diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index f70b07dbff85..aa99d191abc0 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -1,6 +1,5 @@ import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' import { unregisterPty } from '../../../memory/pty-registry' -import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' import { openCodeHookService } from '../../../opencode/hook-service' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' @@ -49,8 +48,6 @@ export function clearProviderPtyState( // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) - // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. - markClaudePtyExited(id) ptySizes.delete(id) ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7f29e895e0bc..5e823ac535ca 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -13,6 +13,7 @@ import { finishPtyShutdown } from './provider/liveness' import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' import { installPtyInspectIpcHandlers } from './ipc/inspect' import { installPtyCodexSharedServerIpcHandler } from './ipc/codex-shared-server' +import { installPtyClaudeOldTerminalIpcHandler } from './ipc/claude-old-terminal' import { installPtyKillIpcHandler, stopReplacedPanePty, @@ -124,6 +125,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:isCodexOnSharedServer') ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') ipcMain.removeHandler('pty:stopCodexSharedServer') + ipcMain.removeHandler('pty:openedBeforeClaudeAccounts') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') @@ -286,5 +288,6 @@ export function registerPtyHandlers( installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) + installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index c38637371c36..e6e301aa8afa 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -7,7 +7,6 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -240,9 +239,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (!ctx.stablePaneOwner) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } - if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { - markClaudePtySpawned(ctx.result.id) - } if (args.telemetry && !ctx.stablePaneOwner) { recordPtySpawnTelemetry(args.telemetry) } diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 26c3856b45ee..6f041b0686ba 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -19,7 +19,6 @@ import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' @@ -47,9 +46,6 @@ export async function buildRuntimePtySpawnOptions( // Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env. ctx.env = withCodexTerminalServerIsolationEnv(ctx.env, ctx.deps.getSettings?.()) - const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined ctx.spawnOptions = { cols: args.cols, rows: args.rows, @@ -74,7 +70,6 @@ export async function buildRuntimePtySpawnOptions( args.onPtySpawnCommitted?.() } ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( - authEnvToDelete, args.envToDelete ?? [], // Persistent daemons and older SSH hosts must not resurrect a parent Pi's ownership. PI_PROCESS_OWNER_ENV_KEYS, diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index cfbace932ab8..88db8879630e 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -96,6 +96,7 @@ describe('runtime pty spawn preflight: Claude account routing in a WSL pane', () new ClaudeProfileRouter({ getSettings: () => getDefaultSettings('/tmp'), dataRoot: '/data/orca', + env: {}, runSetup }) ) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index c82b0656a377..0c8d2371dc01 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,12 +23,6 @@ import { } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, - hasClaudeAuthEnvConflict -} from '../../../claude-accounts/environment' import { isSafePtySessionId, mintPtySessionId, @@ -70,9 +64,6 @@ export async function prepareRuntimePtySpawn( } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - 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. // `args.shellOverride` is the per-request pick (`terminal create --shell`), read here the way // the renderer twin (ipc/spawn-preflight.ts) reads a tab's override. Without it a runtime create @@ -151,12 +142,6 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) - } - if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) - } ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true if (ctx.shouldPersistHostSessionBinding) { diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts index 38ec1b05a723..604fd8b00bd5 100644 --- a/src/main/macos-keychain/generic-password.test.ts +++ b/src/main/macos-keychain/generic-password.test.ts @@ -4,12 +4,7 @@ const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { - deleteKeychainPassword, - isKeychainNotFoundError, - readKeychainPassword, - writeKeychainPassword -} from './generic-password' +import { isKeychainNotFoundError, readKeychainPassword } from './generic-password' const originalPlatform = process.platform @@ -71,27 +66,10 @@ describe('readKeychainPassword', () => { it('never shells out off macOS', async () => { setPlatform('win32') await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() - await writeKeychainPassword('svc', 'acct', 'value') - await deleteKeychainPassword('svc', 'acct') expect(execFileMock).not.toHaveBeenCalled() }) }) -describe('deleteKeychainPassword', () => { - it('swallows a missing item', async () => { - fail(Object.assign(new Error('could not be found'), { code: 44 })) - await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() - }) - - it('surfaces an access failure only when the caller asks for it', async () => { - fail(new Error('User interaction is not allowed')) - await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() - await expect( - deleteKeychainPassword('svc', 'acct', { failOnAccessError: true }) - ).rejects.toThrow('User interaction is not allowed') - }) -}) - describe('isKeychainNotFoundError', () => { it('recognizes the security(1) not-found signals', () => { expect(isKeychainNotFoundError({ code: 44 })).toBe(true) diff --git a/src/main/macos-keychain/generic-password.ts b/src/main/macos-keychain/generic-password.ts index 8a805edd809b..1616008f303b 100644 --- a/src/main/macos-keychain/generic-password.ts +++ b/src/main/macos-keychain/generic-password.ts @@ -79,20 +79,6 @@ export function execSecurityCommand(args: string[]): Promise { - return execSecurityCommand(args).then(undefined, (error: unknown) => { - if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { - return - } - if (!options?.ignoreFailure) { - throw error - } - }) -} - export async function readKeychainPassword( service: string, account: string @@ -120,28 +106,3 @@ export async function readKeychainPassword( throw error } } - -export async function writeKeychainPassword( - service: string, - account: string, - contents: string -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) -} - -export async function deleteKeychainPassword( - service: string, - account: string, - options?: { failOnAccessError?: boolean } -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['delete-generic-password', '-s', service, '-a', account], { - ignoreNotFound: true, - ignoreFailure: !options?.failOnAccessError - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 98aab1b6ba28..3525e66e5f66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -149,6 +149,8 @@ export type AgentSessionPreSpawnReason = Extract< | 'managedAccountUnsupported' | 'launchFolderMissing' | 'historyInOtherAccount' + | 'claudeAccountFolderMissing' + | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index ea7837278cbf..37f728808b26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -33,6 +33,8 @@ const TYPED_START_REFUSALS = [ 'managedAccountUnsupported', 'launchFolderMissing', 'historyInOtherAccount', + 'claudeAccountFolderMissing', + 'claudeAccountSetupFailed', 'agentCommandNotRunnable' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index ed6c35a43a95..c9ecb34dad48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -2,6 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../../shared/claude-profile-routing' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' @@ -170,6 +174,18 @@ describe('a create that fails before any process spawns', () => { 'historyInOtherAccount', "This chat's history is in another Claude account. Switch back to that account to continue it." ], + [ + 'a selected Claude account whose folder is gone', + CLAUDE_PROFILE_MISSING_MESSAGE, + 'claudeAccountFolderMissing', + CLAUDE_PROFILE_MISSING_MESSAGE + ], + [ + 'a selected Claude account that could not be set up', + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + 'claudeAccountSetupFailed', + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ], [ 'a Command setting that names no runnable program', 'the claude Command setting is not a runnable program', diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts deleted file mode 100644 index 8d6aa55783f4..000000000000 --- a/src/main/native-chat/claude-structured-managed-account-support.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' -import { - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from './claude-structured-managed-account-support' - -function account(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -function settings( - overrides: Partial -): ClaudeManagedAccountGateSettings { - return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } -} - -describe('structuredClaudeMatchesActiveManagedAccount', () => { - it('allows an unmanaged install, where nothing claims an identity', () => { - expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) - }) - - it('allows a selected host account, which the runtime syncs into the ambient config', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } - }) - ) - ).toBe(true) - }) - - it('refuses a WSL-only managed account, which never reaches the ambient config', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } - }) - ) - ).toBe(false) - }) - - it('refuses when a host selection names an account that is WSL-bound or missing', () => { - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } - }) - ) - ).toBe(false) - expect( - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } - }) - ) - ).toBe(false) - }) - - /** Absent and empty are the same answer: this user has no managed Claude accounts, so nothing - * claims an identity and the ambient path is legitimate. Only settings that cannot be READ are - * unknown. Treating a missing key as unknown strands profiles that simply never wrote it — the - * auth policy's own predicate takes `(accounts ?? [])` for exactly this reason. */ - it('treats an absent account list the same as an empty one', () => { - expect( - structuredClaudeMatchesActiveManagedAccount(settings({ claudeManagedAccounts: [] })) - ).toBe(true) - expect( - structuredClaudeMatchesActiveManagedAccount({ - activeClaudeManagedAccountId: null - } as unknown as ClaudeManagedAccountGateSettings) - ).toBe(true) - }) - - it('fails closed when the settings cannot be read at all', () => { - expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) - expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) - }) - - /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the - * settings alone cannot distinguish honest deselection from the WSL-only steady state, because - * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. - * So the presence of ANY WSL-bound account decides. Simplifying this to "none active -> - * supported" re-opens the auth-identity misrepresentation this gate exists to prevent. */ - it('splits none-active on whether a WSL-bound account exists at all', () => { - const noneActive = (accounts: ReturnType[]) => - structuredClaudeMatchesActiveManagedAccount( - settings({ - claudeManagedAccounts: accounts, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } - }) - ) - - expect(noneActive([account('host-1', 'host')])).toBe(true) - expect(noneActive([account('host-1', 'host'), account('host-2', 'host')])).toBe(true) - expect(noneActive([account('wsl-1', 'wsl')])).toBe(false) - // Mixed list still refuses: the WSL account is present and nothing is selected. - expect(noneActive([account('host-1', 'host'), account('wsl-1', 'wsl')])).toBe(false) - }) - - /** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the - * selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's - * fall-through lives — reading the runtime map directly silently disagrees with the policy. */ - it('resolves the same account as the auth policy on a legacy flat selection', () => { - const legacy = settings({ - claudeManagedAccounts: [account('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1' - }) - - expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1') - expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true) - }) - - it('agrees with the auth policy that a legacy flat WSL selection is refused', () => { - const legacy = settings({ - claudeManagedAccounts: [account('wsl-1', 'wsl')], - activeClaudeManagedAccountId: 'wsl-1' - }) - - expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1') - expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false) - }) -}) diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts deleted file mode 100644 index 3b42c44c443f..000000000000 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { GlobalSettings } from '../../shared/global-settings-types' -import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' - -export type ClaudeManagedAccountGateSettings = Pick< - GlobalSettings, - | 'claudeManagedAccounts' - | 'activeClaudeManagedAccountId' - | 'activeClaudeManagedAccountIdsByRuntime' -> - -/** - * A structured Claude session launches against the ambient Claude config, which the account service - * keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro - * and is never synced there, so such a session would authenticate as whatever the ambient identity - * happens to be while the UI names the WSL account — the user is told one identity and given - * another. Refuse the structured path there and let the terminal-backed one, which resolves the - * account per runtime, handle that account shape. - * - * Reads the selection through the same accessor the auth policy uses. Resolving it any other way - * lets the two disagree, and a session admitted by this gate would then run under a policy computed - * from a different account than the one approved here. - * - * Unknown answers refuse, and only genuinely unknown ones: settings that cannot be read at all, or - * an active selection this cannot resolve. An install with no managed accounts — the list empty or - * never written — claims no identity and is fine. - */ -export function structuredClaudeMatchesActiveManagedAccount( - settings: ClaudeManagedAccountGateSettings | null | undefined -): boolean { - if (!settings) { - return false - } - // Absent is the same answer as empty — this user has no managed Claude accounts, so nothing - // claims an identity and ambient auth is the truth. Only settings that cannot be READ are - // unknown, and those refuse above. The auth policy reads the list the same way. - const accounts = settings.claudeManagedAccounts ?? [] - if (accounts.length === 0) { - return true - } - const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - if (!activeHostId) { - // Nothing selected for the host runtime is two different states that the settings cannot tell - // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no - // identity, and the WSL-only case, where the prune emptied the host slot and persisted null - // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !hasWslBoundClaudeAccount(settings) - } - const active = accounts.find((candidate) => candidate.id === activeHostId) - return active ? active.managedAuthRuntime !== 'wsl' : false -} - -/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ -export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { - return (settings.claudeManagedAccounts ?? []).some( - (candidate) => candidate.managedAuthRuntime === 'wsl' - ) -} - -/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ -export function readClaudeManagedAccountGateSettings( - getSettings: () => ClaudeManagedAccountGateSettings -): ClaudeManagedAccountGateSettings | null { - try { - return getSettings() - } catch { - return null - } -} diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index d778f98c247b..9b2d56aa49d4 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -49,7 +49,7 @@ describe('inline visuals launch preference', () => { store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repo', resolveCommand: () => '/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveLaunchArgs: () => ['--add-dir', '/user/notes', '--plugin-dir', '/user/plugin'], resolveEnv: () => ({ [NATIVE_CHAT_VISUALS_DIR_ENV]: '/inherited', KEEP: 'yes' }), resolveInheritedEnv: async () => ({ PATH: '/bin' }), diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index 61dfd787920b..a0b61c5ceadc 100644 --- a/src/main/native-chat/structured-agent-session-create-support.test.ts +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import type { ClaudeManagedAccountGateSettings } from './claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport } from './structured-agent-session-create-support' const LOCAL: AgentSessionExecutionLocation = { @@ -10,64 +9,11 @@ const LOCAL: AgentSessionExecutionLocation = { workspaceKind: 'git-worktree' } -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -const WSL_ONLY: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - -function support( - overrides: Partial[0]> = {} -) { - return resolveStructuredAgentSessionCreateSupport({ - agent: 'claude', - location: LOCAL, - adapterSupportsCreate: true, - getSettings: () => HOST_SELECTED, - ...overrides - }) -} - describe('resolveStructuredAgentSessionCreateSupport', () => { - it('supports Claude under a selected host account', () => { - expect(support()).toEqual({ supported: true }) - }) - - it('refuses Claude under a WSL-only managed account', () => { - expect(support({ getSettings: () => WSL_ONLY })).toEqual({ supported: false, reason: 'wsl' }) - }) - - it('fails closed for Claude when the settings throw', () => { + it('supports what the adapter supports', () => { expect( - support({ - getSettings: () => { - throw new Error('no store') - } - }) - ).toEqual({ supported: false, reason: 'wsl' }) - }) - - it('leaves Codex to the adapter answer under the same WSL-only account', () => { - expect(support({ agent: 'codex', getSettings: () => WSL_ONLY })).toEqual({ supported: true }) + resolveStructuredAgentSessionCreateSupport({ location: LOCAL, adapterSupportsCreate: true }) + ).toEqual({ supported: true }) }) it.each([ @@ -75,22 +21,8 @@ describe('resolveStructuredAgentSessionCreateSupport', () => { ['wsl workspace', { ...LOCAL, wslDistro: 'Ubuntu' }, 'wsl'], ['unsupported agent', LOCAL, 'agent'] ] as const)('keeps the adapter refusal reason for %s', (_name, location, reason) => { - expect(support({ adapterSupportsCreate: false, location })).toEqual({ - supported: false, - reason - }) - }) - - // Command values never change the selected chat surface. - it.each([ - ['claude', 'claude-wrapper'], - ['codex', 'codex-nightly'], - ['claude', 'npx claude'], - ['codex', 'wrapper --arg'], - ['claude', '/missing/claude'], - ['codex', './codex'] - ] as const)('supports %s when this host sets launch command %s', (agent, command) => { - const settings = { ...HOST_SELECTED, agentCmdOverrides: { [agent]: command } } - expect(support({ agent, getSettings: () => settings })).toEqual({ supported: true }) + expect( + resolveStructuredAgentSessionCreateSupport({ location, adapterSupportsCreate: false }) + ).toEqual({ supported: false, reason }) }) }) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index dc2b40ac1e65..37a1fc033866 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,27 +1,16 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { - readClaudeManagedAccountGateSettings, - structuredClaudeMatchesActiveManagedAccount, - type ClaudeManagedAccountGateSettings -} from './claude-structured-managed-account-support' export type StructuredAgentSessionCreateSupport = { supported: boolean reason?: 'agent' | 'remote' | 'wsl' } -/** - * The create-support verdict, kept out of the runtime class file because that file is `@ts-nocheck` - * — a call site there is not typechecked, so an auth-identity decision written inline would compile - * however wrong it was. The runtime hands over the two facts it owns and this decides. - */ +/** The create-support verdict: the adapter's location answer, with the reason a refusal names. */ export function resolveStructuredAgentSessionCreateSupport(input: { - agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean - getSettings: () => ClaudeManagedAccountGateSettings }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -34,26 +23,12 @@ export function resolveStructuredAgentSessionCreateSupport(input: { : 'agent' } } - // Claude only: Codex resolves its account on a different path, so its answer is untouched here. - // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL - // workspace — and no client reads the field, so it stays as-is. - if ( - input.agent === 'claude' && - !structuredClaudeMatchesActiveManagedAccount( - readClaudeManagedAccountGateSettings(input.getSettings) - ) - ) { - return { supported: false, reason: 'wsl' } - } return { supported: true } } -/** Which create-support check said no: where the chat would run, the installed agent (its binary - * and version on this host), or Claude's managed-account binding. */ -export type StructuredAgentSessionCreateSupportCheck = - | 'location' - | 'installed-agent' - | 'managed-account' +/** Which create-support check said no: where the chat would run, or the installed agent (its + * binary and version on this host). */ +export type StructuredAgentSessionCreateSupportCheck = 'location' | 'installed-agent' /** One main-log line per create-support verdict that sends a launch to the terminal; names the * check and never a path or environment value. */ diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index 1f750ff1503c..a9890f661644 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -277,67 +277,6 @@ describe('Store', () => { }) }) - describe('claudeLivePtySessionIds', () => { - it('persists added ids across reloads and removes them durably', async () => { - const store = await createStore() - - store.addClaudeLivePtySessionId('claude-session-1') - store.addClaudeLivePtySessionId('claude-session-2') - store.addClaudeLivePtySessionId('claude-session-1') - - expect(store.getClaudeLivePtySessionIds()).toEqual(['claude-session-1', 'claude-session-2']) - - const reloaded = await createStore() - expect(reloaded.getClaudeLivePtySessionIds()).toEqual([ - 'claude-session-1', - 'claude-session-2' - ]) - - reloaded.removeClaudeLivePtySessionId('claude-session-1') - reloaded.flush() - - const reloadedAgain = await createStore() - expect(reloadedAgain.getClaudeLivePtySessionIds()).toEqual(['claude-session-2']) - }) - - it('drops malformed persisted entries on load', async () => { - writeDataFile({ - schemaVersion: 1, - claudeLivePtySessionIds: ['valid-id', '', 42, null, 'valid-id', 'x'.repeat(513)] - }) - - const store = await createStore() - - expect(store.getClaudeLivePtySessionIds()).toEqual(['valid-id']) - }) - - it('keeps the newest ids when an oversized persisted list is loaded', async () => { - writeDataFile({ - schemaVersion: 1, - claudeLivePtySessionIds: Array.from({ length: 205 }, (_, index) => `claude-${index}`) - }) - - const store = await createStore() - - const ids = store.getClaudeLivePtySessionIds() - expect(ids).toHaveLength(200) - expect(ids[0]).toBe('claude-5') - expect(ids[199]).toBe('claude-204') - }) - - it('caps the persisted id list', async () => { - const store = await createStore() - for (let index = 0; index < 205; index += 1) { - store.addClaudeLivePtySessionId(`claude-session-${index}`) - } - - const ids = store.getClaudeLivePtySessionIds() - expect(ids).toHaveLength(200) - expect(ids[0]).toBe('claude-session-5') - expect(ids[199]).toBe('claude-session-204') - }) - }) - describe('retired JSON backups', () => { it.each(['sync', 'async'] as const)( 'leaves legacy JSON and backups unchanged during %s SQL writes', diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index 2f0ccdc8165f..7f72f2117aba 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -2,7 +2,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' -import { MAX_CLAUDE_LIVE_PTY_SESSION_IDS } from '../restoring-sessions/pane-alias-normalization' import { MAX_REMOVED_SSH_TARGET_TOMBSTONES, capRemovedSshTargetTombstones, @@ -19,7 +18,6 @@ export type SshTargetStateOperations = { state: PersistedState protectedSecrets: Pick scheduleSave: () => void - flush: () => void } export function getSshTargets(state: PersistedState): SshTarget[] { @@ -92,41 +90,6 @@ export function removeSshTarget(operations: SshTargetStateOperations, id: string operations.scheduleSave() } -export function getClaudeLivePtySessionIds(state: PersistedState): string[] { - return [...(state.claudeLivePtySessionIds ?? [])] -} - -export function addClaudeLivePtySessionId( - operations: SshTargetStateOperations, - sessionId: string -): void { - if (sessionId.length === 0 || sessionId.length > 512) { - return - } - const ids = operations.state.claudeLivePtySessionIds ?? [] - if (ids.includes(sessionId)) { - return - } - // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. - operations.state.claudeLivePtySessionIds = [...ids, sessionId].slice( - -MAX_CLAUDE_LIVE_PTY_SESSION_IDS - ) - // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. - operations.flush() -} - -export function removeClaudeLivePtySessionId( - operations: SshTargetStateOperations, - sessionId: string -): void { - const ids = operations.state.claudeLivePtySessionIds ?? [] - if (!ids.includes(sessionId)) { - return - } - operations.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) - operations.scheduleSave() -} - export function getDeletedSshConfigAliases(state: PersistedState): string[] { return [...(state.deletedSshConfigAliases ?? [])] } diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 1a3ba807c5ab..7045f922de88 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -12,7 +12,6 @@ import { normalizeSshTarget } from '../leasing-ssh-ptys/ssh-normalization' import { - normalizeClaudeLivePtySessionIds, normalizeLegacyPaneKeyAliasEntries, normalizeMigrationUnsupportedPtyEntries } from '../restoring-sessions/pane-alias-normalization' @@ -102,7 +101,6 @@ export function normalizeLoadedProfileState( .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts index 9cf3d1ec2d1c..9e2bed18bc01 100644 --- a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -93,16 +93,6 @@ describe('SQLite durability barriers with another selective write pending', () = }) }) - it('commits Claude live-PTY admission before returning', () => { - const state = fixture() - state.pendSession() - state.store.addClaudeLivePtySessionId('claude-session') - expect(state.read('claudeLivePtySessionIds')).toMatchObject({ - kind: 'value', - value: ['claude-session'] - }) - }) - it('commits SSH lease admission before returning', () => { const state = fixture() state.pendSession() diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index ce81cea373b8..ad12108129ef 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -1,17 +1,14 @@ import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' import { type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, addSshTarget as addSshTargetOperation, clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, getSshTarget as getSshTargetOperation, getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, releaseRemovedSshTargetTombstone as releaseRemovedSshTargetTombstoneOperation, @@ -26,22 +23,17 @@ import { allocateSshTargetGeneration as allocateSshTargetGenerationOperation } f import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' -import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { RepoLifecycleOperations } from './repo-lifecycle-operations' import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick< - StoreRuntimeState, - 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' -> +type SshProfileOperationsRuntime = Pick const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { runtime: SshProfileOperationsRuntime scheduling: WriteSchedulingOperations - flushBarriers: WriteFlushBarrierOperations repos: RepoLifecycleOperations } @@ -51,10 +43,9 @@ export class SshProfileOperations { constructor( runtime: SshProfileOperationsRuntime, scheduling: WriteSchedulingOperations, - flushBarriers: WriteFlushBarrierOperations, repos: RepoLifecycleOperations ) { - this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + this[sshProfileOperationsContext] = { runtime, scheduling, repos } } getSshTargets(): SshTarget[] { @@ -88,18 +79,6 @@ export class SshProfileOperations { ) } - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) - } - getDeletedSshConfigAliases(): string[] { return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) } @@ -148,13 +127,7 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar return { state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, - scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), - flush: () => { - owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( - 'claudeLivePtySessionIds' - ) - owner[sshProfileOperationsContext].flushBarriers.flush() - } + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling) } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 4ba2e58881ac..cf3063ad37c8 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -183,7 +183,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) - const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) const sshLeases = new SshLeaseRecoveryOperations( runtime, diff --git a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts index c7e88c480217..6b9dca94c923 100644 --- a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts +++ b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts @@ -40,33 +40,9 @@ export function legacyMigrationUnsupportedRowsToAliasEntries( return aliasEntries } -// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. -export const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 - // Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 -export function normalizeClaudeLivePtySessionIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. - const ids: string[] = [] - for (let index = value.length - 1; index >= 0; index -= 1) { - const entry = value[index] - if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { - continue - } - if (!ids.includes(entry)) { - ids.push(entry) - } - if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { - break - } - } - return ids.toReversed() -} - export function normalizeMigrationUnsupportedPtyEntries( value: unknown ): MigrationUnsupportedPtyEntry[] { diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index 63ad18883dc9..a1f194e9af0c 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -1,4 +1,8 @@ import { win32 as pathWin32 } from 'node:path' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV +} from '../../shared/claude-profile-routing' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -55,8 +59,8 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { // Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env.ORCA_CLAUDE_PROFILE_POINTER) { - addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) + if (env[CLAUDE_PROFILE_POINTER_ENV]) { + addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) } if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { // Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL. diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 38352a1b6d48..e6e573263ab9 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -308,7 +308,7 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) - it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { + it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => { const plain = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice', @@ -322,14 +322,12 @@ describe('resolveWindowsShellLaunchArgs', () => { "codex 'fix the bug'" ) - expect(plain.shellArgs).toEqual([ - '-c', - 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' - ]) - // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. - expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( - 'set -- --no-daemon "$@"' - ) + expect(plain.shellArgs).toEqual(launched.shellArgs) + // Why: only the rcfile carries the account-following claude function and codex --no-daemon wrapper. + const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8') + expect(rcfile).toContain('function claude {') + expect(rcfile).toContain('set -- --no-daemon "$@"') + expect(rcfile).toContain('source "$HOME/.bash_profile"') expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c5ceade00a44..b905a3fbb302 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,12 +29,9 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. -function getGitBashLaunchCommand(useWrapper: boolean): string { - if (!useWrapper) { - return GIT_BASH_UTF8_LOGIN_COMMAND - } - +// Why every pane gets the rcfile: it defines the claude function that follows the selected +// account (and the codex wrapper), as bash panes elsewhere get; it sources the login chain itself. +function getGitBashLaunchCommand(): string { ensureShellReadyWrappersAt() const wrapperArgs = getBashWrapperLaunchArgs() if (!wrapperArgs) { @@ -217,10 +214,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: [ - '-c', - getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) - ], + shellArgs: ['-c', getGitBashLaunchCommand()], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index f285ba13ce93..df9867efd3b4 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,248 +1,86 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' import { readClaudeOAuthCredentials, resolveClaudeOAuthCredentialReadOptions } from './claude-oauth-credentials' -import { - canRetryClaudeOAuthWithLegacyKeychain, - makeClaudeUsageClassificationError, - makeLiveClaudeUsageDeferredResult, - repairClaudeCredentialsThenRetryOAuth, - retryClaudeOAuthWithLegacyKeychain, - shouldDeferClaudeUsageForLiveSession -} from './claude-oauth-recovery' import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import { - classifyClaudeCredentialAbsence, - classifyClaudeOAuthUsageError -} from './claude-usage-error-classification' +import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' +import { OAuthUsageError } from './claude-oauth-usage-error' import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' -import { - abortedClaudeRateLimitResult, - makeClaudeUsageResult, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { abortedClaudeRateLimitResult, makeClaudeUsageResult } from './claude-usage-result' +import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +/** Usage observes the account; only a user-started Claude process may refresh its login. */ export async function fetchActiveClaudeRateLimits( options?: ClaudeRateLimitFetchOptions ): Promise { + const usageError = options?.authPreparation?.usageError + if (usageError) { + return makeClaudeUsageResult('error', usageError, { + failureKind: + usageError === CLAUDE_PROFILE_MISSING_MESSAGE ? 'missing-credentials' : 'usage-unavailable', + attemptedSources: [] + }) + } if (options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - const attempts = { attemptedSources: [] } - const allowCliFallback = options?.allowPtyFallback !== false - const plan = resolveClaudeUsageRefreshPlan({ - authPreparation: options?.authPreparation, - allowCliFallback - }) - - if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { - return makeClaudeUsageResult( - 'error', - `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, - { - attemptedSources: [], - failureKind: 'cli-unavailable', - authProvenance: options.authPreparation.provenance - } - ) - } - - const oauthCredentials = await readClaudeOAuthCredentials( + const credentials = await readClaudeOAuthCredentials( resolveClaudeOAuthCredentialReadOptions(options?.authPreparation) ) - if (options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - - if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { - recordClaudeUsageAttempt(attempts, 'oauth') - try { - const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) - if (options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits, - oauthCredentials, - attempts, - options - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - const classification = classifyClaudeOAuthUsageError(error) - - if ( - canRetryClaudeOAuthWithLegacyKeychain({ - classification, - oauthCredentials, - authPreparation: options?.authPreparation - }) - ) { - const legacyResult = await retryClaudeOAuthWithLegacyKeychain({ - failedToken: oauthCredentials.token, - attempts, - options - }) - if (legacyResult) { - return legacyResult - } - } - - if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, classification)) { - return makeLiveClaudeUsageDeferredResult({ - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } - - if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ - options, - attempts, - oauthCredentials - }) - if (repaired) { - return repaired - } - } - - if (classification.shouldAttemptCliFallback && allowCliFallback) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (ptyError) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) - } - } - - return makeClaudeUsageClassificationError({ - error, - classification, - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } + const metadata = { + credentialSource: credentials.source, + authProvenance: options?.authPreparation?.provenance ?? 'system' } - - const credentialClassification = classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: oauthCredentials.hasRefreshableCredentials, - keychainUnavailable: oauthCredentials.keychainUnavailable, - managedRefreshDeferredByLivePty: options?.authPreparation?.managedRefreshDeferredByLivePty - }) - - if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, credentialClassification)) { - return makeLiveClaudeUsageDeferredResult({ - attempts, - oauthCredentials, - authPreparation: options?.authPreparation - }) - } - - if ( - oauthCredentials.hasRefreshableCredentials && - credentialClassification.shouldAttemptDelegatedRefresh && - allowCliFallback - ) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ - options, - attempts, - oauthCredentials - }) - if (repaired) { - return repaired - } - } - + // Why: System Default with no Claude login is an API-key or non-Claude user, not a signed-out account. if ( - (oauthCredentials.token || - oauthCredentials.hasRefreshableCredentials || - oauthCredentials.keychainUnavailable) && - credentialClassification.shouldAttemptCliFallback && - allowCliFallback + !credentials.token && + !credentials.unavailable && + !credentials.hasRefreshableCredentials && + metadata.authProvenance === 'system' ) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - return makeClaudeUsageResult( - 'error', - withMacTailscaleDnsHint(error instanceof Error ? error.message : 'Unknown error'), - { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: - credentialClassification.failureKind === 'keychain-unavailable' - ? 'keychain-unavailable' - : 'cli-unavailable' - }) - } - ) - } - } - - if (oauthCredentials.keychainUnavailable) { - return makeClaudeUsageResult('error', 'Claude Keychain credentials unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'keychain-unavailable' - }) + return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { + ...metadata, + failureKind: 'missing-credentials', + attemptedSources: [] }) } - - if (oauthCredentials.hasRefreshableCredentials) { - return makeClaudeUsageResult('error', 'Claude OAuth access token unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: credentialClassification.failureKind - }) - }) + if (!credentials.token) { + return makeClaudeUsageResult( + 'error', + credentials.unavailable + ? 'Claude usage is unavailable.' + : 'Sign in again to use this account.', + { + ...metadata, + failureKind: credentials.unavailable ? 'keychain-unavailable' : 'missing-credentials', + attemptedSources: [] + } + ) } - - if (allowCliFallback && plan.steps.some((step) => step.source === 'cli')) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - } + try { + return await fetchClaudeOAuthUsage(credentials.token, options?.signal) + } catch (error) { + const { failureKind } = classifyClaudeOAuthUsageError(error) + // Why: the 429 wait gates the poll, and both messages tell the user why usage is missing. + const retryAfterMs = error instanceof OAuthUsageError ? error.retryAfterMs : null + return makeClaudeUsageResult( + 'error', + failureKind === 'stale-token' + ? // Why: Claude refreshes its own login when it next runs; System Default has no "account". + metadata.authProvenance === 'system' + ? 'Claude usage updates the next time Claude runs.' + : 'Claude usage has expired. Start Claude in this account to refresh it.' + : (failureKind === 'rate-limited' || failureKind === 'missing-scope') && + error instanceof OAuthUsageError + ? error.message + : 'Claude usage is unavailable.', + { + ...metadata, + failureKind, + attemptedSources: ['oauth'], + ...(retryAfterMs ? { retryAtMs: Date.now() + retryAfterMs } : {}) + } + ) } - - return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'missing-credentials' - }) - }) } diff --git a/src/main/rate-limits/claude-cli-usage-fetch.ts b/src/main/rate-limits/claude-cli-usage-fetch.ts deleted file mode 100644 index 70d264a4e68f..000000000000 --- a/src/main/rate-limits/claude-cli-usage-fetch.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - isManagedClaudeAuth, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -function classifyClaudeCliUsageFailure( - limits: ProviderRateLimits -): UsageRateLimitFailureKind | undefined { - if (!limits.error) { - return undefined - } - if (/rate limited/i.test(limits.error)) { - return 'rate-limited' - } - if (/plan usage is unavailable|usage is unavailable/i.test(limits.error)) { - return 'usage-unavailable' - } - return 'cli-unavailable' -} - -export async function fetchClaudeUsageViaCli(input: { - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - recordClaudeUsageAttempt(input.attempts, 'cli') - const limits = await fetchViaPty({ - authPreparation: input.authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - source: 'cli', - failureKind: classifyClaudeCliUsageFailure(limits) - }) - ) -} - -async function supplementOAuthUsageFromCli(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - allowUsagePanelSupplement: boolean - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted || !canSupplementClaudeOAuthUsage(input)) { - return input.oauthLimits - } - try { - return mergeClaudeUsageWindows( - input.oauthLimits, - await fetchClaudeUsageViaCli({ - authPreparation: input.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, error) - return input.oauthLimits - } -} - -export async function completeClaudeOAuthUsageSuccess(input: { - oauthLimits: ProviderRateLimits - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): Promise { - const limits = await supplementOAuthUsageFromCli({ - oauthLimits: input.oauthLimits, - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - allowUsagePanelSupplement: - input.options?.allowUsagePanelSupplement ?? - isManagedClaudeAuth(input.options?.authPreparation), - signal: input.options?.signal - }) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) -} diff --git a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts deleted file mode 100644 index 428a29106941..000000000000 --- a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not mask OAuth usage rate limits with the PTY fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429, headers: { 'retry-after': '3000' } } - ) - ) - - const before = Date.now() - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result).toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude usage is rate limited right now.', - usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) - }) - expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) - expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) - - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('omits retryAtMs when a 429 has no Retry-After header', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429 } - ) - ) - - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result.status).toBe('error') - expect(result.usageMetadata?.retryAtMs).toBeUndefined() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['oauth', 'cli'] - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('re-reads credentials and retries OAuth once after CLI repair', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - // Legacy item absent — the stale-scoped legacy fallback must not preempt - // CLI repair in this scenario. - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'repaired-oauth-token', - refreshToken: 'refresh-token-2', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 14 }, - seven_day: { utilization: 27 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 14 }, - weekly: { usedPercent: 27 }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'], - credentialSource: 'scoped-keychain' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - expect(netFetchMock).toHaveBeenNthCalledWith( - 2, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer repaired-oauth-token' - }) - }) - ) - }) - - it('explains auth failures when a live Claude terminal owns managed refresh', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start CLI fallback when live Claude owns managed refresh and no token is readable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.', - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true, - attemptedSources: [] - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start the PTY fallback when disabled for background fetches', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'OAuth API returned 500' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('does not start the PTY fallback for refresh-only credentials when disabled', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude OAuth access token unavailable' - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('falls back to CLI when OAuth credentials are missing in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('marks CLI plan usage shell results as usage unavailable', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: 1, - error: 'Claude plan usage is unavailable for this Claude CLI session.', - status: 'error' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude plan usage is unavailable for this Claude CLI session.', - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - failureKind: 'usage-unavailable' - } - }) - }) - - it('surfaces Keychain read failures as structured usage metadata when CLI fallback is disabled', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ allowPtyFallback: false })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude Keychain credentials unavailable', - usageMetadata: { - failureKind: 'keychain-unavailable', - attemptedSources: [], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback when Keychain is unavailable in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts deleted file mode 100644 index 2a84cc4f2af6..000000000000 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('accepts Claude Code statusline-style rate limit window fields', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 }, - fable_weekly: { used_percentage: 12.3, resets_at: 1770691200 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetsAt: 1770691200000 } - }) - }) - - it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 36 }, - seven_day: { utilization: 73 }, - fable_weekly: { utilization: 12 }, - limits: [ - { kind: 'weekly_scoped', percent: 55, scope: null }, - { - kind: 'weekly_scoped', - percent: 100, - resets_at: '2026-07-17T20:00:00.099908+00:00', - is_active: true, - scope: { model: { display_name: 'Fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - fableWeekly: { - usedPercent: 100, - resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') - }, - usageMetadata: { attemptedSources: ['oauth'] } - }) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - fable_weekly: { utilization: 33 }, - limits: [ - { - kind: 'weekly_scoped', - percent: 90, - is_active: false, - scope: { model: { display_name: 'fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - fableWeekly: { usedPercent: 90 } - }) - }) - - it('surfaces an inactive scoped Fable entry when no legacy Fable field exists (#8979)', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - limits: [ - { - kind: 'weekly_scoped', - percent: 64, - resets_at: '2026-07-24T20:00:00+00:00', - is_active: false, - scope: { model: { display_name: 'Fable' } } - } - ] - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - fableWeekly: { - usedPercent: 64, - resetsAt: Date.parse('2026-07-24T20:00:00+00:00') - } - }) - }) - - it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - fableWeekly: { - usedPercent: 12.3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d 2h' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '4d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchClaudeRateLimits({ - authPreparation, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 58, resetDescription: '4d' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 11 }, - seven_day: { utilization: 22 }, - fable: { utilization: 33 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 11 }, - weekly: { usedPercent: 22 }, - fableWeekly: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts deleted file mode 100644 index 9a75d3826e5c..000000000000 --- a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not read host credentials when WSL config resolution fails', async () => { - await expect( - fetchClaudeRateLimits({ - authPreparation: { - configDir: '/Users/test/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'WSL Claude config unavailable for Ubuntu' - }) - - expect(readFileMock).not.toHaveBeenCalled() - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('reads scoped Keychain credentials when the Claude config dir is explicit', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - readFileMock.mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'file-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer oauth-token', - 'User-Agent': 'claude-code/2.1.0' - }) - }) - ) - }) - - it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ - claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' } - }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => { - const auth = (init?.headers as Record | undefined)?.Authorization - return auth === 'Bearer fresh-legacy-token' - ? new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { status: 200 } - ) - : new Response( - JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), - { status: 401 } - ) - }) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(netFetchMock).toHaveBeenCalledTimes(2) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('prefers a legacy access token over scoped refresh-only credentials', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 } - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' }) - }) - ) - }) - - it('does not retry with the legacy keychain for managed account credentials', async () => { - const configDir = '/Users/test/managed-account' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) - }) - - it('does not retry with the legacy keychain for WSL targets', async () => { - // Why: a WSL target's stale credentials must never be answered with the - // host user's legacy macOS Keychain account. - const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/test/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) => - dir - ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } }) - : JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } }) - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined) - }) - - it('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => { - // Why: Claude's usage endpoint has a tight request budget; retrying the - // identical token would double the request for a guaranteed second 401. - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } }) - ) - netFetchMock.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), { - status: 401 - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - - expect(netFetchMock).toHaveBeenCalledTimes(1) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(undefined) - }) - - it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'host', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer legacy-oauth-token' - }) - }) - ) - }) - - it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - runtime: 'host', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 } - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) - expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer scoped-oauth-token' - }) - }) - ) - }) - - it('falls back to the credentials file when Keychain access fails', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockRejectedValue( - new Error('Keychain locked') - ) - readFileMock.mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'file-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok' - }) - - expect(readFileMock).toHaveBeenCalledWith( - join('/Users/test/.claude', '.credentials.json'), - 'utf-8' - ) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer file-oauth-token' - }) - }) - ) - }) - - it('falls back to legacy Keychain when scoped credentials are unusable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce('{not-json') - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok' - }) - - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) - expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer legacy-oauth-token' - }) - }) - ) - }) - - it('tries OAuth usage even when local credential metadata is expired', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 } - }) - - expect(netFetchMock).toHaveBeenNthCalledWith( - 1, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(readFileMock).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts deleted file mode 100644 index c01bcb00f6eb..000000000000 --- a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fetchManagedAccountUsage } from './claude-fetcher' -import { - primeClaudeFetcherMocks, - restorePlatform, - setPlatform -} from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - let tempDir: string | null = null - - beforeEach(() => { - tempDir = null - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('does not read inactive managed credentials from unowned auth paths', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const unownedAuthPath = join(tempDir, 'unowned', 'auth') - mkdirSync(unownedAuthPath, { recursive: true }) - writeFileSync(join(unownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - writeFileSync( - join(unownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'unowned-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - - await expect( - fetchManagedAccountUsage({ id: 'account-1', managedAuthPath: unownedAuthPath }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'No credentials' - }) - - expect(netFetchMock).not.toHaveBeenCalled() - expect(readFileMock).not.toHaveBeenCalled() - }) - - it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - writeFileSync( - join(ownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'inactive-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 42, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '2d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 42, resetDescription: '2d' } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ - configDir: canonicalAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, - provenance: 'managed:account-1:inactive-preview', - stripAuthEnv: true - }) - }) - }) - - it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const credentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( - credentialsJson, - canonicalAuthPath - ) - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('cleans up scoped Keychain credentials when the inactive preview fails', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'managed-keychain-token' } }) - ) - vi.mocked(fetchViaPty).mockRejectedValueOnce(new Error('preview failed')) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: null - }) - - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('stages refreshed macOS inactive account credentials before Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const staleCredentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }), - { status: 200 } - ) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { - status: 200 - } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ - claudeAiOauth: { - accessToken: 'fresh-access', - refreshToken: 'fresh-refresh' - } - }) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - 'account-1', - stagedCredentialsJson - ) - }) - - it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 91, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toBeNull() - }) - - it('refreshes and persists an expiring inactive account before fetching usage', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const credentialsPath = join(ownedAuthPath, '.credentials.json') - writeFileSync( - credentialsPath, - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }), - 'utf-8' - ) - - // First net.fetch call is the OAuth refresh (token endpoint); second is the - // usage fetch with the refreshed access token. - netFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }) - }) - netFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }) - }) - - const result = await fetchManagedAccountUsage({ - id: 'account-1', - managedAuthPath: ownedAuthPath - }) - - expect(result.status).toBe('ok') - // Rotated token persisted back to managed storage. - const persisted = JSON.parse(readFileSync(credentialsPath, 'utf-8')) - expect(persisted.claudeAiOauth.accessToken).toBe('fresh-access') - expect(persisted.claudeAiOauth.refreshToken).toBe('fresh-refresh') - // Usage fetch used the fresh access token. - const usageCall = netFetchMock.mock.calls.find(([url]) => - String(url).includes('/api/oauth/usage') - ) - expect(usageCall?.[1]?.headers?.Authorization).toBe('Bearer fresh-access') - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-test-harness.ts b/src/main/rate-limits/claude-fetcher-test-harness.ts deleted file mode 100644 index f679d8388337..000000000000 --- a/src/main/rate-limits/claude-fetcher-test-harness.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { vi } from 'vitest' -import type { Mock } from 'vitest' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' - -/** Electron/fs mocks each claude-fetcher test file declares via its own `vi.mock` factories. */ -export type ClaudeFetcherHoistedMocks = { - netFetchMock: Mock - readFileMock: Mock - resolveProxyMock: Mock - setProxyMock: Mock - appGetPathMock: Mock -} - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -export function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -export function restorePlatform(): void { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } -} - -/** Default happy-path state: darwin, no keychain credentials, OAuth usage 12/34, PTY session 56. */ -export function primeClaudeFetcherMocks(mocks: ClaudeFetcherHoistedMocks): void { - setPlatform('darwin') - vi.clearAllMocks() - mocks.readFileMock.mockRejectedValue(new Error('missing file')) - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) - vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() - vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() - vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() - mocks.appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') - mocks.resolveProxyMock.mockResolvedValue('DIRECT') - mocks.netFetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - five_hour: { utilization: 12 }, - seven_day: { utilization: 34 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValue({ - provider: 'claude', - session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1, - error: null, - status: 'ok' - }) -} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 42427766a26f..9b03a2eb9713 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,7 +1,9 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import type { InactiveClaudeAccount } from './claude-managed-account-credentials' -import { fetchInactiveClaudeAccountUsage } from './claude-managed-account-usage' +import { + fetchInactiveClaudeAccountUsage, + type InactiveClaudeAccount +} from './claude-managed-account-usage' import type { ClaudeManagedAccountUsageOptions, ClaudeRateLimitFetchOptions diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts deleted file mode 100644 index 71d505510cf1..000000000000 --- a/src/main/rate-limits/claude-managed-account-credentials.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { existsSync, lstatSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' -import { - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from '../claude-accounts/managed-auth-path' - -export type InactiveClaudeAccount = { - id: string - managedAuthPath: string - managedAuthRuntime?: 'host' | 'wsl' - wslDistro?: string | null - wslLinuxAuthPath?: string | null -} - -export type ClaudeManagedCredentialsLocation = - | { kind: 'keychain'; accountId: string; managedAuthPath: string } - | { kind: 'file'; managedAuthPath: string } - -export function resolveClaudeManagedCredentialsLocation( - account: InactiveClaudeAccount -): ClaudeManagedCredentialsLocation | null { - if (account.managedAuthRuntime === 'wsl') { - const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) - return managedAuthPath ? { kind: 'file', managedAuthPath } : null - } - const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) - if (!managedAuthPath) { - return null - } - return process.platform === 'darwin' - ? { kind: 'keychain', accountId: account.id, managedAuthPath } - : { kind: 'file', managedAuthPath } -} - -export async function readClaudeManagedCredentialsJson( - location: ClaudeManagedCredentialsLocation -): Promise { - try { - return location.kind === 'keychain' - ? await readManagedClaudeKeychainCredentials(location.accountId) - : readClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json') - } catch { - return null - } -} - -export async function writeClaudeManagedCredentialsJson( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string -): Promise { - if (location.kind === 'keychain') { - await writeManagedClaudeKeychainCredentials(location.accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(location.managedAuthPath, '.credentials.json', credentialsJson) - } -} - -function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): string | null { - if (process.platform !== 'win32') { - return null - } - const wslInfo = parseWslUncPath(account.managedAuthPath) - if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { - return null - } - const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath - if ( - !linuxPath.includes('/.local/share/orca/claude-accounts/') || - !linuxPath.endsWith(`/${account.id}/auth`) - ) { - return null - } - try { - const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - readFileSync(markerPath, 'utf-8').trim() !== account.id - ) { - return null - } - return account.managedAuthPath - } catch { - return null - } -} - -export async function withClaudeManagedPreviewKeychainCredentials( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string, - operation: () => Promise -): Promise { - if (location.kind !== 'keychain') { - return operation() - } - await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) - try { - return await operation() - } finally { - await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) - } -} - -export async function readStagedClaudeManagedPreviewCredentials( - location: ClaudeManagedCredentialsLocation -): Promise { - if (location.kind !== 'keychain') { - return null - } - try { - return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index ddf7b3df7508..e94265de7f6a 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -1,104 +1,66 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { - isOauthTokenExpiring, - refreshClaudeOauthCredentials -} from '../claude-accounts/oauth-refresh' -import { - readClaudeManagedCredentialsJson, - resolveClaudeManagedCredentialsLocation, - writeClaudeManagedCredentialsJson, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' -import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' -import { parseClaudeOAuthCredentialsJson } from './claude-oauth-credentials' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' + getClaudeProfileRouter, + getClaudeWslProfileRouter +} from '../claude-accounts/claude-profile-installed-router' +import { toWindowsWslPath } from '../../shared/wsl-paths' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - mergeClaudeUsageWindows, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' +import { claudeUsageUnavailable, makeClaudeUsageResult } from './claude-usage-result' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' -function noClaudeManagedCredentialsResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'No credentials', - status: 'error' - } +/** Usage reads the account's own folder; a host account's comes from its id, never a stored path. */ +export type InactiveClaudeAccount = { + id: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null } export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - const location = resolveClaudeManagedCredentialsLocation(account) - let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (!location || !credentialsJson) { - return noClaudeManagedCredentialsResult() - } - - let token = parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (isOauthTokenExpiring(credentialsJson)) { - const refreshed = await refreshClaudeOauthCredentials(credentialsJson) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (refreshed) { - try { - await writeClaudeManagedCredentialsJson(location, refreshed) - } catch { - // Keep the refreshed token for this fetch; a later poll can persist it. - } - credentialsJson = refreshed - token = parseClaudeOAuthCredentialsJson(refreshed, 'credentials-file').token - } + if (account.managedAuthRuntime !== 'wsl') { + // Why the router: a covered account's usage is System default's, as its launches are. + const authPreparation = getClaudeProfileRouter()?.accountUsagePreparation(account.id) + return authPreparation + ? fetchActiveClaudeRateLimits({ signal: options.signal, authPreparation }) + : signInAgain() } - - if (!token) { - return noClaudeManagedCredentialsResult() - } - const oauthLimits = await fetchClaudeOAuthUsage(token, options.signal) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() + const distro = account.wslDistro + const router = getClaudeWslProfileRouter() + if (!distro || !router) { + return signInAgain() } + // Why: a stopped distro parks a UNC read for minutes, and the inactive loop is sequential. + const guestRoot = toWindowsWslPath('/', distro) if ( - !canSupplementClaudeOAuthUsage({ - oauthLimits, - authPreparation: undefined, - allowUsagePanelSupplement: options.allowUsagePanelSupplement === true - }) + (await filterPathsToRunningWslDistrosAsync([guestRoot], { requireConfirmed: true })).length === + 0 ) { - return oauthLimits + return claudeUsageUnavailable() } - - try { - return mergeClaudeUsageWindows( - oauthLimits, - await fetchClaudeManagedUsagePanelSupplement({ - account, - location, - credentialsJson, - oauthLimits, - networkProxySettings: options.networkProxySettings, - signal: options.signal - }) + const timeout = AbortSignal.timeout(INACTIVE_WSL_READ_TIMEOUT_MS) + const read = router + .accountUsagePreparation(distro, account.id) + .then((authPreparation) => + fetchActiveClaudeRateLimits({ signal: options.signal, authPreparation }) ) - } catch (error) { - warnClaudeUsageFetchFailure( - undefined, - parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file'), - error + .catch(() => claudeUsageUnavailable()) + return Promise.race([ + read, + new Promise((resolve) => + timeout.addEventListener('abort', () => resolve(claudeUsageUnavailable()), { once: true }) ) - return oauthLimits - } + ]) } + +function signInAgain(): ProviderRateLimits { + return makeClaudeUsageResult('error', 'Sign in again to use this account.', { + failureKind: 'missing-credentials', + attemptedSources: [] + }) +} + +const INACTIVE_WSL_READ_TIMEOUT_MS = 10_000 diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts deleted file mode 100644 index 15c1e90169c7..000000000000 --- a/src/main/rate-limits/claude-managed-usage-panel.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import { - readStagedClaudeManagedPreviewCredentials, - withClaudeManagedPreviewKeychainCredentials, - writeClaudeManagedCredentialsJson, - type ClaudeManagedCredentialsLocation, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' - -function getManagedUsagePanelAuthPreparation( - account: InactiveClaudeAccount, - location: ClaudeManagedCredentialsLocation -): ClaudeRuntimeAuthPreparation | null { - if (process.platform === 'win32') { - return null - } - if (account.managedAuthRuntime === 'wsl') { - if (!account.wslLinuxAuthPath || !account.wslDistro) { - return null - } - return { - configDir: location.managedAuthPath, - runtime: 'wsl', - wslDistro: account.wslDistro, - wslLinuxConfigDir: account.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } - } - return { - configDir: location.managedAuthPath, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } -} - -function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { - return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) -} - -function canTrustManagedUsagePanelSupplement( - oauthLimits: ProviderRateLimits, - cliLimits: ProviderRateLimits, - requireMatchingOAuthWindow: boolean -): boolean { - if (!requireMatchingOAuthWindow) { - return true - } - const sharedWindowMatches = [ - oauthLimits.session && cliLimits.session - ? windowsAgree(oauthLimits.session, cliLimits.session) - : null, - oauthLimits.weekly && cliLimits.weekly - ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) - : null - ].filter((match): match is boolean => match !== null) - // Why: older Claude builds may ignore the scoped Keychain and expose the active account. - return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) -} - -export async function fetchClaudeManagedUsagePanelSupplement(input: { - account: InactiveClaudeAccount - location: ClaudeManagedCredentialsLocation - credentialsJson: string - oauthLimits: ProviderRateLimits - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted) { - return null - } - const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) - if (!authPreparation) { - return null - } - - return withClaudeManagedPreviewKeychainCredentials( - input.location, - input.credentialsJson, - async () => { - const cliLimits = await fetchViaPty({ - authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - if (input.signal?.aborted) { - return null - } - if ( - !canTrustManagedUsagePanelSupplement( - input.oauthLimits, - cliLimits, - input.location.kind === 'keychain' - ) - ) { - return null - } - const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) - if (refreshed && refreshed !== input.credentialsJson) { - await writeClaudeManagedCredentialsJson(input.location, refreshed) - } - return cliLimits - } - ) -} diff --git a/src/main/rate-limits/claude-oauth-credentials.ts b/src/main/rate-limits/claude-oauth-credentials.ts index 3e14fb2909e7..7fa4d04fbb92 100644 --- a/src/main/rate-limits/claude-oauth-credentials.ts +++ b/src/main/rate-limits/claude-oauth-credentials.ts @@ -1,160 +1,137 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -type ClaudeCredentials = { - claudeAiOauth?: { - accessToken?: string - refreshToken?: string - expiresAt?: number - } -} +import { z } from 'zod' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' +import { readUserClaudeConfigDir } from '../claude-accounts/claude-profile-paths' +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' export type ClaudeOAuthCredentialSource = | 'scoped-keychain' | 'legacy-keychain' | 'credentials-file' | 'none' - export type ClaudeOAuthCredentialReadResult = { token: string | null hasRefreshableCredentials: boolean source: ClaudeOAuthCredentialSource keychainUnavailable?: boolean + unavailable?: boolean } - type ClaudeOAuthCredentialReadOptions = { credentialsFileConfigDir?: string keychainConfigDir?: string + /** System Default only: a managed profile never reads another login's item. */ + unsuffixedKeychainFallback?: boolean } - +const schema = z.object({ + claudeAiOauth: z + .object({ + accessToken: z.string().optional(), + refreshToken: z.string().optional() + }) + .optional() +}) export function parseClaudeOAuthCredentialsJson( raw: string, source: ClaudeOAuthCredentialSource ): ClaudeOAuthCredentialReadResult { try { - const oauth = (JSON.parse(raw) as ClaudeCredentials)?.claudeAiOauth - const hasRefreshableCredentials = - typeof oauth?.refreshToken === 'string' && oauth.refreshToken.trim() !== '' - if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') { - return { token: null, hasRefreshableCredentials, source } - } + const oauth = schema.parse(JSON.parse(raw)).claudeAiOauth // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. - return { token: oauth.accessToken, hasRefreshableCredentials, source } + return { + token: oauth?.accessToken || null, + hasRefreshableCredentials: Boolean(oauth?.refreshToken), + source + } } catch { - return emptyClaudeOAuthCredentialReadResult() + return { ...emptyClaudeOAuthCredentialReadResult(), unavailable: true } } } - export function emptyClaudeOAuthCredentialReadResult(): ClaudeOAuthCredentialReadResult { return { token: null, hasRefreshableCredentials: false, source: 'none' } } - -function unavailableKeychainResult(): ClaudeOAuthCredentialReadResult { - return { - token: null, - hasRefreshableCredentials: false, - source: 'none', - keychainUnavailable: true - } -} - -async function readFromKeychain(configDir?: string): Promise { - if (process.platform !== 'darwin') { - return emptyClaudeOAuthCredentialReadResult() - } - - if (configDir) { - const scoped = await readClaudeCredentialsFromStrictKeychain(configDir, 'scoped-keychain') - if (scoped.token) { - return scoped - } - - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - // Why: a real access token must not be shadowed by scoped refresh-only credentials. - if (legacy.token) { - return legacy - } - if (scoped.hasRefreshableCredentials) { - return scoped - } - if (legacy.hasRefreshableCredentials) { - return legacy - } - return scoped.keychainUnavailable || legacy.keychainUnavailable - ? unavailableKeychainResult() - : legacy - } - - try { - const credentials = await readActiveClaudeKeychainCredentials(configDir) - return credentials - ? parseClaudeOAuthCredentialsJson(credentials, 'legacy-keychain') - : emptyClaudeOAuthCredentialReadResult() - } catch { - return unavailableKeychainResult() - } -} - export async function readClaudeCredentialsFromStrictKeychain( configDir: string | undefined, source: ClaudeOAuthCredentialSource ): Promise { try { - const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) - return credentials - ? parseClaudeOAuthCredentialsJson(credentials, source) + const contents = await readActiveClaudeKeychainCredentialsStrict(configDir) + return contents + ? parseClaudeOAuthCredentialsJson(contents, source) : emptyClaudeOAuthCredentialReadResult() } catch { - return unavailableKeychainResult() + return { + ...emptyClaudeOAuthCredentialReadResult(), + unavailable: true, + keychainUnavailable: true + } } } - -async function readFromCredentialsFile( - configDir?: string +async function readClaudeKeychainCredentials( + options?: ClaudeOAuthCredentialReadOptions ): Promise { - const credentialPath = path.join( - configDir ?? path.join(homedir(), '.claude'), - '.credentials.json' + if (!options?.keychainConfigDir) { + return readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + } + const scoped = await readClaudeCredentialsFromStrictKeychain( + options.keychainConfigDir, + 'scoped-keychain' ) - try { - return parseClaudeOAuthCredentialsJson( - await readFile(credentialPath, 'utf-8'), - 'credentials-file' - ) - } catch { - return emptyClaudeOAuthCredentialReadResult() + if (scoped.token || !options.unsuffixedKeychainFallback) { + return scoped } + const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') + // Why: as before profiles, a token wins over an unreadable item. + const candidates = [scoped, legacy] + return ( + candidates.find((candidate) => candidate.token) ?? + candidates.find((candidate) => candidate.unavailable) ?? + legacy + ) } - export async function readClaudeOAuthCredentials( options?: ClaudeOAuthCredentialReadOptions ): Promise { - const keychain = await readFromKeychain(options?.keychainConfigDir) - if (keychain.token || keychain.hasRefreshableCredentials) { + const keychain = + process.platform === 'darwin' + ? await readClaudeKeychainCredentials(options) + : emptyClaudeOAuthCredentialReadResult() + if (keychain.token) { return keychain } - - const file = await readFromCredentialsFile(options?.credentialsFileConfigDir) - if (file.token || file.hasRefreshableCredentials) { - return file + try { + return parseClaudeOAuthCredentialsJson( + await readFile( + path.join( + options?.credentialsFileConfigDir ?? path.join(homedir(), '.claude'), + '.credentials.json' + ), + 'utf8' + ), + 'credentials-file' + ) + } catch (error) { + return isDefinitiveAbsence(error) ? keychain : { ...keychain, unavailable: true } } - return keychain.keychainUnavailable ? keychain : emptyClaudeOAuthCredentialReadResult() } - export function resolveClaudeOAuthCredentialReadOptions( authPreparation?: ClaudeRuntimeAuthPreparation ): ClaudeOAuthCredentialReadOptions | undefined { if (!authPreparation) { return undefined } + const accountDir = authPreparation.envPatch.CLAUDE_CONFIG_DIR return { credentialsFileConfigDir: authPreparation.configDir, - keychainConfigDir: authPreparation.configDir + // Why the user's own folder, login shell first: Claude names System default's item from it. + keychainConfigDir: + accountDir ?? + getClaudeProfileRouter()?.userConfigDir() ?? + readUserClaudeConfigDir(process.env), + // An unsuffixed lookup is exclusively System default, never a fallback from an account. + unsuffixedKeychainFallback: !accountDir } } diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts deleted file mode 100644 index 58c6bf91cb37..000000000000 --- a/src/main/rate-limits/claude-oauth-recovery.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' -import { - readClaudeCredentialsFromStrictKeychain, - readClaudeOAuthCredentials, - resolveClaudeOAuthCredentialReadOptions, - type ClaudeOAuthCredentialReadResult -} from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -import type { ClaudeUsageErrorClassification } from './claude-usage-error-classification' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - isManagedClaudeAuth, - makeClaudeUsageResult, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -const LIVE_REFRESH_DEFERRED_MESSAGE = - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' - -export function canRetryClaudeOAuthWithLegacyKeychain(input: { - classification: ClaudeUsageErrorClassification - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): boolean { - return ( - input.classification.failureKind === 'stale-token' && - input.oauthCredentials.source === 'scoped-keychain' && - (input.authPreparation?.runtime ?? 'host') === 'host' && - !isManagedClaudeAuth(input.authPreparation) - ) -} - -export async function retryClaudeOAuthWithLegacyKeychain(input: { - failedToken: string | null - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): Promise { - const legacy = await readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - if (!legacy.token || legacy.token === input.failedToken) { - return null - } - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - try { - const limits = await fetchClaudeOAuthUsage(legacy.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits: limits, - oauthCredentials: legacy, - attempts: input.attempts, - options: input.options - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, legacy, error) - return null - } -} - -export function shouldDeferClaudeUsageForLiveSession( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - classification: ClaudeUsageErrorClassification -): boolean { - return Boolean( - authPreparation?.managedRefreshDeferredByLivePty && - (classification.failureKind === 'stale-token' || - classification.failureKind === 'refreshable-credentials-without-token' || - classification.failureKind === 'deferred-by-live-session') - ) -} - -export function makeLiveClaudeUsageDeferredResult(input: { - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): ProviderRateLimits { - return makeClaudeUsageResult('error', LIVE_REFRESH_DEFERRED_MESSAGE, { - ...metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true - }) - }) -} - -export function makeClaudeUsageClassificationError(input: { - error: unknown - classification: ClaudeUsageErrorClassification - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation -}): ProviderRateLimits { - const message = - input.error instanceof Error ? input.error.message : String(input.error || 'Unknown error') - const retryAfterMs = input.error instanceof OAuthUsageError ? input.error.retryAfterMs : null - return makeClaudeUsageResult('error', withMacTailscaleDnsHint(message), { - ...metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - failureKind: input.classification.failureKind, - retryAtMs: retryAfterMs ? Date.now() + retryAfterMs : undefined - }) - }) -} - -export async function repairClaudeCredentialsThenRetryOAuth(input: { - options?: ClaudeRateLimitFetchOptions - attempts: ClaudeUsageAttemptState - oauthCredentials: ClaudeOAuthCredentialReadResult -}): Promise { - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - let cliResult: ProviderRateLimits | null = null - try { - cliResult = await fetchClaudeUsageViaCli({ - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - signal: input.options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, error) - } - - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - const refreshed = await readClaudeOAuthCredentials( - resolveClaudeOAuthCredentialReadOptions(input.options?.authPreparation) - ) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if (refreshed.token) { - recordClaudeUsageAttempt(input.attempts, 'oauth') - try { - const retry = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - mergeClaudeUsageWindows(retry, cliResult), - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: refreshed, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, refreshed, error) - } - } - return cliResult -} diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts similarity index 66% rename from src/main/rate-limits/claude-fetcher-extra-usage.test.ts rename to src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts index fb56512bd8c6..9911343080c4 100644 --- a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts +++ b/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts @@ -1,94 +1,28 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() + net: { fetch: netFetchMock }, + session: { defaultSession: {} } })) -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn(async () => {}) })) -function oauthPrep(): ClaudeRuntimeAuthPreparation { - return { - configDir: '/Users/test/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } -} - async function fetchUsageResponse(body: string) { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } - }) - ) netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) - return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) + return fetchClaudeOAuthUsage('oauth-token') } -describe('fetchClaudeRateLimits extra usage', () => { +describe('Claude OAuth usage extra usage', () => { beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() + vi.clearAllMocks() }) it('maps the usage-credits spend object into a capped balance in major units', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -105,7 +39,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -122,14 +56,6 @@ describe('fetchClaudeRateLimits extra usage', () => { }) it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -147,7 +73,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -163,14 +89,6 @@ describe('fetchClaudeRateLimits extra usage', () => { }) it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) netFetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -188,7 +106,7 @@ describe('fetchClaudeRateLimits extra usage', () => { ) ) - await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts new file mode 100644 index 000000000000..a87ddf4c9299 --- /dev/null +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -0,0 +1,278 @@ +import type * as Keychain from '../claude-accounts/keychain' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() })) +vi.mock('../macos-keychain/generic-password', () => ({ + readKeychainPassword: calls.keychainService +})) +vi.mock('../claude-accounts/keychain', () => ({ + readActiveClaudeKeychainCredentialsStrict: calls.keychain +})) +const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +const wslRouter = vi.hoisted(() => ({ accountUsagePreparation: vi.fn() })) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined, + getClaudeWslProfileRouter: () => wslRouter +})) +vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage })) +const runningDistros = vi.hoisted(() => ({ + filter: vi.fn(async (paths: readonly string[]) => [...paths]) +})) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: runningDistros.filter +})) +vi.mock('../../shared/child-process/run-process', () => ({ + spawnProcess: () => { + throw new Error('Usage must not launch a process') + }, + runProcess: () => { + throw new Error('Usage must not launch a process') + } +})) +import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' +import { + readClaudeOAuthCredentials, + resolveClaudeOAuthCredentialReadOptions +} from './claude-oauth-credentials' +import { OAuthUsageError } from './claude-oauth-usage-error' +import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +const roots: string[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) + vi.unstubAllGlobals() + vi.clearAllMocks() +}) +function profile() { + const home = mkdtempSync(join(tmpdir(), 'claude-usage-')) + roots.push(home) + calls.keychain.mockResolvedValue(null) + const options = { + authPreparation: { + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + provenance: 'profile:fake' + } + } + return { home, options } +} +function systemDefault() { + const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-')) + roots.push(home) + calls.keychain.mockResolvedValue(null) + return { + home, + options: { + authPreparation: { configDir: home, envPatch: {}, provenance: 'system' } + } + } +} +it('lets the server decide a locally expired token and never refreshes it', async () => { + const f = profile() + const file = join(f.home, '.credentials.json') + writeFileSync( + file, + JSON.stringify({ + claudeAiOauth: { accessToken: 'expired-fake', refreshToken: 'must-not-be-used', expiresAt: 1 } + }) + ) + vi.stubGlobal('fetch', () => { + throw new Error('No refresh endpoint') + }) + calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null)) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'error', + error: 'Claude usage has expired. Start Claude in this account to refresh it.', + usageMetadata: { failureKind: 'stale-token' } + }) + expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake']) + const system = systemDefault() + writeFileSync( + join(system.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'expired-default', expiresAt: 1 } }) + ) + calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ + error: 'Claude usage updates the next time Claude runs.', + usageMetadata: { failureKind: 'stale-token' } + }) +}) +it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { + const f = profile() + await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) + if (process.platform === 'darwin') { + expect(calls.keychain.mock.calls).toEqual([[f.home]]) + } else { + expect(calls.keychain).not.toHaveBeenCalled() + } +}) +it('distinguishes missing, malformed and inaccessible credential observations', async () => { + const f = profile() + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'missing-credentials' } + }) + writeFileSync(join(f.home, '.credentials.json'), '{') + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'keychain-unavailable' } + }) +}) +it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => { + const f = profile() + expect( + await fetchActiveClaudeRateLimits({ + authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE } + }) + ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }) + expect(calls.keychain).not.toHaveBeenCalled() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) + ) + calls.usage.mockRejectedValue(new Error('network unavailable')) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' }) + expect(calls.usage).toHaveBeenCalledTimes(1) +}) + +it('strict Keychain lookup never falls back to the unsuffixed service', async () => { + const f = profile() + calls.keychainService.mockResolvedValue(null) + const actual = await vi.importActual('../claude-accounts/keychain') + await actual.readActiveClaudeKeychainCredentialsStrict(f.home) + expect(calls.keychainService).toHaveBeenCalled() + for (const [service] of calls.keychainService.mock.calls) { + expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/) + } + calls.keychainService.mockClear() + calls.keychainService.mockRejectedValue(new Error('locked')) + await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked') + expect(calls.keychainService).toHaveBeenCalledTimes(1) +}) +it('treats an unreadable credential file as unavailable, not a missing login', async () => { + const f = profile() + mkdirSync(join(f.home, '.credentials.json')) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + usageMetadata: { failureKind: 'keychain-unavailable' } + }) +}) +it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => { + const f = systemDefault() + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'unavailable', + usageMetadata: { failureKind: 'missing-credentials' } + }) + expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({ + status: 'error', + error: 'Sign in again to use this account.' + }) +}) +it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => { + const f = profile() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } }) + ) + const message = 'Claude usage is rate limited right now.' + calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000)) + const before = Date.now() + const limited = await fetchActiveClaudeRateLimits(f.options) + expect(limited).toMatchObject({ + status: 'error', + error: message, + usageMetadata: { failureKind: 'rate-limited' } + }) + expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000) + expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000) + calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null)) + expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined() +}) +it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => { + const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-')) + roots.push(root) + const inherited = join(root, 'own-claude-config') + vi.stubEnv('CLAUDE_CONFIG_DIR', inherited) + try { + calls.keychain.mockResolvedValue(null) + await readClaudeOAuthCredentials( + resolveClaudeOAuthCredentialReadOptions({ + configDir: inherited, + envPatch: {}, + provenance: 'system' + }) + ) + expect(calls.keychain.mock.calls).toEqual( + process.platform === 'darwin' ? [[inherited], [undefined]] : [] + ) + } finally { + vi.unstubAllEnvs() + } + const managed = profile() + calls.keychain.mockClear() + await fetchActiveClaudeRateLimits(managed.options) + expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : []) +}) +it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => { + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + profileRouter.userConfigDir = '/shell/claude-config' + try { + expect( + resolveClaudeOAuthCredentialReadOptions({ + configDir: '/shell/claude-config', + envPatch: {}, + provenance: 'system' + })?.keychainConfigDir + ).toBe('/shell/claude-config') + } finally { + profileRouter.userConfigDir = undefined + vi.unstubAllEnvs() + } +}) +it('reports a host problem as unavailable usage, never as a signed-out account', async () => { + const f = profile() + expect( + await fetchActiveClaudeRateLimits({ + authPreparation: { + ...f.options.authPreparation, + usageError: 'Could not read the home folder of WSL distro Ubuntu.' + } + }) + ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } }) +}) +it('reads an inactive WSL account where the WSL router says its launches would run', async () => { + const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') + const authPreparation = { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', + runtime: 'wsl' as const, + wslDistro: 'Ubuntu', + envPatch: {}, + provenance: 'wsl:Ubuntu:system' + } + wslRouter.accountUsagePreparation.mockResolvedValueOnce(authPreparation) + calls.usage.mockResolvedValueOnce({ five_hour: null, seven_day: null }) + await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' + }) + expect(wslRouter.accountUsagePreparation).toHaveBeenCalledWith('Ubuntu', 'acct') +}) +it('does not read an inactive WSL account through a stopped distro', async () => { + const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') + runningDistros.filter.mockResolvedValueOnce([]) + wslRouter.accountUsagePreparation.mockClear() + const result = await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' + }) + expect(runningDistros.filter).toHaveBeenCalledWith(['\\\\wsl.localhost\\Ubuntu\\'], { + requireConfirmed: true + }) + expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) + expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled() + expect(calls.keychain).not.toHaveBeenCalled() +}) diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts deleted file mode 100644 index 43137a8cea54..000000000000 --- a/src/main/rate-limits/claude-pty-reset-parser.ts +++ /dev/null @@ -1,307 +0,0 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' -import { buildWallClockTimestamp } from './time-zone-wall-clock' - -const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i -const MONTH_PATTERN = - 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?' -const MONTH_DAY_COMPACT_RE = new RegExp( - `\\b(${MONTH_PATTERN})(\\d{1,2})(?=\\s*at\\s*\\d|\\D|$)`, - 'i' -) -const MONTH_DAY_TIME_RE = new RegExp( - `\\b(${MONTH_PATTERN})\\.?\\s+(\\d{1,2})(?:,?\\s*(?:at\\s+)?)?(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)\\b`, - 'i' -) -const WEEKDAY_TIME_RE = - /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. -const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ -const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') -const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i -const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi -const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ - -export type ClaudePtyResetMetadata = Pick - -const MONTH_INDEX_BY_NAME: Record = { - jan: 0, - january: 0, - feb: 1, - february: 1, - mar: 2, - march: 2, - apr: 3, - april: 3, - may: 4, - jun: 5, - june: 5, - jul: 6, - july: 6, - aug: 7, - august: 7, - sep: 8, - sept: 8, - september: 8, - oct: 9, - october: 9, - nov: 10, - november: 10, - dec: 11, - december: 11 -} - -const WEEKDAY_INDEX_BY_NAME: Record = { - sun: 0, - sunday: 0, - mon: 1, - monday: 1, - tue: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6 -} - -export function extractClaudePtyResetMetadata( - lines: string[], - matchesLabel: (line: string) => boolean, - isSectionLabel: (line: string) => boolean -): ClaudePtyResetMetadata { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - for (let j = i; j < Math.min(i + 14, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = RESET_LINE_RE.exec(lines[j]) - if (m) { - const resetDescription = normalizeResetDescription(m[1]) - return { - resetsAt: parseResetTimestamp(resetDescription), - resetDescription - } - } - } - } - return { resetsAt: null, resetDescription: null } -} - -function normalizeResetDescription(raw: string): string { - // Why: Claude's TUI occasionally drops spaces around the Fable reset date - // when copied from the PTY buffer, but the value still encodes a real reset. - return ( - raw - .trim() - // Why: PTY captures keep trailing box-border glyphs from framed status panels. - .replace(/[)\s│]+$/, '') - .replace(/\s+/g, ' ') - .replace(MONTH_DAY_COMPACT_RE, '$1 $2') - .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') - .replace(/(\d)(am|pm)\(/gi, '$1$2 (') - ) -} - -function parseResetTimestamp(resetDescription: string | null): number | null { - if (!resetDescription) { - return null - } - - return ( - parseRelativeResetTimestamp(resetDescription) ?? - parseMonthDayResetTimestamp(resetDescription) ?? - parseWeekdayResetTimestamp(resetDescription) ?? - parseTimeOnlyResetTimestamp(resetDescription) ?? - parseTwentyFourHourResetTimestamp(resetDescription) - ) -} - -function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const timeMatch = TIME_24H_RE.exec(resetText) - if (!timeMatch) { - return null - } - const hour = Number(timeMatch[1]) - const minute = Number(timeMatch[2]) - if (!isValidClockTime(hour, minute)) { - return null - } - - const dayMonthMatch = DAY_MONTH_RE.exec(resetText) - if (dayMonthMatch) { - const day = Number(dayMonthMatch[1]) - const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] - if (monthIndex === undefined || day < 1 || day > 31) { - return null - } - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseRelativeResetTimestamp(resetDescription: string): number | null { - if (!RELATIVE_RESET_RE.test(resetDescription)) { - return null - } - - let durationMs = 0 - for (const match of resetDescription.matchAll(RELATIVE_RESET_TOKEN_RE)) { - const amount = Number(match[1]) - const unit = match[2].toLowerCase()[0] - if (!Number.isFinite(amount)) { - continue - } - if (unit === 'd') { - durationMs += amount * 24 * 60 * 60_000 - } else if (unit === 'h') { - durationMs += amount * 60 * 60_000 - } else if (unit === 'm') { - durationMs += amount * 60_000 - } - } - return durationMs > 0 ? Date.now() + durationMs : null -} - -function parseMonthDayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = MONTH_DAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const monthIndex = MONTH_INDEX_BY_NAME[match[1].toLowerCase()] - if (monthIndex === undefined) { - return null - } - - const day = Number(match[2]) - const hour = parseHour(match[3], match[5]) - const minute = Number(match[4] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp -} - -function parseWeekdayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = WEEKDAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const weekdayIndex = WEEKDAY_INDEX_BY_NAME[match[1].toLowerCase()] - if (weekdayIndex === undefined) { - return null - } - - const now = new Date() - const hour = parseHour(match[2], match[4]) - const minute = Number(match[3] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date(now) - const daysUntil = (weekdayIndex - now.getDay() + 7) % 7 - candidate.setDate(now.getDate() + daysUntil) - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 7) - } - return candidate.getTime() -} - -function parseTimeOnlyResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = TIME_ONLY_RE.exec(resetText) - if (!match) { - return null - } - - const hour = parseHour(match[1], match[3]) - const minute = Number(match[2] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseHour(hourText: string, periodText: string): number { - const hour = Number(hourText) - const period = periodText.toLowerCase() - if (hour < 1 || hour > 12) { - return Number.NaN - } - if (period === 'am') { - return hour === 12 ? 0 : hour - } - return hour === 12 ? 12 : hour + 12 -} - -function isValidClockTime(hour: number, minute: number): boolean { - return Number.isInteger(hour) && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 -} - -function stripResetTimeZone(resetDescription: string): string { - return resetDescription.replace(/\s*\([^)]*\)?\s*$/, '').trim() -} - -function extractResetTimeZone(resetDescription: string): string | null { - const match = IANA_TIME_ZONE_RE.exec(resetDescription) - if (!match?.[1]) { - return null - } - const timeZone = match[1].trim() - try { - new Intl.DateTimeFormat('en-US', { timeZone }) - return timeZone - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts deleted file mode 100644 index 2bba56c69112..000000000000 --- a/src/main/rate-limits/claude-pty-stop-markers.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Why: these substrings indicate the /usage TUI panel has finished rendering. -export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ - 'Current week (all models)', - 'Current week (Opus)', - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - 'Weekly limits', - 'Weekly limit', - 'Weekly usage', - '7-day', - 'Current session', - 'Failed to load usage data', - 'failed to load usage data' -] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts deleted file mode 100644 index 3328d9471600..000000000000 --- a/src/main/rate-limits/claude-pty-usage-parser.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' - -// Why: these patterns match the Claude CLI's /usage TUI panel output. -// "Current session" shows a percent like "62% used" or "62% left". -// Weekly labels have varied between "Current week" and "Weekly limits". -const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_WORD_RE = /\bfable\b/i -const FABLE_LABEL_RE = /^\s*fable\s*$/i -// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place -// instead of silently reopening the Fable-weekly parsing gap this fix closed. -const FABLE_WEEKLY_LABEL_RE = new RegExp( - `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, - 'i' -) -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') -const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') - -export function stripTerminalControlSequences(output: string): string { - return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') -} - -/** - * Extract percent-left from lines following a label match. - * Scans up to 12 lines after the label to find the associated percent. - */ -function matchesWeeklyLabel(line: string): boolean { - return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) -} - -function matchesFableBoundary(line: string): boolean { - return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) -} - -function matchesFableUsageLabel(line: string): boolean { - // Why: broad Fable-weekly copy should stop nearby scans, but only a real - // Fable usage heading should produce the distinct Fable meter. - return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) -} - -function isSectionLabel(line: string): boolean { - return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) -} - -function extractPercentAfterLabel( - lines: string[], - matchesLabel: (line: string) => boolean -): number | null { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - // Scan next 12 lines for a percent - for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = PERCENT_RE.exec(lines[j]) - if (m) { - const pct = Number.parseFloat(m[1]) - const word = m[2].toLowerCase() - const isUsed = word === 'used' || word === 'consumed' - return isUsed ? pct : 100 - pct - } - } - } - return null -} - -export function parseClaudePtyUsage(output: string): { - session: RateLimitWindow | null - weekly: RateLimitWindow | null - fableWeekly: RateLimitWindow | null -} { - const lines = output.split(/\r\n|\n|\r/) - - const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) - const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => SESSION_RE.test(line), - isSectionLabel - ) - const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) - const fableWeeklyReset = extractClaudePtyResetMetadata( - lines, - matchesFableUsageLabel, - isSectionLabel - ) - - const session: RateLimitWindow | null = - sessionPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, sessionPct)), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null - - const weekly: RateLimitWindow | null = - weeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, weeklyPct)), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - - const fableWeekly: RateLimitWindow | null = - fableWeeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), - windowMinutes: 10080, - resetsAt: fableWeeklyReset.resetsAt, - resetDescription: fableWeeklyReset.resetDescription - } - : null - - return { session, weekly, fableWeekly } -} - -const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i -const LOAD_FAILED_RE = /failed to load usage data/i -const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i -const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i - -export function isClaude21UsagePanel(output: string): boolean { - return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) -} - -export function describeClaudeUsageFailure(output: string): string { - if (RATE_LIMITED_RE.test(output)) { - return 'Claude usage is rate limited right now.' - } - - if (LOAD_FAILED_RE.test(output)) { - return 'Claude usage is unavailable right now.' - } - - if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { - return 'Claude plan usage is unavailable for this Claude CLI session.' - } - - // Why: parser failures are an implementation detail of Orca's PTY fallback. - // The UI should explain the user-visible outcome, not leak internal parsing - // mechanics that the user cannot act on. - return 'Claude usage is unavailable right now.' -} - -export function abortedClaudeUsageResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Rate-limit fetch aborted', - status: 'error' - } -} diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts deleted file mode 100644 index f2fd976821b6..000000000000 --- a/src/main/rate-limits/claude-pty.test.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { resolveClaudeCommandMock, spawnMock } = vi.hoisted(() => ({ - resolveClaudeCommandMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: resolveClaudeCommandMock -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -import { fetchViaPty } from './claude-pty' -import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' - -function makeDisposable() { - return { dispose: vi.fn() } -} - -type MockTerm = { - onData: ReturnType - onExit: ReturnType - write: ReturnType - kill: ReturnType -} - -function makeMockTerm(): MockTerm & { - emitData: (data: string) => void - emitExit: () => void -} { - let dataHandler: ((data: string) => void) | null = null - let exitHandler: (() => void) | null = null - return { - onData: vi.fn((handler: (data: string) => void) => { - dataHandler = handler - return makeDisposable() - }), - onExit: vi.fn((handler: () => void) => { - exitHandler = handler - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data), - emitExit: () => exitHandler?.() - } -} - -describe('fetchViaPty', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveClaudeCommandMock.mockReturnValue('claude') - }) - - it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { - const onDataDisposable = makeDisposable() - const onExitDisposable = makeDisposable() - const killMock = vi.fn() - - spawnMock.mockReturnValue({ - onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), - write: vi.fn(), - kill: killMock - }) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(25_000) - await resultPromise - - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - }) - - it('injects the configured proxy into the hidden PTY spawn env', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' } - }) - await vi.advanceTimersByTimeAsync(0) - - expect(spawnMock).toHaveBeenCalled() - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe('http://127.0.0.1:7890') - expect(spawnEnv.HTTP_PROXY).toBe('http://127.0.0.1:7890') - - term.emitExit() - await resultPromise - }) - - it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string - expect(spawnCwd).toContain('rate-limit-pty-cwd') - expect(spawnCwd).not.toBe('/') - expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) - - term.emitExit() - await resultPromise - }) - - it('leaves the spawn env proxy untouched when no proxy is configured', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const inheritedHttpsProxy = process.env.HTTPS_PROXY - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe(inheritedHttpsProxy) - - term.emitExit() - await resultPromise - }) - - it('exports the configured proxy inside the WSL launch command', async () => { - // Why: Windows-side spawn env does not cross into the distro, so the proxy - // must be exported in the bash command for the inner claude to see it. - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' }, - authPreparation: { - configDir: '/home/u/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/u/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - }) - await vi.advanceTimersByTimeAsync(0) - - const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] - expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") - expect(bashCommand).toContain('exec claude') - - term.emitExit() - await resultPromise - }) - - it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const controller = new AbortController() - const killMock = term.kill - - const resultPromise = fetchViaPty({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(0) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - controller.abort() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(killMock).toHaveBeenCalledTimes(1) - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('clears the startup delay timer when the hidden PTY exits early', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - expect(spawnMock).toHaveBeenCalled() - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears pending settle timers when the hidden PTY exits after usage output starts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(2_000) - term.emitData('Current session\r12% used\r') - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - } - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - expect(term.write).toHaveBeenCalledWith('/usage\r') - - term.emitData(` - Settings Status Config Usage Stats - - Session - Total cost: $0.0000 - Usage: 0 input, 0 output, 0 cache read, 0 cache write - `) - - await vi.advanceTimersByTimeAsync(8_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - session: null, - weekly: null, - error: 'Claude plan usage is unavailable for this Claude CLI session.' - }) - expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') - }) - - it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Settings Status Config Usage Stats - Session - Total cost: $0.0000 - `) - - await vi.advanceTimersByTimeAsync(1_000) - term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12, - resetDescription: '4:00pm' - }, - weekly: { - usedPercent: 34 - }, - error: null - }) - }) - - it('parses the newer Claude weekly limits wording for Fable usage', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Weekly limits - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - const result = await resultPromise - - expect(result).toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetsAt: Date.now() + 2 * 60 * 60_000 + 10 * 60_000, - resetDescription: '2h 10m' - }, - weekly: null, - fableWeekly: { - usedPercent: 42, - resetsAt: Date.now() + 3 * 24 * 60 * 60_000 + 2 * 60 * 60_000, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses generic weekly and Fable weekly limits as separate windows', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Current week (all models) - 84% left - Resets in 5d 4h - - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetDescription: '2h 10m' - }, - weekly: { - usedPercent: 16, - resetDescription: '5d 4h' - }, - fableWeekly: { - usedPercent: 42, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses Claude current-week Fable usage as a distinct weekly window', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 8% used - Resets 3:39am - - Current week (all models) - 33% used - Resets Jul 10 at 12:59pm - - Current week (Fable) - 62% used - Resets Jul10at12:59pm(America/Los_Angeles) - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 8, - resetDescription: '3:39am' - }, - weekly: { - usedPercent: 33, - resetsAt: new Date(2026, 6, 10, 12, 59).getTime(), - resetDescription: 'Jul 10 at 12:59pm' - }, - fableWeekly: { - usedPercent: 62, - resetsAt: Date.parse('2026-07-10T19:59:00.000Z'), - resetDescription: 'Jul 10 at 12:59pm (America/Los_Angeles' - }, - error: null - }) - }) - - it('does not let an incomplete Fable section consume later usage sections', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - Usage unavailable - - Current session - 12% used - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('does not treat inline Fable weekly text as a parsed usage label', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - Fable weekly usage - 42% consumed - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: null, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('keeps waiting after a bare Fable heading until another usage section renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - let settled = false - - const resultPromise = fetchViaPty().finally(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - `) - await vi.advanceTimersByTimeAsync(2_000) - - expect(settled).toBe(false) - - term.emitData(` - 42% consumed - - Current session - 12% used - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - fableWeekly: { - usedPercent: 42 - }, - error: null - }) - }) - - it('parses 7-day weekly labels without the old Current week heading', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Usage - - Current session - 12% used - - 7-day - 84% left - Resets Wed at 9:05 PM - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16, - resetDescription: 'Wed at 9:05 PM' - }, - error: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts deleted file mode 100644 index cbaedbd17f9d..000000000000 --- a/src/main/rate-limits/claude-pty.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { resolveClaudeCommand } from '../codex-cli/command' -// Why: import from the shared module, not the codex-cli re-export, so a test that -// mocks '../codex-cli/command' does not have to restate this pure helper. -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { applyClaudeEnvPatch } from '../claude-accounts/environment' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import { - getHiddenRateLimitWslCwdSetupCommands, - resolveHiddenRateLimitPtyCwd -} from './hidden-rate-limit-pty-cwd' -import { - abortedClaudeUsageResult, - describeClaudeUsageFailure, - isClaude21UsagePanel, - parseClaudePtyUsage, - stripTerminalControlSequences -} from './claude-pty-usage-parser' -import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' -import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' - -const PTY_TIMEOUT_MS = 25_000 -const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit - -// --------------------------------------------------------------------------- -// PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI -// --------------------------------------------------------------------------- - -// Why: prompt detection is unreliable because the Claude CLI v2.x renders -// a status bar and TUI elements that push the `❯` prompt out of any -// reasonable detection window. Instead we wait a fixed 2s after spawning -// for the CLI to initialize, then send `/usage\r` directly. Command -// palette prompts ("Show plan usage limits") are auto-confirmed with Enter. -const COMMAND_PALETTE_RE = /show plan|usage limits/i -const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i -const STARTUP_DELAY_MS = 2_000 -const SETTLE_AFTER_STOP_MS = 2_000 -const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 - -export async function fetchViaPty(options?: { - authPreparation?: ClaudeRuntimeAuthPreparation - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - const pty = await import('node-pty') - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - - return new Promise((resolve) => { - let output = '' - let resolved = false - let sentUsage = false - let stopDetected = false - let claude21UsageDetected = false - let startupDelayTimer: ReturnType | null = null - let stopSettleTimer: ReturnType | null = null - let claude21UsageSettleTimer: ReturnType | null = null - - const claudeCommand = resolveClaudeCommand() - - // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — - // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 - // and ensure the command path is properly quoted if it contains spaces. - const isWin32 = process.platform === 'win32' - const spawnEnv = applyClaudeEnvPatch( - { ...process.env, TERM: 'xterm-256color' } as Record, - options?.authPreparation?.envPatch ?? {}, - { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } - ) - // Why: this hidden usage PTY spawns `claude` directly, not the user's shell - // wrapper, so without the configured proxy it would reach api.anthropic.com - // from the app's own IP — bypassing the proxy the user set for Claude and - // risking rate-limit/geo signals on the account. Falls back to {} when unset. - const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) - Object.assign(spawnEnv, proxyEnv) - const authPreparation = options?.authPreparation - const wslConfig = - authPreparation?.runtime === 'wsl' && - authPreparation.wslDistro && - authPreparation.wslLinuxConfigDir - ? { - distro: authPreparation.wslDistro, - linuxConfigDir: authPreparation.wslLinuxConfigDir - } - : null - const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = wslConfig - ? [ - '-d', - wslConfig.distro, - '--exec', - 'bash', - '-lc', - // Why: Windows-side env does not cross into the distro without WSLENV, - // so export the configured proxy inside the command for the inner claude. - [ - // Why: hidden usage probes must not inherit a root-like WSL cwd; - // keep Claude discovery bounded to a tiny temp directory. - ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map( - ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` - ), - 'exec claude' - ].join(' && ') - ] - : isWin32 - ? ['/c', `"${claudeCommand}"`] - : [] - - const term = pty.spawn(spawnFile, spawnArgs, { - name: 'xterm-256color', - cols: 120, - rows: 40, - // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a - // drive root), which can trigger unbounded file discovery. - cwd: resolveHiddenRateLimitPtyCwd(), - env: withCliRuntimeOnPath(claudeCommand, spawnEnv) - }) - const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - let enterInterval: ReturnType | null = null - let timeout: ReturnType | null = null - - function clearFollowupTimers(): void { - if (startupDelayTimer) { - clearTimeout(startupDelayTimer) - startupDelayTimer = null - } - if (stopSettleTimer) { - clearTimeout(stopSettleTimer) - stopSettleTimer = null - } - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - } - - function settleAborted(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve(abortedClaudeUsageResult()) - } - - if (options?.signal) { - if (options.signal.aborted) { - settleAborted() - return - } - options.signal.addEventListener('abort', settleAborted, { once: true }) - termDisposables.push({ - dispose: () => options.signal?.removeEventListener('abort', settleAborted) - }) - } - - timeout = setTimeout(() => { - if (!resolved) { - resolved = true - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - // Even on timeout, try to parse whatever we collected - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - if (session || weekly || fableWeekly) { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } else { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint( - isClaude21UsagePanel(clean) - ? describeClaudeUsageFailure(clean) - : 'PTY timeout — /usage panel did not render', - clean - ), - status: 'error' - }) - } - } - }, PTY_TIMEOUT_MS) - - // Why: the Claude TUI may have scrollable panels or prompts. - // Sending Enter every 0.8s advances through them. - function startEnterPresses(): void { - if (enterInterval) { - return - } - enterInterval = setInterval(() => { - if (!resolved && !stopDetected) { - term.write('\r') - } - }, 800) - } - - function finalize(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - - if (!session && !weekly && !fableWeekly) { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint(describeClaudeUsageFailure(clean), clean), - status: 'error' - }) - } else { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } - } - - // Why: wait 2s for the CLI to initialize, then send `/usage\r` - // directly without detecting the prompt character (see comment above). - startupDelayTimer = setTimeout(() => { - startupDelayTimer = null - if (resolved) { - return - } - sentUsage = true - term.write('/usage\r') - startEnterPresses() - }, STARTUP_DELAY_MS) - - const onDataDisposable = term.onData((data) => { - output += data - // Why: prevent memory exhaustion if the CLI process floods output - if (output.length > MAX_OUTPUT_LENGTH) { - output = output.slice(-MAX_OUTPUT_LENGTH) - } - - const cleanChunk = stripTerminalControlSequences(data) - - // Why: the Claude CLI may prompt for first-run setup (trust files, - // workspace directory). Auto-accept so we can reach /usage. - if (TRUST_PROMPT_RE.test(cleanChunk)) { - term.write('y\r') - return - } - - // Why: Claude CLI v2.x may show a command palette when `/usage` is - // entered, listing options like "Show plan usage limits". Auto-confirm - // by sending Enter when these prompts appear. - if (sentUsage && COMMAND_PALETTE_RE.test(cleanChunk)) { - term.write('\r') - } - - // Check if we've hit a stop substring indicating the panel rendered - if (sentUsage && !stopDetected) { - const clean = stripTerminalControlSequences(output) - if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { - claude21UsageDetected = true - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - // Why: Claude 2.1 may render session stats without subscription - // plan windows. Give async usage loading a grace period, then finish - // with a user-facing unavailable state instead of a false PTY timeout. - claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) - } - for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { - if (clean.includes(sub)) { - stopDetected = true - // Why: 2.0s settle time after detecting the stop substring - // allows the full panel to finish rendering. - stopSettleTimer = setTimeout(finalize, SETTLE_AFTER_STOP_MS) - break - } - } - } - }) - if (onDataDisposable) { - termDisposables.push(onDataDisposable) - } - - const onExitDisposable = term.onExit(() => { - cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) - clearFollowupTimers() - if (!resolved) { - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: - session || weekly || fableWeekly - ? null - : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), - status: session || weekly || fableWeekly ? 'ok' : 'error' - }) - } - }) - if (onExitDisposable) { - termDisposables.push(onExitDisposable) - } - }) -} diff --git a/src/main/rate-limits/claude-usage-error-classification.test.ts b/src/main/rate-limits/claude-usage-error-classification.test.ts index 9371479cfba5..5e60c281674f 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - classifyClaudeCredentialAbsence, - classifyClaudeOAuthUsageError -} from './claude-usage-error-classification' +import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' import { OAuthUsageError } from './claude-oauth-usage-error' describe('classifyClaudeOAuthUsageError', () => { @@ -47,38 +44,3 @@ describe('classifyClaudeOAuthUsageError', () => { }) }) }) - -describe('classifyClaudeCredentialAbsence', () => { - it('classifies refresh-only credentials as repairable', () => { - expect(classifyClaudeCredentialAbsence({ hasRefreshableCredentials: true })).toMatchObject({ - failureKind: 'refreshable-credentials-without-token', - shouldAttemptDelegatedRefresh: true, - shouldAttemptCliFallback: true - }) - }) - - it('classifies Keychain read failures separately from missing credentials', () => { - expect( - classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: false, - keychainUnavailable: true - }) - ).toMatchObject({ - failureKind: 'keychain-unavailable', - shouldAttemptCliFallback: true, - shouldAttemptDelegatedRefresh: false - }) - }) - - it('classifies live Claude ownership as a deferred state', () => { - expect( - classifyClaudeCredentialAbsence({ - hasRefreshableCredentials: true, - managedRefreshDeferredByLivePty: true - }) - ).toMatchObject({ - failureKind: 'deferred-by-live-session', - terminal: true - }) - }) -}) diff --git a/src/main/rate-limits/claude-usage-error-classification.ts b/src/main/rate-limits/claude-usage-error-classification.ts index 71c88c242bd8..440d1155d243 100644 --- a/src/main/rate-limits/claude-usage-error-classification.ts +++ b/src/main/rate-limits/claude-usage-error-classification.ts @@ -39,23 +39,6 @@ export function classifyClaudeOAuthUsageError(error: unknown): ClaudeUsageErrorC return fallbackOnly('unknown') } -export function classifyClaudeCredentialAbsence(input: { - hasRefreshableCredentials: boolean - keychainUnavailable?: boolean - managedRefreshDeferredByLivePty?: boolean -}): ClaudeUsageErrorClassification { - if (input.managedRefreshDeferredByLivePty) { - return terminal('deferred-by-live-session') - } - if (input.keychainUnavailable) { - return fallbackOnly('keychain-unavailable') - } - if (input.hasRefreshableCredentials) { - return recoverableAuth('refreshable-credentials-without-token') - } - return terminal('missing-credentials') -} - function recoverableAuth(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, diff --git a/src/main/rate-limits/claude-usage-refresh-plan.test.ts b/src/main/rate-limits/claude-usage-refresh-plan.test.ts deleted file mode 100644 index dced9e7815c7..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' - -describe('resolveClaudeUsageRefreshPlan', () => { - it('uses OAuth then CLI app auto ordering when CLI fallback is available', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).steps).toEqual([ - { source: 'oauth', reason: 'app-auto-preferred-oauth' }, - { source: 'cli', reason: 'app-auto-fallback-cli' } - ]) - }) - - it('keeps web deferred and internal-only for the first parity pass', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).webDeferred).toBe(true) - }) - - it('omits CLI fallback for unresolved WSL Claude config', () => { - expect( - resolveClaudeUsageRefreshPlan({ - allowCliFallback: true, - authPreparation: { - configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }).steps - ).toEqual([{ source: 'oauth', reason: 'app-auto-preferred-oauth' }]) - }) -}) diff --git a/src/main/rate-limits/claude-usage-refresh-plan.ts b/src/main/rate-limits/claude-usage-refresh-plan.ts deleted file mode 100644 index 06144a15715b..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { UsageRateLimitSource } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -export type ClaudeUsageRefreshStep = { - source: UsageRateLimitSource - reason: 'app-auto-preferred-oauth' | 'app-auto-fallback-cli' | 'future-web-fallback-deferred' -} - -export type ClaudeUsageRefreshPlanInput = { - authPreparation?: ClaudeRuntimeAuthPreparation - allowCliFallback: boolean -} - -export type ClaudeUsageRefreshPlan = { - steps: ClaudeUsageRefreshStep[] - webDeferred: boolean -} - -export function resolveClaudeUsageRefreshPlan( - input: ClaudeUsageRefreshPlanInput -): ClaudeUsageRefreshPlan { - const steps: ClaudeUsageRefreshStep[] = [ - { - source: 'oauth', - reason: 'app-auto-preferred-oauth' - } - ] - - if (input.allowCliFallback && isCliPlausiblyAvailable(input.authPreparation)) { - steps.push({ - source: 'cli', - reason: 'app-auto-fallback-cli' - }) - } - - return { - steps, - webDeferred: true - } -} - -function isCliPlausiblyAvailable(authPreparation?: ClaudeRuntimeAuthPreparation): boolean { - if (authPreparation?.runtime === 'wsl') { - return Boolean(authPreparation.wslDistro && authPreparation.wslLinuxConfigDir) - } - return true -} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index a29cb38d4aad..e1863be724d3 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -1,16 +1,4 @@ -import type { - ProviderRateLimits, - UsageRateLimitFailureKind, - UsageRateLimitMetadata, - UsageRateLimitSource -} from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import { OAuthUsageError } from './claude-oauth-usage-error' - -export type ClaudeUsageAttemptState = { - attemptedSources: UsageRateLimitSource[] -} +import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types' export function abortedClaudeRateLimitResult(): ProviderRateLimits { return { @@ -23,30 +11,6 @@ export function abortedClaudeRateLimitResult(): ProviderRateLimits { } } -export function recordClaudeUsageAttempt( - state: ClaudeUsageAttemptState, - source: UsageRateLimitSource -): UsageRateLimitSource[] { - if (!state.attemptedSources.includes(source)) { - state.attemptedSources.push(source) - } - return state.attemptedSources -} - -export function withClaudeUsageMetadata( - limits: ProviderRateLimits, - metadata: UsageRateLimitMetadata -): ProviderRateLimits { - return { - ...limits, - usageMetadata: { - ...limits.usageMetadata, - ...metadata, - attemptedSources: metadata.attemptedSources ?? limits.usageMetadata?.attemptedSources - } - } -} - export function makeClaudeUsageResult( status: ProviderRateLimits['status'], error: string | null, @@ -63,76 +27,10 @@ export function makeClaudeUsageResult( } } -export function metadataForClaudeUsageAttempt(input: { - attemptedSources: UsageRateLimitSource[] - oauthCredentials: ClaudeOAuthCredentialReadResult - authPreparation?: ClaudeRuntimeAuthPreparation - source?: UsageRateLimitSource - failureKind?: UsageRateLimitFailureKind - deferredByLiveClaudeSession?: boolean - retryAtMs?: number -}): UsageRateLimitMetadata { - return { - source: input.source, - attemptedSources: [...input.attemptedSources], - failureKind: input.failureKind, - credentialSource: input.oauthCredentials.source, - authProvenance: input.authPreparation?.provenance ?? 'system', - deferredByLiveClaudeSession: input.deferredByLiveClaudeSession, - retryAtMs: input.retryAtMs - } -} - -export function warnClaudeUsageFetchFailure( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - credentials: ClaudeOAuthCredentialReadResult, - error: unknown -): void { - console.warn('[claude-rate-limits] Claude usage refresh failed', { - provenance: authPreparation?.provenance ?? 'system', - runtime: authPreparation?.runtime ?? 'host', - wslDistro: authPreparation?.wslDistro ?? null, - hasExplicitClaudeConfigDir: Boolean(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - credentialSource: credentials.source, - keychainUnavailable: credentials.keychainUnavailable, - hasRefreshableCredentials: credentials.hasRefreshableCredentials, - status: error instanceof OAuthUsageError ? error.status : null, - message: error instanceof Error ? error.message : String(error) +// Why plain: the profile/host reason is shown on the account row, not as raw text in usage. +export function claudeUsageUnavailable(): ProviderRateLimits { + return makeClaudeUsageResult('error', 'Claude usage is unavailable right now.', { + failureKind: 'usage-unavailable', + attemptedSources: [] }) } - -export function mergeClaudeUsageWindows( - primary: ProviderRateLimits, - supplement: ProviderRateLimits | null -): ProviderRateLimits { - if (!supplement) { - return primary - } - return { - ...primary, - session: primary.session ?? supplement.session, - weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, - // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. - extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null - } -} - -export function canSupplementClaudeOAuthUsage(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - allowUsagePanelSupplement: boolean -}): boolean { - return Boolean( - input.allowUsagePanelSupplement && - !input.authPreparation?.managedRefreshDeferredByLivePty && - !input.oauthLimits.fableWeekly && - (input.oauthLimits.session || input.oauthLimits.weekly) - ) -} - -export function isManagedClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - return authPreparation?.provenance.startsWith('managed:') === true -} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts deleted file mode 100644 index 43e8453fa0fd..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - cleanupHiddenRateLimitPty, - getActiveHiddenRateLimitPtyCount, - registerHiddenRateLimitPty -} from './hidden-pty-cleanup' - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -describe('cleanupHiddenRateLimitPty', () => { - const originalPlatform = process.platform - - afterEach(() => { - setPlatform(originalPlatform) - vi.clearAllMocks() - }) - - it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { - setPlatform('darwin') - const dataDisposable = { dispose: vi.fn() } - const exitDisposable = { dispose: vi.fn() } - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [dataDisposable, exitDisposable], { kill: true }) - - expect(dataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( - term.destroy.mock.invocationCallOrder[0] - ) - }) - - it('releases the PTY fd without killing again after natural exit', () => { - setPlatform('darwin') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(killMock).not.toHaveBeenCalled() - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { - setPlatform('linux') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn(() => { - term.kill('SIGHUP') - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(killMock).toHaveBeenCalledTimes(1) - expect(killMock).toHaveBeenCalledWith() - }) - - it('does not destroy after an intentional Windows kill because destroy kills again', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).not.toHaveBeenCalled() - }) - - it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('removes registered hidden PTYs when cleanup kills them', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: true }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('removes registered hidden PTYs after natural exit cleanup', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: false }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) -}) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts deleted file mode 100644 index 068b937ee844..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ /dev/null @@ -1,58 +0,0 @@ -type HiddenPty = { - kill: (signal?: string) => void - destroy?: () => void -} - -type Disposable = { - dispose: () => void -} - -const activeHiddenRateLimitPtys = new Set() - -export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { - activeHiddenRateLimitPtys.add(term) - return { - dispose: () => { - activeHiddenRateLimitPtys.delete(term) - } - } -} - -export function getActiveHiddenRateLimitPtyCount(): number { - return activeHiddenRateLimitPtys.size -} - -export function cleanupHiddenRateLimitPty( - term: HiddenPty, - disposables: Disposable[], - options: { kill: boolean } -): void { - for (const disposable of disposables.splice(0)) { - disposable.dispose() - } - - if (options.kill) { - try { - term.kill() - } catch { - /* already exited */ - } - - // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can - // close the same ConPTY handle twice after an intentional termination. - if (process.platform === 'win32') { - return - } - } - - // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize - // the post-close SIGHUP hook after exit/kill to avoid pid reuse. - if (process.platform !== 'win32') { - term.kill = () => {} - } - try { - term.destroy?.() - } catch { - /* already torn down */ - } -} diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 27e93208c1a1..dba478291d7f 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -368,7 +368,6 @@ describe('RateLimitService', () => { wslDistro: target?.wslDistro ?? null, wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, envPatch: target?.runtime === 'wsl' ? { CLAUDE_CONFIG_DIR: '/home/jin/.claude' } : {}, - stripAuthEnv: target?.runtime === 'wsl', provenance: target?.runtime === 'wsl' ? 'managed:wsl-account:wsl:Ubuntu' : 'system' })) service.setClaudeAuthPreparationResolver(resolver) @@ -385,8 +384,7 @@ describe('RateLimitService', () => { authPreparation: expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/jin/.claude', - stripAuthEnv: true + wslLinuxConfigDir: '/home/jin/.claude' }), allowPtyFallback: true, allowUsagePanelSupplement: true, @@ -404,7 +402,6 @@ describe('RateLimitService', () => { wslDistro: null, wslLinuxConfigDir: null, envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) @@ -450,7 +447,6 @@ describe('RateLimitService', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/jin/.claude', envPatch: {}, - stripAuthEnv: true, provenance: 'wsl:Ubuntu:system' })) @@ -551,9 +547,7 @@ describe('RateLimitService', () => { it('does not cache host Claude usage under an outgoing WSL account', async () => { const service = new RateLimitService() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'wsl-account-1' }]) service.setClaudeAuthPreparationResolver(async (target) => ({ configDir: target?.runtime === 'wsl' @@ -563,7 +557,6 @@ describe('RateLimitService', () => { wslDistro: target?.wslDistro ?? null, wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, envPatch: {}, - stripAuthEnv: target?.runtime === 'wsl', provenance: target?.runtime === 'wsl' ? 'managed:wsl-account-1:wsl:Ubuntu' : 'system' })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index cae71c569e71..ca05c7c0fb92 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -78,7 +78,7 @@ describe('RateLimitService', () => { it('aborts inactive Claude preview fetches on stop', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const account = { id: 'account-1' } const capturedSignals: { claude?: AbortSignal } = {} service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockImplementation( @@ -212,7 +212,7 @@ describe('RateLimitService', () => { it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const account = { id: 'account-1' } service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) @@ -230,9 +230,7 @@ describe('RateLimitService', () => { it('does not start overlapping inactive Claude preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) const firstFetch = service.fetchInactiveClaudeAccountsOnOpen() @@ -401,12 +399,11 @@ describe('RateLimitService', () => { it('does not recache an inactive Claude account removed during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() - let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + let inactiveAccounts = [{ id: 'account-1' }] service.setInactiveClaudeAccountsResolver(() => inactiveAccounts) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) @@ -420,7 +417,7 @@ describe('RateLimitService', () => { ]) service.evictInactiveClaudeCache('account-1') - inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + inactiveAccounts = [{ id: 'account-1' }] await service.refreshForClaudeAccountChange('account-1') expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1') @@ -435,13 +432,10 @@ describe('RateLimitService', () => { it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [ - { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - ]) + service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 8c35ab77acc7..01c341d00f50 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -14,6 +14,12 @@ import { resetRateLimitProviderMocks } from './rate-limit-service-test-harness' +const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined +})) + vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), fetchManagedAccountUsage: vi.fn() @@ -280,6 +286,58 @@ describe('RateLimitService', () => { } }) + it('attributes statusline posts to System Default when it inherits CLAUDE_CONFIG_DIR', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + vi.stubEnv('CLAUDE_CONFIG_DIR', '/own/claude-config') + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/own/claude-config', + envPatch: {}, + provenance: 'system' + })) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: '/own/claude-config', + fiveHour: { used_percentage: 44 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(44) + } finally { + vi.unstubAllEnvs() + vi.useRealTimers() + } + }) + + it("attributes System Default posts to the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 18)) + mockFreshBackgroundProviderFetches() + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + profileRouter.userConfigDir = '/shell/claude-config' + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/shell/claude-config', + envPatch: {}, + provenance: 'system' + })) + await service.refresh() + service.ingestLiveClaudeRateLimits({ + configDir: '/shell/claude-config', + fiveHour: { used_percentage: 44 }, + sevenDay: null + }) + expect(service.getState().claude?.session?.usedPercent).toBe(44) + } finally { + profileRouter.userConfigDir = undefined + vi.unstubAllEnvs() + vi.useRealTimers() + } + }) + it('keeps the other window when a statusline post carries only one', async () => { vi.useFakeTimers() try { @@ -440,7 +498,6 @@ describe('RateLimitService', () => { envPatch: { CLAUDE_CONFIG_DIR: outgoing ? '/outgoing/.claude' : '/incoming/.claude' }, - stripAuthEnv: false, provenance: outgoing ? 'managed:outgoing' : 'managed:incoming' } }) @@ -477,51 +534,4 @@ describe('RateLimitService', () => { vi.useRealTimers() } }) - - describe('refreshAfterClaudeLivePtysDrained', () => { - function deferredClaudeResult(): ProviderRateLimits { - return { - ...errorProvider('claude', 'Waiting for Claude session'), - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true - } - } - } - - it('refetches Claude usage when the current result was deferred by a live session', async () => { - const service = new RateLimitService() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(deferredClaudeResult()) - await service.refresh() - expect(service.getState().claude?.usageMetadata?.deferredByLiveClaudeSession).toBe(true) - vi.mocked(fetchClaudeRateLimits).mockClear() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) - expect(service.getState().claude?.status).toBe('ok') - }) - - it('does not refetch when the current Claude result was not deferred', async () => { - const service = new RateLimitService() - vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce( - errorProvider('claude', 'Token expired') - ) - await service.refresh() - vi.mocked(fetchClaudeRateLimits).mockClear() - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).not.toHaveBeenCalled() - }) - - it('does not refetch when there is no Claude state yet', async () => { - const service = new RateLimitService() - - await service.refreshAfterClaudeLivePtysDrained() - - expect(fetchClaudeRateLimits).not.toHaveBeenCalled() - }) - }) }) diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index 6aeacd99583b..944c09be2479 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -191,15 +191,4 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna await this.fetchClaudeOnly({ force: true }) return this.getState() } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } } diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index cfd4a6e9d58e..907f0512f007 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -10,6 +10,8 @@ import { type ProviderRateLimits } from './service-types' import { mapClaudeUsageWindow } from '../claude-usage-window' +import { readUserClaudeConfigDir } from '../../claude-accounts/claude-profile-paths' +import { getClaudeProfileRouter } from '../../claude-accounts/claude-profile-installed-router' export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { protected getMiniMaxCredentialError(message: string): ProviderRateLimits { @@ -91,7 +93,12 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT return } this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + // Why the user's own folder: System default passes it through rather than injecting it. + configDir: normalizeClaudeConfigDir( + authPreparation?.envPatch.CLAUDE_CONFIG_DIR ?? + getClaudeProfileRouter()?.userConfigDir() ?? + readUserClaudeConfigDir(process.env) + ), provenance: authPreparation?.provenance ?? 'system' } } diff --git a/src/main/runtime/agent-session-record-store-slot.test.ts b/src/main/runtime/agent-session-record-store-slot.test.ts index 35005c3badfc..6b69d5848004 100644 --- a/src/main/runtime/agent-session-record-store-slot.test.ts +++ b/src/main/runtime/agent-session-record-store-slot.test.ts @@ -54,7 +54,7 @@ describe('the record store slot', () => { ...location(), claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [] }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index bc44b74a8a16..a2eaee178c4a 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -208,7 +208,7 @@ function textOf(item: AgentJournalRenderItem): string { beforeEach(async () => { operations = 0 - claudeAuthPolicy = { stripAuthEnv: false } + claudeAuthPolicy = { account: 'system' } shellEnv = { PATH: '/shell/bin:/usr/bin' } shellEnvironmentPolicy = { inheritAll: true, names: [] } claudeLaunchEnv = { @@ -318,8 +318,8 @@ 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 } + it("keeps the shell's Anthropic auth with its proxy address once a managed account is pinned", async () => { + claudeAuthPolicy = { account: 'managed' } claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } shellEnv = { ...shellEnv, @@ -331,9 +331,8 @@ describe('a structured Claude session over agentSession.*', () => { 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_API_KEY: 'sk-ant-SHELL-LEAK', ANTHROPIC_BASE_URL: 'https://gateway.example.test', CLAUDE_CONFIG_DIR: join(root, 'claude-home') }) @@ -388,36 +387,6 @@ describe('a structured Claude session over agentSession.*', () => { expect(env).not.toHaveProperty('CODEX_LB_API_KEY') }) - 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 params = createIntentParams() - const sentence = - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - const refused = await call('agentSession.create', params) - - // The thrown answer keeps its wire code; only its words are the ones its replay reads. - expect(refused).toMatchObject({ - ok: false, - error: { code: 'runtime_error', message: sentence } - }) - // Its replay reads the same sentence, beside the situation it names. - expect(await call('agentSession.create', params)).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'managedAccountEnvOverride' }, - message: sentence - } - } - }) - // Refused before spawn: no provider child was ever opened. - expect(claude.connections).toHaveLength(0) - }) - it('publishes, then ends the session with sign-in guidance when initialization has no credentials', async () => { claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 7fd0dec42ad8..c7e3c1d79a12 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -41,8 +41,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent, worktreeSelector, location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), - runtime: this, - getSettings: () => this.requireStore().getSettings() + runtime: this }) } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 1698676a3cb1..4fb79e83cd4b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -260,8 +260,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveAgentCommandSettings: () => this.requireStore().getSettings(), resolveCodexAccountKind: (home) => resolveStructuredCodexAccountKind(home, this.requireStore().getSettings()), - // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. - getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), ...(this.prepareCodexCatalogProbeHomeFn ? { prepareCodexCatalogProbeHome: this.prepareCodexCatalogProbeHomeFn } 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 4b63ec021c89..7199bd74a17c 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 @@ -133,14 +133,10 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt ...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 }) + applyClaudeEnvPatch(baseEnv, auth.envPatch) } - const envToDelete = auth?.stripAuthEnv - ? [...inheritedEnvKeys].filter((key) => !(key in baseEnv)) - : undefined const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) const launch = this.claudeAgentTeams.createLaunchEnv({ @@ -150,7 +146,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt shimBin }) const env = auth ? { ...auth.envPatch, ...launch.env } : launch.env - return envToDelete ? { env, envToDelete } : { env } + return { 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-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts deleted file mode 100644 index 64b451e9ea90..000000000000 --- a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { - return { - id, - email: `${id}@example.com`, - managedAuthPath: `/managed/${id}`, - managedAuthRuntime, - authMethod: 'subscription-oauth' as const, - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0 - } -} - -const WSL_ONLY: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } -} - -/** Registered Claude accounts with none selected: ambient auth, and the UI names no host identity, - * so this must reach structured rather than silently falling back to a terminal session. */ -const ACCOUNTS_PRESENT_NONE_ACTIVE: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host'), managedAccount('host-2', 'host')], - activeClaudeManagedAccountId: null, - activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } -} - -const HOST_SELECTED: ClaudeManagedAccountGateSettings = { - claudeManagedAccounts: [managedAccount('host-1', 'host')], - activeClaudeManagedAccountId: 'host-1', - activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } -} - -function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService { - // No store at all is the unreadable-settings case the gate must fail closed on. - const runtime = claude - ? new OrcaRuntimeService({ getSettings: () => claude } as never) - : new OrcaRuntimeService() - const internal = runtime as unknown as { - resolveStructuredAgentSessionLocation: (selector: string) => Promise - ensureStructuredAgentSessionHost: () => Promise - } - internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' as const - })) - // The adapter's own location answer is irrelevant here; pin it supported so only the account - // gate can refuse. - internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) - setStructuredAgentSessionHost({ - supportsCreate: () => true - } as unknown as StructuredAgentSessionHost) - return runtime -} - -afterEach(() => { - setStructuredAgentSessionHost(null) -}) - -describe('structured Claude managed-account gate', () => { - it('refuses Claude under a WSL-only managed account', async () => { - const runtime = runtimeWithAccounts(WSL_ONLY) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: false }) - }) - - it('supports Claude when accounts are registered but none is selected', async () => { - const runtime = runtimeWithAccounts(ACCOUNTS_PRESENT_NONE_ACTIVE) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: true }) - }) - - it('still supports Claude under a selected host managed account', async () => { - const runtime = runtimeWithAccounts(HOST_SELECTED) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: true }) - }) - - it('fails closed for Claude when the account runtime cannot be determined', async () => { - const runtime = runtimeWithAccounts(null) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') - ).resolves.toMatchObject({ supported: false }) - }) - - /** The gate is Claude's alone: Codex resolves its account separately and this lane must not - * change any Codex answer. */ - it('leaves Codex supported under the same WSL-only Claude account', async () => { - const runtime = runtimeWithAccounts(WSL_ONLY) - await expect( - runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex') - ).resolves.toMatchObject({ supported: true }) - }) -}) diff --git a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts deleted file mode 100644 index 0d9b12f7c528..000000000000 --- a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const installed = vi.hoisted(() => ({ deps: null as Record | null })) - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -vi.mock('./structured-agent-session-runtime', () => ({ - ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { - installed.deps = deps - }) -})) - -import { OrcaRuntimeService } from './orca-runtime' -import { - readClaudeManagedAccountGateSettings, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' - -const SETTINGS = { - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - agentDefaultEnv: {}, - agentDefaultArgs: {} -} as unknown as ClaudeManagedAccountGateSettings - -function gateSettingsGetter(): (() => ClaudeManagedAccountGateSettings) | undefined { - const deps: Record = installed.deps ?? {} - const get = deps['getClaudeManagedAccountGateSettings'] - return typeof get === 'function' ? (get as () => ClaudeManagedAccountGateSettings) : undefined -} - -/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or - * missing gate hookup compiles clean. Pin it behaviourally instead. */ -describe('structured Claude managed-account gate wiring', () => { - it('hands the host a gate reader that resolves the live settings', async () => { - installed.deps = null - const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) - - await runtime.ensureStructuredAgentSessionHost() - - const get = gateSettingsGetter() - expect(typeof get).toBe('function') - expect(get?.()).toBe(SETTINGS) - }) - - /** The installer composes this getter with the fail-closed reader, which is the shape the - * resolver consumes; pin that composition end to end. */ - it('composes into a null answer instead of throwing when settings cannot be read', async () => { - installed.deps = null - const runtime = new OrcaRuntimeService() - - await runtime.ensureStructuredAgentSessionHost() - - const get = gateSettingsGetter() - expect(typeof get).toBe('function') - expect(() => get?.()).toThrow() - expect(readClaudeManagedAccountGateSettings(get!)).toBeNull() - }) -}) diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index b8473d00e014..c10d70b7ad99 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -15,20 +15,6 @@ function method(name: string) { describe('account RPC methods', () => { it.each([ - { - methodName: 'accounts.addClaudeFromConfigDir', - params: { - configDir: join(tmpdir(), 'claude-login'), - previousLegacyCredentialsSha256: 'a'.repeat(64) - }, - runtimeMethod: 'addClaudeAccountFromConfigDir', - expectedSource: join(tmpdir(), 'claude-login'), - expectedOptions: { - runtime: undefined, - wslDistro: null, - previousLegacyCredentialsSha256: 'a'.repeat(64) - } - }, { methodName: 'accounts.addCodexFromHome', params: { sourceHome: join(tmpdir(), 'codex-login') }, @@ -50,29 +36,37 @@ describe('account RPC methods', () => { }) it.each([ - ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }, /only available/], [ 'accounts.addDataFromHome', - { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } - ] - ])('rejects paired-device calls to %s', async (methodName, params) => { + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' }, + /only available/ + ], + ['accounts.beginClaudeSignIn', {}, /Sign in on the Orca execution host/], + ['accounts.finishClaudeSignIn', { accountId: 'a' }, /Sign in on the Orca execution host/] + ] as const)('rejects paired-device calls to %s', async (methodName, params, refusal) => { const runtime = { - addClaudeAccountFromConfigDir: vi.fn(), - addCodexAccountFromHome: vi.fn() - } as unknown as OrcaRuntimeService + addCodexAccountFromHome: vi.fn(), + addDataAccountFromHome: vi.fn(), + beginClaudeSignIn: vi.fn(), + finishClaudeSignIn: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: refused calls never reach the runtime; only these stubs are asserted. + const runtimeService = runtime as unknown as OrcaRuntimeService const addMethod = method(methodName) if (isStreamingMethod(addMethod)) { throw new Error(`${methodName} must be a request method`) } for (const clientKind of ['mobile', 'runtime'] as const) { - await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( - /only available on the Orca host runtime/ - ) + await expect( + addMethod.handler(params, { runtime: runtimeService, clientKind }) + ).rejects.toThrow(refusal) } - expect(runtime.addClaudeAccountFromConfigDir).not.toHaveBeenCalled() expect(runtime.addCodexAccountFromHome).not.toHaveBeenCalled() + expect(runtime.addDataAccountFromHome).not.toHaveBeenCalled() + expect(runtime.beginClaudeSignIn).not.toHaveBeenCalled() + expect(runtime.finishClaudeSignIn).not.toHaveBeenCalled() }) it('keeps explicit account-list refreshes on the forced refresh lane', async () => { @@ -227,3 +221,14 @@ describe('account RPC methods', () => { await running }) }) + +it('rejects the retired Claude import even for a local socket, before touching its supplied path', async () => { + const old = method('accounts.addClaudeFromConfigDir') + if (isStreamingMethod(old)) { + throw new Error('Unexpected stream') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Rejection happens before the handler accesses its context. + await expect(old.handler({ configDir: '/never-read' }, {} as never)).rejects.toThrow( + 'Update the Orca CLI to add accounts' + ) +}) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 10c39aacd2f8..7c97c36ac405 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -4,6 +4,8 @@ import { SelectDataAccountParams, RemoveDataAccountParams, AccountsUnsubscribeParams, + BeginClaudeSignInParams, + FinishClaudeSignInParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, ConsumeCodexResetCreditParams, @@ -19,15 +21,42 @@ import { // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 -// Why: bridges the desktop ClaudeAccountService / CodexAccountService / -// RateLimitService into the WebSocket / local-socket RPC. Read + switch + -// remove for all clients; interactive add/re-auth flows spawn `claude login` -// / `codex login` PTYs that need a desktop browser, so they intentionally -// remain desktop-only. `accounts.addClaudeFromConfigDir` is the exception: it -// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local -// `orca account add` CLI can register accounts on a headless host; it is gated -// to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS = [ + // Why local only: sign-in runs `claude auth login` in a terminal on the execution host. + defineMethod({ + name: 'accounts.beginClaudeSignIn', + permission: 'accounts-admin', + params: BeginClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + return runtime.beginClaudeSignIn(params) + } + }), + defineMethod({ + name: 'accounts.finishClaudeSignIn', + permission: 'accounts-admin', + params: FinishClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + return runtime.finishClaudeSignIn(params) + } + }), + defineMethod({ + name: 'accounts.cancelClaudeSignIn', + permission: 'accounts-admin', + params: FinishClaudeSignInParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Sign in on the Orca execution host.') + } + await runtime.cancelClaudeSignIn(params) + return {} + } + }), defineMethod({ name: 'accounts.listData', permission: 'workspace', @@ -118,17 +147,10 @@ export const ACCOUNT_METHODS = [ name: 'accounts.addClaudeFromConfigDir', permission: 'accounts-admin', params: AddClaudeFromConfigDirParams, - handler: async (params, { runtime, clientKind }) => { - // Why: capturing a host filesystem path is local-socket-only; paired - // mobile and remote-runtime tokens must never read host credential paths. - if (clientKind !== undefined) { - throw new Error('Adding Claude accounts is only available on the Orca host runtime.') - } - return runtime.addClaudeAccountFromConfigDir(params.configDir, { - runtime: params.runtime, - wslDistro: params.wslDistro ?? null, - previousLegacyCredentialsSha256: params.previousLegacyCredentialsSha256 - }) + handler: async () => { + throw new Error( + 'Update the Orca CLI to add accounts. Importing Claude logins is no longer supported.' + ) } }), defineMethod({ diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index c824301e5a5f..248adf2c18f1 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -2,7 +2,9 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import { hasAppEnvironment } from '../../shared/app-environment' import { getManagedDataAccountService } from '../managed-data-accounts/service' import type { + ClaudeAccountSignIn, ClaudeRateLimitAccountsState, + ClaudeSignInRequest, CodexRateLimitAccountsState, ManagedDataAccountProvider, ManagedDataAccountsState @@ -164,15 +166,18 @@ export class RuntimeAccountController { return this.requireServices().claudeAccounts.removeAccount(accountId) } - addClaudeFromConfigDir( - configDir: string, - options?: { - runtime?: 'host' | 'wsl' - wslDistro?: string | null - previousLegacyCredentialsSha256?: string | null - } + beginClaudeSignIn(request: ClaudeSignInRequest): Promise { + return this.requireServices().claudeAccounts.beginSignIn(request) + } + + finishClaudeSignIn( + signIn: Omit ): Promise { - return this.requireServices().claudeAccounts.addAccountFromConfigDir(configDir, options) + return this.requireServices().claudeAccounts.finishSignIn(signIn) + } + + cancelClaudeSignIn(signIn: Omit): Promise { + return this.requireServices().claudeAccounts.cancelSignIn(signIn) } removeCodex(accountId: string): Promise { diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index c62fc799dd40..966976e1a946 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -54,7 +54,9 @@ export type RuntimeServiceCommandSurface = { selectCodexAccountForTarget: RuntimeAccountController['selectCodexForTarget'] consumeCodexRateLimitResetCredit: RuntimeAccountController['consumeCodexResetCredit'] removeClaudeAccount: RuntimeAccountController['removeClaude'] - addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] + beginClaudeSignIn: RuntimeAccountController['beginClaudeSignIn'] + finishClaudeSignIn: RuntimeAccountController['finishClaudeSignIn'] + cancelClaudeSignIn: RuntimeAccountController['cancelClaudeSignIn'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] @@ -154,7 +156,9 @@ export function installRuntimeServiceCommandSurface( selectCodexAccountForTarget: accounts.selectCodexForTarget.bind(accounts), consumeCodexRateLimitResetCredit: accounts.consumeCodexResetCredit.bind(accounts), removeClaudeAccount: accounts.removeClaude.bind(accounts), - addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts), + beginClaudeSignIn: accounts.beginClaudeSignIn.bind(accounts), + finishClaudeSignIn: accounts.finishClaudeSignIn.bind(accounts), + cancelClaudeSignIn: accounts.cancelClaudeSignIn.bind(accounts), removeCodexAccount: accounts.removeCodex.bind(accounts), addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts), addDataAccountFromHome: accounts.addDataFromHome.bind(accounts), diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 3ba29e29e75f..5eb76a9168df 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -25,7 +25,7 @@ export function resolveStructuredClaudeAccountHomePath( deps: StructuredClaudeAccountHomeDeps ): string { // Why only an account: System default keeps the launch-env and configured homes below. - const accountHome = deps.wslDistro ? null : getClaudeProfileRouter()?.selectedHome() + const accountHome = deps.wslDistro ? null : getClaudeProfileRouter()?.routedHome() if (accountHome) { return accountHome } diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index aecff69f1785..97f258120726 100644 --- a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -66,8 +66,7 @@ function createSupport(settings: ReturnType) { runtime: { requireStore: () => ({ getSettings: () => settings }), resolveRuntimeFileTarget: async () => ({ worktree: { path: join(root, 'proj') } }) - }, - getSettings: () => ({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null }) + } }) } diff --git a/src/main/runtime/structured-agent-launch-args-wiring.test.ts b/src/main/runtime/structured-agent-launch-args-wiring.test.ts index 6037c26e5732..5b266396d3a8 100644 --- a/src/main/runtime/structured-agent-launch-args-wiring.test.ts +++ b/src/main/runtime/structured-agent-launch-args-wiring.test.ts @@ -30,7 +30,7 @@ it('refuses installation when the host omits the saved Arguments source', async hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), logger: createStructuredAgentSessionLogger() }) ).rejects.toThrow(STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index d6d1feddb58a..12e65aca5747 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -3,7 +3,6 @@ import type { StructuredAgentId } from '../../shared/agent-session-provider-hand import type { GlobalSettings } from '../../shared/global-settings-types' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport, warnStructuredAgentSessionCreateUnsupported, @@ -47,13 +46,12 @@ export async function structuredAgentSupportsLaunch( /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The * agent's location rule and installed-agent check from its registration, without installing the - * host, then Claude's managed-account gate. A refusal logs which check said no. */ + * host. A refusal logs which check said no. */ export async function resolveHostStructuredAgentCreateSupport(input: { agent: StructuredAgentId worktreeSelector: string location: AgentSessionExecutionLocation runtime: LaunchSupportRuntime - getSettings: () => ClaudeManagedAccountGateSettings }): Promise { const { agent, location } = input const supportsLocation = @@ -62,15 +60,13 @@ export async function resolveHostStructuredAgentCreateSupport(input: { supportsLocation && (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) const support = resolveStructuredAgentSessionCreateSupport({ - agent, location, - adapterSupportsCreate: supportsLaunch, - getSettings: input.getSettings + adapterSupportsCreate: supportsLaunch }) warnStructuredAgentSessionCreateUnsupported( agent, support, - !supportsLocation ? 'location' : !supportsLaunch ? 'installed-agent' : 'managed-account' + supportsLocation ? 'installed-agent' : 'location' ) return support } diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index 544676dc9290..9845c33166bf 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -113,7 +113,7 @@ describe('the model catalog contract on every registration', () => { stateDirectory: '/state', resolveClaudeCommand: () => resolveStructuredAgentCommand('claude', settings), resolveCodexCommand: (options) => resolveStructuredAgentCommand('codex', settings, options), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveClaudeAuthPolicy: () => ({ account: 'system' as const }), resolveAgentCommandSettings: () => settings } as StructuredAgentModelCatalogContext['deps'], environment: { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index bba20e17847d..ef919351db4f 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -21,7 +21,6 @@ import { import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import { @@ -188,12 +187,6 @@ function createClaudeAdapter( ...(deps.resolveClaudePermissionMode ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } : {}), - ...(deps.getClaudeManagedAccountGateSettings - ? { - readClaudeManagedAccountGate: () => - readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) - } - : {}), attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index e28f7f689006..a1095f28ccc9 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -217,7 +217,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index 31dff2777569..6e3e94a3fa14 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -233,7 +233,7 @@ beforeEach(async () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveLaunchArgs: () => [], resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 13b70638b95a..7c07255edb31 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -161,7 +161,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 3824f6edb6de..02c7ae282f27 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -174,7 +174,7 @@ async function startHost(): Promise { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 9db9b37287ca..b57b6a3e1b52 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -277,7 +277,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index e343a45ab211..a9ba53700dd3 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -309,7 +309,7 @@ describe('installing the runtime', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) } as unknown as Parameters[0] ) ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) @@ -326,7 +326,7 @@ describe('installing the runtime', () => { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), logger: { warn: () => { throw new Error('logger broke') diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 3187b44fa84b..c3a39e8eb4db 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -44,7 +44,7 @@ function installHost(): Promise { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}), logger: createStructuredAgentSessionLogger() }) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 50c22f15b7dd..a33bbb33fd1d 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -45,7 +45,7 @@ function install(): ReturnType { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) } diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index ecc32d4f3837..4e4597aab850 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -41,7 +41,7 @@ function install(): ReturnType { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) } diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2842c2b280fd..49ca125b1d12 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -347,7 +347,7 @@ beforeEach(async () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index d9562e2ffefe..f5ed248e736a 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -88,7 +88,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -194,7 +194,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -226,7 +226,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -301,7 +301,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -409,7 +409,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts index 7fe0c5945860..b104e572a6ae 100644 --- a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -50,7 +50,7 @@ describe('the host catalog lifetime', () => { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' as const }) }) expect(service.prewarm).toHaveBeenCalledOnce() expect(service.stop).not.toHaveBeenCalled() diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index c6b663bf0c14..947b73dfdd0c 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -229,7 +229,7 @@ describe('structured agent-session runtime install', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}) }) let finishWrite = (): void => {} @@ -271,7 +271,7 @@ describe('structured agent-session runtime install', () => { resolveWorkspacePath: async () => stateDirectory!, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), readProcessStartTime: async () => 1_700_000_000_000 }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 5c7d87623c2c..7566249677b6 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,7 +34,6 @@ import { import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { installAgentSessionAttachments, stopAgentSessionAttachments @@ -134,8 +133,6 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentLaunchEnv?: (agent: string) => Record /** The settings a per-agent Command override is read from, for the same agents. */ resolveAgentCommandSettings?: () => StructuredAgentCommandSettings - /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ - getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise /** Which login-shell variables Codex and Claude children inherit; absent inherits all. */ resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 7ebc52cd89a7..eb4e962051ef 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -143,7 +143,7 @@ describe('a process that inherited a spawn token', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}) }) // The installed host reads the record the seed wrote, so the check below has a lease to act on. diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index ec800315ed6c..6f4c08e815b7 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -81,7 +81,7 @@ function startupRuntime(log = recordingStructuredAgentSessionLogger()) { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() return runtime diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index d29159ec1116..039b80631dda 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -186,7 +186,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) options.afterInstall?.() return installed diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 6e9faebd1cae..8155b889b37e 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -255,7 +255,7 @@ beforeEach(async () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({ PATH: '/usr/bin' }), openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 02079d90360a..294e2fd227a9 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -124,7 +124,7 @@ describe('the chat strip and the session list read the same host child records', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts index 9971f93ccdbf..8899691f1e68 100644 --- a/src/main/runtime/structured-claude-attachment-grant.test.ts +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -70,7 +70,7 @@ describe("structured Claude's read grant for chat attachments", () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => directory, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveLaunchArgs: () => [], resolveEnvironment: async () => ({}) }) diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts index cc2b5c3b1640..845c5c80dba4 100644 --- a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -8,11 +8,8 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -/** - * The structured host's Claude auth policy has exactly one production wiring. Deleting it - * used to leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached - * the child, because `stripAuthEnv` silently fell back to `false`. - */ +/** The structured host's Claude auth policy has exactly one production wiring; without it a + * failed sign-in would silently name the wrong login. */ describe('structured Claude auth policy wiring', () => { describe('installing without one', () => { let stateDirectory: string | null = null diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 25a0ef4ebe84..90d9b7f19a37 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -118,7 +118,7 @@ beforeEach(async () => { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveClaudeCommand: () => '/usr/local/bin/claude', resolveClaudeLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveClaudeAuthPolicy: () => ({ account: 'system' }), openClaudeConnection: claude.openConnection, readProcessStartTime: async () => HOST_TEST_NOW, onLifecycleEvent: () => {} diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index e55e8dcbd746..865b9e262e3b 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -15,7 +15,6 @@ import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' -import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' @@ -40,7 +39,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting for Claude; absent means prompting. */ resolveClaudePermissionMode?: () => Promise | PermissionMode - readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null /** Where the host stores chat attachments; granted to the agent as a readable directory. */ attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] @@ -120,9 +118,6 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.resolveClaudePermissionMode ? { resolvePermissionMode: deps.resolveClaudePermissionMode } : {}), - ...(deps.readClaudeManagedAccountGate - ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } - : {}), ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), ...(deps.claudeCliFlags ? { cliFlags: deps.claudeCliFlags } : {}), ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 8d7db18ecfd5..96614dc817d7 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -241,7 +241,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { resolveWorkspacePath: async () => directory, resolveClaudeCommand: () => '/usr/local/bin/claude', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveClaudeAuthPolicy: () => ({ account: 'system' }), openClaudeConnection: openConnection, readProcessStartTime }) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 3bf2dbf7d19d..d32fc1f74d1a 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -77,7 +77,7 @@ describe('structured Codex child work through the production runtime', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 52929392d888..c86419e60540 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -214,7 +214,7 @@ export async function openStructuredCodexRpcHarness( resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({ PATH: '/shell/bin:/usr/bin', EXAMPLE_GATEWAY_TOKEN: 'shell-exported', diff --git a/src/main/runtime/structured-foreground-command-strip.test.ts b/src/main/runtime/structured-foreground-command-strip.test.ts index f21054ef1b64..bba1246a1819 100644 --- a/src/main/runtime/structured-foreground-command-strip.test.ts +++ b/src/main/runtime/structured-foreground-command-strip.test.ts @@ -56,7 +56,7 @@ describe('foreground commands in the background-task channel', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: codex.openConnection, diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 9e86ebdd15ca..edc24c7a44a5 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -68,6 +68,8 @@ async function expectWrapperFiles(transport: string, root: string): Promise !activeIds.has(account.id)) .map((account) => ({ id: account.id, - managedAuthPath: account.managedAuthPath, managedAuthRuntime: account.managedAuthRuntime, wslDistro: account.wslDistro, wslLinuxAuthPath: account.wslLinuxAuthPath diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 9c2c3766c221..e7bc3086a307 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -21,11 +21,6 @@ import { setDefaultWslDistroOverride } from '../git/runner' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from '../claude-accounts/live-pty-gate' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -254,21 +249,6 @@ export async function initializeReadyFoundation(): Promise { } } }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void state.rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index e086459a774e..8498ed391429 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -73,9 +73,6 @@ vi.mock('../persistence', () => ({ return {} } onSettingsChanged() {} - getClaudeLivePtySessionIds() { - return [] - } getSshTargets() { return [] } @@ -87,7 +84,6 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ store: { getSettings: () => ({}), onSettingsChanged: () => {}, - getClaudeLivePtySessionIds: () => [], getSshTargets: () => [] } }) @@ -149,11 +145,6 @@ vi.mock('../git/runner', () => ({ vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ wslHookRelayManager: { setManagedHookSettingsResolver: vi.fn() } })) -vi.mock('../claude-accounts/live-pty-gate', () => ({ - attachClaudeLivePtyPersistence: vi.fn(), - onLiveClaudePtysDrained: vi.fn(), - seedLiveClaudePtysFromPersistence: vi.fn() -})) vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) vi.mock('./dev-education-suppression', () => ({ shouldSuppressDevEducation: () => false, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index ef986e43ab87..aedb8dfccada 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -112,7 +112,6 @@ describe('main window profile-state update preparation', () => { expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ codexRuntimeHome: state.codexRuntimeHome, - claudeRuntimeAuth: state.claudeRuntimeAuth, store }) expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 186d73d3a617..1c7603fa6cba 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -93,11 +93,7 @@ export function attachMainWindowCoreServices( onBeforeRelaunch: async () => { state.isQuitting = true state.desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome, - claudeRuntimeAuth, - store - }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) }, onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), // Sign-out is the one fence a paired phone can be told about; quit and @@ -133,7 +129,7 @@ export function attachMainWindowCoreServices( onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) await store.flushPendingOrThrowAsync({ fullCheckpoint: true }) }, onBeforeUpdateQuitFailure: 'abort', diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index 3ebb66c36327..8e05b221d90b 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -234,7 +234,6 @@ describe('prepareLocalCommitMessageAgentEnv', () => { prepareForClaudeLaunch: async () => ({ configDir: '/home/me/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'system' }) }) @@ -242,20 +241,19 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: shellBase }) }) - it('lets the selected Claude account override the shell environment', async () => { + it('lets the selected Claude account set its folder and keeps the shell auth', async () => { const result = await prepareLocalCommitMessageAgentEnv('claude', { resolveBaseEnvironment: async () => shellBase, prepareForClaudeLaunch: async () => ({ configDir: '/managed/claude', envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' }, - stripAuthEnv: true, provenance: 'managed:account-1' }) }) const env = envOf(result) expect(env?.CLAUDE_CONFIG_DIR).toBe('/managed/claude') - expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env?.ANTHROPIC_AUTH_TOKEN).toBe('shell-token') expect(env?.PATH).toBe('/shell/bin') }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index bafd590e8fba..13e3a589d2ba 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -160,9 +160,7 @@ export async function prepareLocalCommitMessageAgentEnv( if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { const preparation = await resolvers.prepareForClaudeLaunch(target) - const env = applyClaudeEnvPatch({ ...base }, preparation.envPatch, { - stripAuthEnv: preparation.stripAuthEnv - }) + const env = applyClaudeEnvPatch({ ...base }, preparation.envPatch) return { ok: true, env } } } catch (error) { diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 229ef1a5414c..1dc52c949e9c 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -145,6 +145,12 @@ export type PtyApi = { /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise + /** A local pane whose daemon lacks the claude function and whose claude runs another account + * than the one selected for the pane's runtime; false for any other pane. */ + openedBeforeClaudeAccounts: ( + id: string, + target: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } + ) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: (scope?: PtySessionListScope) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index e0b2e7f2bf67..e01783382b5d 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -23,6 +23,10 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), stopCodexSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), + openedBeforeClaudeAccounts: ( + id: string, + target: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } + ): Promise => ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id, target }), getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), getSize: (id: string): Promise<{ cols: number; rows: number } | null> => ipcRenderer.invoke('pty:getSize', { id }), diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index e5391829efe1..ab69d444ab85 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -16,6 +16,12 @@ import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { ProviderFrameRow } from './NativeChatTranscriptChrome' +import { Button } from '@/components/ui/button' +import { + isClaudeSignInFailureKind, + nativeChatClaudeSignInLabel, + useNativeChatClaudeSignInView +} from './native-chat-claude-sign-in' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' @@ -46,6 +52,7 @@ export function NativeChatNoticeRow({ }): React.JSX.Element { useTranslation() const orcaStopView = useNativeChatOrcaStopView() + const claudeSignIn = useNativeChatClaudeSignInView() if (block.presentation === 'compaction' || block.presentation === 'context-cleared') { const label = block.presentation === 'context-cleared' @@ -148,6 +155,17 @@ export function NativeChatNoticeRow({ {Icon ?