diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 375dff65e51..25a738a0489 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1275,6 +1275,7 @@ jobs: src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts src/main/wsl/wsl-executable-path.win32.test.ts + src/main/claude-accounts/claude-profile-history-windows.test.ts src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.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 eaf04762e5b..00000000000 --- 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 74d2304b187..26428153c0b 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 66da6d4b1b7..59df0c6b3a4 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 8b2fe9d5ae7..e19fd4a8ad2 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 d4081c3c5da..db2cd31c8b8 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 c8de61a9fda..754e1eee90d 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 ff24125e2c0..076fd8f1a69 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 18850b4a0ef..c73d9a73062 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 05f3da594e0..1b85c0f08d9 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 ff24125e2c0..076fd8f1a69 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 b3c8a92869c..52bbfa7b327 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 21dce8480e6..f52ef39a676 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 0b0ff8ca622..b5056e83606 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 d00641088cc..a41be9a0183 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 8aead85ff78..d3faefdd939 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 00000000000..f449dbcd231 --- /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 00000000000..1d6e60b4e04 --- /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 bebc26be680..13ec68d7dee 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) - 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.') - } - - 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 + // 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 } } - private async persist( - accountId: string, - location: ClaudeManagedAuthLocation, - previousSettings: ReturnType, - captured: CapturedClaudeAuth - ): Promise { - 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 } + } + + async cancel(signIn: Omit): Promise { + if (!this.deps.selection.findAccount(signIn.accountId)) { + await this.deps.runtimeAuth.removeAccountFolder(signIn.accountId, signInTarget(signIn)) } + } + + async finish( + signIn: Omit, + notSignedInMessage = CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE + ): Promise { + 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) - } - - 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) - } + 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() } } + +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 d81f2346eb7..b2601042078 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 f055475506b..00000000000 --- 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 3e17f4c00eb..00000000000 --- 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 09ec7813ffe..00000000000 --- 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 8ed94bebc6e..00000000000 --- 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 641ef58b5be..00000000000 --- 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 8aee047e5fa..00000000000 --- 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 2ad1eb6356d..00000000000 --- 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 d66d1f3ea51..00000000000 --- 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 00000000000..80810512a23 --- /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 2acaa440868..00000000000 --- 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 00000000000..514a8ed4d48 --- /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 78fe109f79f..98b867d02a7 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 00000000000..f462c710c5d --- /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 00000000000..c4cc2f42c60 --- /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 1f2fd963183..00000000000 --- 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 ce9a398bb86..47160b506b2 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 b430979655c..00000000000 --- 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 6727996886f..957542fdd54 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 03f13644fcf..dec67e9e0a9 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 a0c04e3c9ba..f622d60e9df 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 00000000000..9e2d538458e --- /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 f0d04f0fd73..070b486eeb2 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 74ee5222c8e..ece12937405 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 66bf0346d90..30da554f29c 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'), { - mcpServers: { local: { command: 'example' } }, - theme: 'dark', - oauthAccount: { email: 'source' }, - userID: 'source-id' - }) - await provision(f) - expect(fs.existsSync(join(f.profileHome, '.claude.json'))).toBe(false) - 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' }, - userID: 'profile-id', - projects: { '/work': { allowedTools: ['Read'] } } - }) - await provision(f, ['/work']) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - oauthAccount: { email: 'profile' }, - userID: 'profile-id', + const shared = { mcpServers: { local: { command: 'example' } }, theme: 'dark', hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: 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 }) + // 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: { emailAddress: 'profile@example.com' }, + userID: 'profile-id', + groveConfigCache: { b: 2 }, + theme: 'light', + onlyHere: true + }) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + oauthAccount: { emailAddress: 'profile@example.com' }, + userID: 'profile-id', + 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 752f7fee3f3..f637c3e7414 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' -export const CLAUDE_PROFILE_RESOURCE_DIRS = [ - 'skills', - 'plugins', - 'agents', - 'commands', - 'output-styles', - 'rules', - 'themes', - 'workflows' +const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' + +/** + * 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' -]) -const PRIVATE_ENV = new Set([ - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'CLAUDE_CODE_OAUTH_TOKEN' -]) -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] - }) +/** 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 +]) + +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') ) } -function mergeSettings( - source: string, - target: string, - ledger: ClaudeProfileLedger -): ClaudeProfileSurfaceOutcome { +/** + * 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' +]) + +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') + ) +} + +// 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) + 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 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 } + } + } + } + return merged +} + +async function copyState(source: string, target: string): Promise { if (lstatIfPresent(target)?.isSymbolicLink()) { return 'user-owned' } - const existing = readClaudeProfileObject(target) const input = readClaudeProfileObject(source) - if (existing.kind === 'unavailable' || input.kind === 'unavailable') { - throw new ClaudeProfileSurfaceError('unreadable', 'Claude settings.json is unreadable') - } - 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 }) - } - // 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' -} - -async function mergeState(args: { - source: string - target: string - ledger: ClaudeProfileLedger - trustKeys: readonly string[] - report: ClaudeProfileReport -}): Promise { - if (lstatIfPresent(args.target)?.isSymbolicLink()) { - return 'user-owned' - } - const input = readClaudeProfileObject(args.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 - } - // 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 - } - return changed ? { kind: 'changed', config } : { kind: 'unchanged' } - }) - if (outcome === 'missing-config') { - // Why: no state file means no completed login; writing one would fabricate an account. + if (input.kind === 'absent') { return 'absent' } - if (outcome === 'locked' || outcome === 'unreadable') { - throw new ClaudeProfileSurfaceError(outcome, `Profile Claude state is ${outcome}`) + 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 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 JSON.stringify(config) === JSON.stringify(current) + ? { kind: 'unchanged' } + : { kind: 'changed', config } + }) + 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 2a5baae068e..0ceb42b504a 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 5391072d0bb..f65069ebe7a 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) - expect(f.setup.calls).toBe(1) - f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) - + const second = f.router.prepareLaunch() f.setup.outcome = 'refused' - const refused = f.router.prepareLaunch() f.setup.settle() - await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - - // Set up once (setup's marker exists): launches stop waiting. - writeFileSync(join(f.dataRoot, 'claude-profiles', 'a', 'profile.json'), '{}') - await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) - expect(f.setup.calls).toBe(2) + await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) + await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) + expect(f.setup.calls).toBe(1) }) - it('names a never-signed-in folder without creating it, and refuses to launch it', async () => { + 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(first).resolves.toMatchObject({ configDir: f.home('a') }) + + // 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(second).resolves.toMatchObject({ configDir: f.home('a') }) + + // 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(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("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' + // Before its setup, a terminal still opens; its claude function creates the folder itself. + expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + 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')) - expect(f.setup.calls).toBe(0) - expect(existsSync(f.home('b'))).toBe(false) - 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 }) + 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 3b12f6f7c82..3a7d3f81f42 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 0195805eff3..69edd5bc3c1 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 1687d9cf0e8..422dfe163bc 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 69201b62f5b..be7b4509612 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 4bf99fdac51..fb4ed6255e1 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 47629865c54..cab59aee079 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 a5096c040ee..39d2bf67728 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 decde2f9d35..be520cac3ee 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,8 +139,95 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome } }) - await f.router.prepareLaunch('Ubuntu') + // 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 () => { @@ -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 0fba0eb59a6..c71a8c76d2f 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 a2d7c7d5da8..66f5f5d7b5d 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 c30cd69b827..38ed700bc1f 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 69ca24c21a8..00000000000 --- 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 3fd7871c70d..11059a0f7a3 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 3ee2c6b7759..3bd725ba068 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 })) + +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' -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) - - 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) - - 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] - ]) - }) +afterEach(() => { + process.env.USER = originalUser + reads.mockClear() +}) + +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 aa4dcfa64db..14070e6bb63 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 7359bbd9805..00000000000 --- 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 caab66c3430..00000000000 --- 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 2914b72e033..00000000000 --- 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 dd5e01c245a..00000000000 --- 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 cc717ef51ee..00000000000 --- 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 dbccd87cee5..00000000000 --- 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 249e403b075..00000000000 --- 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 efd650618b4..00000000000 --- 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 7a80c5be002..00000000000 --- 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 85d68d30e8b..00000000000 --- 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 94bdab9b4d8..00000000000 --- 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 2806d44e82a..00000000000 --- 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 4066063c562..00000000000 --- 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 f3bb1f00137..00000000000 --- 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 1df2430b8f6..00000000000 --- 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 874e7534ff1..00000000000 --- 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 61c3323b632..00000000000 --- 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 00000000000..25ecbab4a84 --- /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 adbbce30da0..66659ddb53f 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,96 +1,127 @@ +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 + private readonly dataRoot = getAppEnvironment().getPath('userData') + private mutationQueue: Promise = Promise.resolve() - 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() + 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 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) + } } - const router = routerFor(effectiveTarget) - if (router) { - return router.preparation() - } - 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( + /** 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))) + } + }) + } + + /** Creates and sets up an account's folder so Claude can sign in to it. */ + async prepareAccountFolder( + accountId: string, 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 + ): 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 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 { + const effective = this.resolveTarget(target) + const home = + effective.runtime === 'wsl' && effective.wslDistro ? getWslHome(effective.wslDistro) : null + return home ? join(home, '.claude') : this.router.systemDefaultHome() } // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must @@ -101,64 +132,28 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { }) } - /** 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))) - } + /** 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 } - 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() - }) - } - - getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.getPreparation(target).configDir - } - - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - } - - 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) + /** 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 8cb00420dc6..00000000000 --- 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 d029ae0d699..00000000000 --- 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 390ec57a8b2..00000000000 --- 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 8aa33a88c60..00000000000 --- 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 ef4c54acab8..00000000000 --- 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 3a93f63d4dd..00000000000 --- 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 dabcd9d472f..00000000000 --- 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 848ee093dfb..00000000000 --- 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 40d4d45395a..00000000000 --- 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 e184ec4e751..00000000000 --- 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 bb2158d9464..00000000000 --- 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 6d6e5e47a48..00000000000 --- 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 23e9112d7ed..00000000000 --- 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 77f0394e571..73c2a04a50f 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 cd1607e5a31..aa2f168b6ea 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 d9ff5701f26..c164df91d9c 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 eda5b271cb5..fe2d1373ac4 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 d1c79eaf17d..9d64480cfd4 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 }) } - async addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions + reauthenticateAccount(accountId: string): Promise { + return this.signInHidden({ accountId }) + } + + 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 097e1725d12..00000000000 --- 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 e0014f25c4b..00000000000 --- 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 f772b5634c9..00000000000 --- 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 acbd988c614..c34a827fcac 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 456e8938cb6..4a3f2af6ba1 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 2e980cd0477..2f12556444f 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 e6a337a70a6..065d7657455 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 58ef1050a93..29b9b52433b 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 35db45340b0..177d1c6a885 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 e26e0226303..276328b009c 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 5f39b7acb48..4d7aeeb237a 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 227fb117914..c2bee84dfc9 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() - }) + // 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 }) - // 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 + 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(`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') + }) }) - }) - - 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' - }) - // 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 5f787ca6060..8c9cc21cfc8 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 73b871ae2c9..d6162b77f16 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 031559a4438..eda02246f19 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 0df1b2f599c..14afad9b5c4 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 5ada28257e1..963df6ce769 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 1eba82721d2..eb2cf04ae97 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 0411523b191..b7596703601 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 a74677ddacf..7e3ff7c5c95 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 83fc04ba2fb..a518e13d561 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 f4538da4b07..f24757cd6ab 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 e71fd3b5673..58a62d797e6 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 0f96932ecb8..965bde775cd 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 ad7c1edabc8..3356d1a4cda 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 423ee9ee34f..2de5fd5be84 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 540b8494549..2504b90232b 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 22b6510e47f..dd2d2f95136 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 18f6ad6cf49..14b76e2af7a 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 e29fff5330a..e3c1638ff1f 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 f88f094e46b..181164a4646 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 d8b836c91a1..c37f33bc091 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 73154bddab5..a4c4a17d7f8 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 7e4178f35d3..470582f3561 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 bdfd4118182..3dcc6cbe85b 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 d530a9cd194..36c02a0f234 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 00000000000..0164b4a7952 --- /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 00000000000..04ad8c58a02 --- /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 24df59c1201..ffcc5d158bb 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 8adec688a67..55cce790340 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 b6a50c43bbb..4b8d71c0441 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 31bacec82f7..ec13ad0d36e 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 8a362b74aaf..5c4265bdc6e 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 7a494522ec0..eeb5fef028c 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 f70b07dbff8..aa99d191abc 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 7f29e895e0b..5e823ac535c 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 c38637371c3..e6e301aa8af 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 26c3856b45e..6f041b0686b 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 cfbace932ab..88db8879630 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 c82b0656a37..0c8d2371dc0 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 38ec1b05a72..604fd8b00bd 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 8a805edd809..1616008f303 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 98aab1b6ba2..3525e66e5f6 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 ea7837278cb..37f728808b2 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 ed6c35a43a9..c9ecb34dad4 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 8d6aa55783f..00000000000 --- 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 3b42c44c443..00000000000 --- 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 d778f98c247..9b2d56aa49d 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 61dfd787920..a0b61c5cead 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 dc2b40ac1e6..37a1fc03386 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 1f750ff1503..a9890f66164 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 2f0ccdc8165..7f72f2117ab 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 1a3ba807c5a..7045f922de8 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 9cf3d1ec2d1..9e2bed18bc0 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 570c678c464..5cfffc41a72 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,12 +127,6 @@ 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 6254e38d07a..2d4fb6e92d8 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -124,7 +124,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 c7e88c48021..6b9dca94c92 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 63ad18883dc..a1f194e9af0 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 38352a1b6d4..e6e573263ab 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 c5ceade00a4..b905a3fbb30 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 f285ba13ce9..df9867efd3b 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) { + const credentials = await readClaudeOAuthCredentials( + resolveClaudeOAuthCredentialReadOptions(options?.authPreparation) + ) + const metadata = { + credentialSource: credentials.source, + authProvenance: options?.authPreparation?.provenance ?? 'system' + } + // Why: System Default with no Claude login is an API-key or non-Claude user, not a signed-out account. + if ( + !credentials.token && + !credentials.unavailable && + !credentials.hasRefreshableCredentials && + metadata.authProvenance === 'system' + ) { + return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { + ...metadata, + failureKind: 'missing-credentials', + attemptedSources: [] + }) + } + if (!credentials.token) { return makeClaudeUsageResult( 'error', - `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, + credentials.unavailable + ? 'Claude usage is unavailable.' + : 'Sign in again to use this account.', { - attemptedSources: [], - failureKind: 'cli-unavailable', - authProvenance: options.authPreparation.provenance + ...metadata, + failureKind: credentials.unavailable ? 'keychain-unavailable' : 'missing-credentials', + attemptedSources: [] } ) } - - const oauthCredentials = 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() + 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 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 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 - } - } - - if ( - (oauthCredentials.token || - oauthCredentials.hasRefreshableCredentials || - oauthCredentials.keychainUnavailable) && - credentialClassification.shouldAttemptCliFallback && - allowCliFallback - ) { - 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' - }) - }) - } - - if (oauthCredentials.hasRefreshableCredentials) { - return makeClaudeUsageResult('error', 'Claude OAuth access token unavailable', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: credentialClassification.failureKind - }) - }) - } - - 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) - } - } - - 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 70d264a4e68..00000000000 --- 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 428a2910694..00000000000 --- 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 2a84cc4f2af..00000000000 --- 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 9a75d3826e5..00000000000 --- 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 c01bcb00f6e..00000000000 --- 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 f679d838833..00000000000 --- 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 42427766a26..9b03a2eb971 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 71d505510cf..00000000000 --- 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 ddf7b3df750..e94265de7f6 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() + 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() } - 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 (!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 15c1e90169c..00000000000 --- 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 3e14fb2909e..7fa4d04fbb9 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' - ) - try { - return parseClaudeOAuthCredentialsJson( - await readFile(credentialPath, 'utf-8'), - 'credentials-file' - ) - } catch { - return emptyClaudeOAuthCredentialReadResult() + if (!options?.keychainConfigDir) { + return readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') } + const scoped = await readClaudeCredentialsFromStrictKeychain( + options.keychainConfigDir, + 'scoped-keychain' + ) + 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 58c6bf91cb3..00000000000 --- 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 fb56512bd8c..9911343080c 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 - } - } + net: { fetch: netFetchMock }, + session: { defaultSession: {} } })) -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn(async () => {}) })) -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -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 00000000000..a87ddf4c929 --- /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 43137a8cea5..00000000000 --- 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 2bba56c6911..00000000000 --- 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 3328d947160..00000000000 --- 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 f2fd976821b..00000000000 --- 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 cbaedbd17f9..00000000000 --- 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 9371479cfba..5e60c281674 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 71c88c242bd..440d1155d24 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 dced9e7815c..00000000000 --- 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 06144a15715..00000000000 --- 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 a29cb38d4aa..e1863be724d 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 43e8453fa0f..00000000000 --- 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 068b937ee84..00000000000 --- 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 27e93208c1a..dba478291d7 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 cae71c569e7..ca05c7c0fb9 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 8c35ab77acc..01c341d00f5 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 6aeacd99583..944c09be247 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 cfd4a6e9d58..907f0512f00 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 35005c3badf..6b69d584800 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 bc44b74a8a1..a2eaee178c4 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 7fd0dec42ad..c7e3c1d79a1 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 2fe58e13de3..8002bac661d 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -247,8 +247,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 4b63ec021c8..7199bd74a17 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 64b451e9ea9..00000000000 --- 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 0d9b12f7c52..00000000000 --- 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 b8473d00e01..c10d70b7ad9 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 10c39aacd2f..7c97c36ac40 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 c824301e5a5..248adf2c18f 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 c62fc799dd4..966976e1a94 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 3ba29e29e75..5eb76a9168d 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 aecff69f178..97f25812072 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 6037c26e573..5b266396d3a 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 d6d1feddb58..12e65aca574 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 544676dc929..9845c33166b 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 bba20e17847..ef919351db4 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 e28f7f68900..a1095f28ccc 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 31dff277756..6e3e94a3fa1 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 13b70638b95..7c07255edb3 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 3824f6edb6d..02c7ae282f2 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 9db9b37287c..b57b6a3e1b5 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 e343a45ab21..a9ba53700dd 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 3187b44fa84..c3a39e8eb4d 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 50c22f15b7d..a33bbb33fd1 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 ecc32d4f383..4e4597aab85 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 2842c2b280f..49ca125b1d1 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 d9562e2ffef..f5ed248e736 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 7fe0c594586..b104e572a6a 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 c6b663bf0c1..947b73dfdd0 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 5c7d87623c2..7566249677b 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 7ebc52cd89a..eb4e962051e 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 ec800315ed6..6f4c08e815b 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 d29159ec111..039b80631dd 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 6e9faebd1ca..8155b889b37 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 02079d90360..294e2fd227a 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 9971f93ccdb..8899691f1e6 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 cc2b5c3b164..845c5c80dba 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 25a0ef4ebe8..90d9b7f19a3 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 e55e8dcbd74..865b9e262e3 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 8d7db18ecfd..96614dc817d 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 3bf2dbf7d19..d32fc1f74d1 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 52929392d88..c86419e6054 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 f21054ef1b6..bba1246a181 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 9e86ebdd15c..edc24c7a44a 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 9c2c3766c22..e7bc3086a30 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 e086459a774..8498ed39142 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 ef986e43ab8..aedb8dfccad 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 186d73d3a61..1c7603fa6cb 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 3ebb66c3632..8e05b221d90 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 bafd590e8fb..13e3a589d2b 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 229ef1a5414..1dc52c949e9 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 e0b2e7f2bf6..e01783382b5 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 e5391829efe..ab69d444ab8 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 ?