diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0d27ea92204..6f957ce9fe3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1275,7 +1275,6 @@ 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 new file mode 100644 index 00000000000..eaf04762e5b --- /dev/null +++ b/config/scripts/claude-account-windows-spawn-repro.mjs @@ -0,0 +1,342 @@ +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 fc0894d58f6..d9ee133a70e 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/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 +// (`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 // branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 59df0c6b3a4..66da6d4b1b7 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; needsSignIn?: true }[] + accounts: readonly { id: string; email: string }[] activeAccountId: string | null activeAccountIdsByRuntime?: { host: string | null @@ -35,10 +35,7 @@ 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.needsSignIn ? ' (sign in again in Orca Settings > AI Provider Accounts)' : '' - }` + (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` ) 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 e19fd4a8ad2..8b2fe9d5ae7 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -1,5 +1,6 @@ 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' @@ -62,10 +63,7 @@ import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' -import { - ACCOUNT_IMPORT_RUNTIME_CAPABILITY, - CLAUDE_SIGN_IN_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' function successfulChild(): EventEmitter { const child = new EventEmitter() @@ -133,14 +131,8 @@ describe('account CLI handlers', () => { ok: true, result: method === 'status.get' - ? { - 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' - ), + ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } + : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), _meta: { runtimeId: 'test-runtime' } }) ) @@ -314,17 +306,37 @@ describe('account CLI handlers', () => { expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) }) - it('runs login in the final profile and never reads, writes or cleans up Keychain credentials', async () => { + it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + await ACCOUNT_HANDLERS['account add'](context('claude')) - 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(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(readKeychainMock).not.toHaveBeenCalled() - expect(writeKeychainMock).not.toHaveBeenCalled() - expect(deleteKeychainMock).not.toHaveBeenCalled() + + const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR + expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { + configDir, + runtime: 'wsl', + wslDistro: 'Ubuntu-22.04' + }) }) it('attributes a Codex account added through the WSL bridge to the cwd distro', async () => { @@ -352,9 +364,8 @@ describe('account CLI handlers', () => { ) expect(callMock).toHaveBeenCalledWith( - 'accounts.beginClaudeSignIn', - expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }), - { timeoutMs: 300000 } + 'accounts.addClaudeFromConfigDir', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }) ) }) @@ -364,14 +375,10 @@ 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.mock.calls.some( - ([method, params]) => - method === - (agent === 'claude' ? 'accounts.beginClaudeSignIn' : 'accounts.addCodexFromHome') && - params.wslDistro === 'Ubuntu Work' - ) - ).toBe(true) + expect(callMock).toHaveBeenCalledWith( + agent === 'claude' ? 'accounts.addClaudeFromConfigDir' : 'accounts.addCodexFromHome', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu Work' }) + ) } ) @@ -463,6 +470,62 @@ 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". @@ -477,9 +540,7 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { - capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] - }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -508,6 +569,32 @@ 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')) @@ -519,40 +606,22 @@ describe('account CLI handlers', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('still adds Codex through an older host that advertises only the retired import capability', async () => { + it('fails before login when the running runtime predates account imports', async () => { callMock.mockResolvedValue({ id: 'test', ok: true, - result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + result: { capabilities: [] }, _meta: { runtimeId: 'test-runtime' } }) - spawnMock.mockImplementation(() => { - throw new Error('stop after the capability gate') - }) - await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow() - expect(spawnMock).toHaveBeenCalled() + 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() }) - 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) => { @@ -598,9 +667,7 @@ describe('account CLI handlers', () => { ? Promise.resolve({ id: 'test', ok: true, - result: { - capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CLAUDE_SIGN_IN_RUNTIME_CAPABILITY] - }, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, _meta: { runtimeId: 'test-runtime' } }) : method === 'accounts.list' @@ -632,14 +699,15 @@ describe('account CLI handlers', () => { expect(logSpy).not.toHaveBeenCalled() }) - 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('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('rejects `--agent` with no value instead of defaulting to Claude', async () => { @@ -673,32 +741,6 @@ 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 db2cd31c8b8..d4081c3c5da 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -1,4 +1,5 @@ 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' @@ -8,10 +9,10 @@ import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell -} from '../../shared/wsl-login-shell-command' + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from '../../main/claude-accounts/keychain' import { getVersionManagerBinPaths, resolveCliCommand, @@ -23,13 +24,9 @@ import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' -import { - ACCOUNT_IMPORT_RUNTIME_CAPABILITY, - CLAUDE_SIGN_IN_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { - ClaudeAccountSignIn, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, ManagedDataAccountsState @@ -143,53 +140,79 @@ async function runAgentLoginInTerminal( }) } -/** Signs in to a new account folder the host created, then registers it (superset AddAccountDialog). */ +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. */ async function addClaudeAccount({ client, cwd, json }: HandlerContext): Promise { - const { result: signIn } = await client.call( - 'accounts.beginClaudeSignIn', - getWslAccountTarget(cwd) ?? {}, - { timeoutMs: 300_000 } - ) + const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) 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 () => { - 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)) + await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) }, async () => { - 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 - ) + if (process.platform === 'darwin') { + legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() + restoreLegacyCredentials = true } - session.registering = true - return client.call( - 'accounts.finishClaudeSignIn', - { accountId: signIn.accountId, runtime: signIn.runtime, wslDistro: signIn.wslDistro }, - { timeoutMs: 300_000 } + 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 + } + : {}) + }) } ) printResult(result, json, (state) => formatAccountsBlock('Claude', state)) @@ -242,14 +265,9 @@ function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string) ) } -async function assertAccountImportSupported( - { client }: HandlerContext, - agent: 'claude' | 'codex' -): Promise { +async function assertAccountImportSupported({ client }: HandlerContext): Promise { const status = await client.call('status.get') - const capability = - agent === 'claude' ? CLAUDE_SIGN_IN_RUNTIME_CAPABILITY : ACCOUNT_IMPORT_RUNTIME_CAPABILITY - if (!status.result.capabilities?.includes(capability)) { + if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_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.' @@ -282,7 +300,7 @@ export const ACCOUNT_HANDLERS: Record = { return } // Why: fail on runtime version skew before burning a full OAuth round trip. - await assertAccountImportSupported(ctx, agent) + await assertAccountImportSupported(ctx) 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 eb280f1900b..c8de61a9fda 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -138,30 +138,6 @@ 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 - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; 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 42fb21032ec..ff24125e2c0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -17,34 +17,4 @@ if test "$__orca_codex_type" = file command codex $argv end end -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 - set -l headers - if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" - set headers -u ANTHROPIC_CUSTOM_HEADERS - end - env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv - end -end -set -e __orca_claude_type +set -e __orca_codex_type \ No newline at end of file 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 22c221bb943..18850b4a0ef 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -220,30 +220,6 @@ __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 - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; 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 a43c7bf2c39..05f3da594e0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -148,30 +148,6 @@ 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 - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; 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 42fb21032ec..ff24125e2c0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -17,34 +17,4 @@ if test "$__orca_codex_type" = file command codex $argv end end -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 - set -l headers - if string match -irq 'authorization|x-api-key|api-key|bearer' -- "$ANTHROPIC_CUSTOM_HEADERS" - set headers -u ANTHROPIC_CUSTOM_HEADERS - end - env $headers -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u AWS_BEARER_TOKEN_BEDROCK CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv - end -end -set -e __orca_claude_type +set -e __orca_codex_type \ No newline at end of file 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 e6e492c48bc..b3c8a92869c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -227,30 +227,6 @@ __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 - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; 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 1fcf476a1a0..21dce8480e6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -190,30 +190,6 @@ __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 - ( unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AWS_BEARER_TOKEN_BEDROCK; case "${ANTHROPIC_CUSTOM_HEADERS:-}" in *[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]*|*[xX]-[aA][pP][iI]-[kK][eE][yY]*|*[aA][pP][iI]-[kK][eE][yY]*|*[bB][eE][aA][rR][eE][rR]*) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; 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 b5056e83606..0b0ff8ca622 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 and flushes the store', async () => { + it('syncs Codex then Claude before flushing the store', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ @@ -17,6 +17,11 @@ describe('preserveAgentAuthBeforeRestart', () => { calls.push('codex') }) }, + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(async () => { + calls.push('claude') + }) + }, store: { flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') @@ -24,7 +29,7 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex', 'flush']) + expect(calls).toEqual(['codex', 'claude', 'flush']) }) it('drains retained WSL Codex auth before flushing the store', async () => { @@ -101,6 +106,27 @@ 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() @@ -122,15 +148,83 @@ 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(1) + expect(warn).toHaveBeenCalledTimes(2) 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 a41be9a0183..d00641088cc 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -1,3 +1,4 @@ +import type { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import type { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import type { Store } from './persistence' @@ -5,17 +6,23 @@ const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 type CodexRuntimeAuthSync = Pick & Partial> +type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick -type AuthPreservationStep = 'Codex auth preservation' | 'Store persistence' +type AuthPreservationStep = + | 'Codex auth preservation' + | 'Claude 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() @@ -23,6 +30,17 @@ 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/claude-accounts/claude-account-folder.test.ts b/src/main/claude-accounts/claude-account-folder.test.ts deleted file mode 100644 index 5374afed213..00000000000 --- a/src/main/claude-accounts/claude-account-folder.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, 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, 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!') - }) - - // 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 deleted file mode 100644 index 7e4bfe8e3ef..00000000000 --- a/src/main/claude-accounts/claude-account-folder.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - existsSync, - lstatSync, - readdirSync, - rmdirSync, - rmSync, - statSync, - unlinkSync -} from 'node:fs' -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 } ->() - -/** 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 -} - -/** - * 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 13ec68d7dee..bebc26be680 100644 --- a/src/main/claude-accounts/claude-account-registration.ts +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -1,152 +1,250 @@ import { randomUUID } from 'node:crypto' import type { - ClaudeAccountSignIn, - ClaudeRateLimitAccountsState, - ClaudeSignInRequest + ClaudeManagedAccount, + ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' -import type { - ClaudeAccountRuntime, - ClaudeAccountSelection, - ClaudeAccountStore, - ClaudeAccountUsage -} from './claude-account-selection' -import type { ClaudeCommandConfig } from './claude-command-process' +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 type { + ClaudeManagedAuthLocation, + ClaudeManagedAuthSnapshot, + ClaudeManagedAuthTarget +} from './claude-managed-auth-storage' +import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget + normalizeClaudeRuntimeSelection } from './runtime-selection' +import type { ClaudeAccountSelection } from './claude-account-selection' -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.' +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 {} -/** - * 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 deps: { - store: ClaudeAccountStore - rateLimits: ClaudeAccountUsage - runtimeAuth: ClaudeAccountRuntime - selection: ClaudeAccountSelection - } - ) {} + constructor(private readonly dependencies: ClaudeAccountRegistrationDependencies) {} - /** 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 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 + } } - /** Runs `login` into the account's folder, then saves it; a failed new folder is deleted. */ - async signIn( - request: ClaudeSignInRequest, - login: (folder: ClaudeCommandConfig) => Promise + async addFromConfigDir( + configDir: string, + options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null } ): Promise { - const { accountId, target, folder } = await this.prepare(request) - const signIn = { accountId, ...target } + const accountId = randomUUID() + const location = await this.dependencies.createManagedAuth(accountId, options) + const previousSettings = this.dependencies.store.getSettings() try { - 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) + const captured = await this.dependencies.captureExisting( + configDir, + options?.previousLegacyCredentialsSha256 + ) + return await this.persist(accountId, location, previousSettings, captured) } catch (error) { - // Why caught: the sign-in's own failure is what the user needs to see. - await this.cancel(signIn).catch((cleanupError: unknown) => - console.warn( - '[claude-accounts] Could not delete an unfinished sign-in folder:', - cleanupError - ) + 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 ) throw error } } - 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 + private async persist( + accountId: string, + location: ClaudeManagedAuthLocation, + previousSettings: ReturnType, + captured: CapturedClaudeAuth ): 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) + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') } - const accounts = store.getSettings().claudeManagedAccounts - const scope = { - managedAuthRuntime: target.runtime ?? 'host', - wslDistro: target.runtime === 'wsl' ? (target.wslDistro ?? null) : null - } as const if ( - !saved && - findDuplicateClaudeAccount(accounts, { - email: login.email, - organizationUuid: login.organizationUuid, - ...scope + findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { + email: captured.identity.email, + organizationUuid: captured.identity.organizationUuid, + managedAuthRuntime: location.managedAuthRuntime, + wslDistro: location.wslDistro }) ) { - await runtimeAuth.removeAccountFolder(signIn.accountId, target) - throw new Error('This Claude account is already added.') + throw new DuplicateClaudeAccountError('This Claude account is already added.') } + await this.dependencies.writeManagedAuth(accountId, location.managedAuthPath, captured) const now = Date.now() - 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 - } - ] + 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 }) - 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() + this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) + return this.dependencies.selection.snapshot() } -} -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.') + 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) + } } - 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 c0b089e902c..d81f2346eb7 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -3,10 +3,9 @@ import type { ClaudeManagedAccountSummary, ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' -import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' -import { claudeStateFile, readClaudeFolderLogin } from './claude-account-folder' -import { savedWslClaudeAccountHome } from './claude-profile-wsl-paths' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -19,137 +18,59 @@ 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' - > -} - -export type ClaudeAccountUsage = Pick< - RateLimitService, - 'evictInactiveClaudeCache' | 'refreshForClaudeAccountChange' -> - export class ClaudeAccountSelection { constructor( - private readonly store: ClaudeAccountStore, - private readonly rateLimits: ClaudeAccountUsage, - private readonly runtimeAuth: ClaudeAccountRuntime + private readonly store: Store, + private readonly rateLimits: RateLimitService, + private readonly runtimeAuth: ClaudeRuntimeAuthService, + private readonly removeManagedAuth: (accountId: string, path: string) => Promise ) {} - /** Each row is labelled with the login its folder holds (superset U/profiles.ts:121-137). */ list(): ClaudeRateLimitAccountsState { - 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)) - ) - return login - ? { - ...summary, - email: login.email, - organizationUuid: login.organizationUuid, - organizationName: login.organizationName - } - : { ...summary, needsSignIn: true } + this.normalizeActiveSelection() + return this.snapshot() } async remove(accountId: string): Promise { const account = this.requireAccount(accountId) const settings = this.store.getSettings() - const target = getClaudeSelectionTargetForAccount(account) - const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId + const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) const nextSelection = removeClaudeAccountIdFromSelection( normalizeClaudeRuntimeSelection(settings), 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) + const nextActiveId = + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host + const target = getClaudeSelectionTargetForAccount(account) + const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId try { - await this.runtimeAuth.removeAccountFolder(accountId, target) + 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() } catch (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.restoreSettings(settings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error } - 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( @@ -160,90 +81,111 @@ export class ClaudeAccountSelection { if (accountId !== null) { const account = this.requireAccount(accountId) const accountTarget = getClaudeSelectionTargetForAccount(account) - const requested = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) - const owned = normalizeClaudeAccountSelectionTarget(accountTarget) + const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) if ( - requested.runtime !== owned.runtime || - (requested.wslDistro !== null && requested.wslDistro !== owned.wslDistro) + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.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 previous = this.store.getSettings() - const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previous, effectiveTarget) + const previousSettings = this.store.getSettings() + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) const nextSelection = setSelectedClaudeAccountIdForTarget( - normalizeClaudeRuntimeSelection(previous), + normalizeClaudeRuntimeSelection(previousSettings), accountId, effectiveTarget ) - this.saveSettings({ + this.store.updateSettings({ activeClaudeManagedAccountId: effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, activeClaudeManagedAccountIdsByRuntime: nextSelection }) try { - await this.runtimeAuth.syncForCurrentSelection(effectiveTarget) + await this.syncRuntimeAuth(effectiveTarget) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) + return this.snapshot() } catch (error) { - this.saveSettings({ - activeClaudeManagedAccountId: previous.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: previous.activeClaudeManagedAccountIdsByRuntime - }) - await this.runtimeAuth.publishAll().catch((rollbackError: unknown) => { - console.warn('[claude-accounts] Rollback republish failed:', rollbackError) - }) + this.restoreSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() throw error } - this.refreshUsage(outgoingAccountId, effectiveTarget) - return this.list() } - /** 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) + 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) + } } requireAccount(accountId: string): ClaudeManagedAccount { - const account = this.findAccount(accountId) + const account = this.store + .getSettings() + .claudeManagedAccounts.find((entry) => entry.id === accountId) if (!account) { throw new Error('That Claude account no longer exists.') } return account } - // 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 }) + restoreSettings(settings: ReturnType): void { + this.store.updateSettings({ + claudeManagedAccounts: settings.claudeManagedAccounts, + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime + }) } - private pruneSelection(): void { + async syncRuntimeAuth( + target?: ClaudeAccountSelectionTarget, + operation?: () => Promise + ): Promise { + beginClaudeAuthSwitch() + try { + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) + } finally { + endClaudeAuthSwitch() + } + } + + private normalizeActiveSelection(): void { const settings = this.store.getSettings() - const current = normalizeClaudeRuntimeSelection(settings) - const next = pruneInvalidClaudeRuntimeSelection(current, settings.claudeManagedAccounts) + const currentSelection = normalizeClaudeRuntimeSelection(settings) + const nextSelection = pruneInvalidClaudeRuntimeSelection( + currentSelection, + settings.claudeManagedAccounts + ) if ( - next.host !== settings.activeClaudeManagedAccountId || - JSON.stringify(next) !== JSON.stringify(current) + nextSelection.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(nextSelection) !== JSON.stringify(currentSelection) ) { this.store.updateSettings({ - activeClaudeManagedAccountId: next.host, - activeClaudeManagedAccountIdsByRuntime: next + activeClaudeManagedAccountId: nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection }) } } } + +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 new file mode 100644 index 00000000000..f055475506b --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-account-selection.test.ts @@ -0,0 +1,488 @@ +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 new file mode 100644 index 00000000000..3e17f4c00eb --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-add-account.test.ts @@ -0,0 +1,373 @@ +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 new file mode 100644 index 00000000000..09ec7813ffe --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-api-parity.test.ts @@ -0,0 +1,90 @@ +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 new file mode 100644 index 00000000000..8ed94bebc6e --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts @@ -0,0 +1,250 @@ +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 new file mode 100644 index 00000000000..641ef58b5be --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-credential-capture.test.ts @@ -0,0 +1,150 @@ +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 new file mode 100644 index 00000000000..8aee047e5fa --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -0,0 +1,684 @@ +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 new file mode 100644 index 00000000000..2ad1eb6356d --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts @@ -0,0 +1,420 @@ +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 new file mode 100644 index 00000000000..d66d1f3ea51 --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-test-harness.ts @@ -0,0 +1,41 @@ +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 deleted file mode 100644 index 55c4304ca62..00000000000 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -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 } }) - ) - } - 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')) - }, - 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, 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("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 new file mode 100644 index 00000000000..2acaa440868 --- /dev/null +++ b/src/main/claude-accounts/claude-auth-capture.ts @@ -0,0 +1,176 @@ +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 deleted file mode 100644 index 514a8ed4d48..00000000000 --- a/src/main/claude-accounts/claude-command-process.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 98b867d02a7..78fe109f79f 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -4,13 +4,7 @@ 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' @@ -25,10 +19,15 @@ 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, @@ -150,7 +149,7 @@ export function runClaudeCommandProcess( return } settle(() => { - if (code === 0) { + if (code === 0 || options?.allowFailure) { resolvePromise(output) return } @@ -189,9 +188,12 @@ 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 } + return { + CLAUDE_CONFIG_DIR: configDir, + // Why: Claude Code 2.1.220+ hashes this for the Keychain service name. + CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir + } } function resolveClaudeInvocation( @@ -212,15 +214,15 @@ function resolveClaudeInvocation( } : configDir.linuxPath && configDir.wslDistro ? { - // 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(' ')}` - ) - ]), + 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(' ')}` + ], 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 deleted file mode 100644 index 4156b58ced4..00000000000 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -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' - ], - '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': ['mkdirSync', 'rmSync', 'writeFileAtomically'], - '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-login-completion.oracle.test.ts b/src/main/claude-accounts/claude-login-completion.oracle.test.ts new file mode 100644 index 00000000000..1f2fd963183 --- /dev/null +++ b/src/main/claude-accounts/claude-login-completion.oracle.test.ts @@ -0,0 +1,192 @@ +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 47160b506b2..ce9a398bb86 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -1,6 +1,19 @@ +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: ( @@ -9,14 +22,19 @@ 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( - folder: ClaudeCommandConfig, + location: ClaudeManagedAuthLocation, dependencies: ClaudeLoginSessionDependencies -): Promise { +): Promise { + const tempConfig = await createTemporaryClaudeConfigDir(location) const controller = new AbortController() dependencies.setCancel(() => { if (controller.signal.aborted) { @@ -25,12 +43,107 @@ 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 { - await dependencies.runCommand(['auth', 'login', '--claudeai'], folder, LOGIN_TIMEOUT_MS, { + if (controller.signal.aborted) { + throw new Error('Claude sign-in was cancelled.') + } + await dependencies.runCommand(['auth', 'login', '--claudeai'], tempConfig, 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 new file mode 100644 index 00000000000..b430979655c --- /dev/null +++ b/src/main/claude-accounts/claude-managed-auth-storage.ts @@ -0,0 +1,285 @@ +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-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index c4efc9f39e4..a0c04e3c9ba 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -26,7 +26,7 @@ export function listClaudeProfileHomes(dataRoot: string): string[] { let installed: ClaudeProfileRouter | undefined -/** Installed by the desktop's account services; workers, child processes and orcad have none. */ +/** Installed by the host runtime only when routing is enabled; workers and child processes have none. */ export function installClaudeProfileRouter(router: ClaudeProfileRouter | undefined): void { installed = router } @@ -46,8 +46,8 @@ export function withClaudeProfileTerminalEnv } /** - * 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. + * 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. */ 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-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index 070b486eeb2..f0d04f0fd73 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -12,6 +12,7 @@ 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, @@ -56,16 +57,10 @@ describe('Claude profile namespace', () => { target, home: join(dir, 'claude-profiles/account-a/home') }) - 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' - }) + 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' }) // The host id is the caller's view of the host, so another caller's spelling is the same profile. prepareClaudeProfileDirectory( dir, @@ -126,9 +121,11 @@ describe('Claude profile namespace', () => { it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { const userHome = root() mkdirSync(join(userHome, '.claude')) - const dataRoot = join(userHome, '.CLAUDE', 'orca') + expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( + 'separate directories' + ) expect(() => - prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) + assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), 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 ece12937405..74ee5222c8e 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -1,10 +1,6 @@ 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' @@ -97,23 +93,23 @@ function readOwnershipMarker(file: string): string | null { return ownershipRecord(version, accountId, runtime, distro) } -// Written when a first setup finishes, so its absence means no setup has finished here yet. +/** + * 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. 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) @@ -139,14 +135,12 @@ export function prepareClaudeProfileDirectory( ) } mkdirSync(profile.home, { recursive: true, mode: 0o700 }) - return () => { - if (marker === null) { - writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) - } + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) } } -function assertClaudeProfileDescendant(root: string, destination: string): void { +export 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( @@ -190,7 +184,7 @@ function canonicalPath(file: string): string { } } -function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { +export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { const left = canonicalPath(profile) const right = canonicalPath(defaultHome) for (const [root, destination] of [ @@ -208,7 +202,7 @@ function assertDistinctClaudeProfile(profile: string, defaultHome: string): void } /** Claude's default homes: a profile may never be, contain, or sit inside one. */ -function assertOutsideDefaultClaudeHomes( +export function assertOutsideDefaultClaudeHomes( profileHome: string, userHome: string, userConfigDir?: string @@ -220,18 +214,16 @@ 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) { + if (!configDir || (injected && resolve(injected) === resolve(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 4bc12a4738a..66bf0346d90 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -13,15 +13,13 @@ vi.mock('../codex-accounts/fs-utils', async (original) => { return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } }) import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { provisionClaudeProfile } from './claude-profile-provisioning' +import { CLAUDE_PROFILE_MEMORY_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-'))) @@ -35,8 +33,13 @@ 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 }) => - provisionClaudeProfile({ profileHome: f.profileHome, userHome: f.userHome, platform: 'linux' }) +const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => + provisionClaudeProfile({ + profileHome: f.profileHome, + userHome: f.userHome, + trustKeys, + platform: 'linux' + }) afterEach(() => { vi.clearAllMocks() for (const dir of roots.splice(0)) { @@ -216,7 +219,7 @@ describe('dormant Claude profile provisioning', () => { 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', oauthAccount: LOGIN }) + 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') @@ -230,18 +233,17 @@ describe('dormant Claude profile provisioning', () => { await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p', - oauthAccount: LOGIN, theme: 'dark', hasCompletedOnboarding: true }) }) - it('requires existing state, merges MCP/theme/onboarding and never copies or writes credentials', async () => { + it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes 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: { emailAddress: 'source@example.com' }, + oauthAccount: { email: 'source' }, userID: 'source-id' }) await provision(f) @@ -249,18 +251,18 @@ describe('dormant Claude profile provisioning', () => { 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' }, + oauthAccount: { email: 'profile' }, userID: 'profile-id', projects: { '/work': { allowedTools: ['Read'] } } }) - await provision(f) + await provision(f, ['/work']) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - oauthAccount: { emailAddress: 'profile@example.com' }, + oauthAccount: { email: 'profile' }, userID: 'profile-id', mcpServers: { local: { command: 'example' } }, theme: 'dark', hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'] } } + projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } }) expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( 'SOURCE_CREDENTIAL_BYTES' @@ -275,50 +277,44 @@ describe('dormant Claude profile provisioning', () => { } } }) - it('leaves a folder Claude has not signed in yet to Claude’s own onboarding', async () => { - const f = fixture() - f.json(join(f.userHome, '.claude.json'), { theme: 'dark', mcpServers: { a: {} } }) - // Claude writes its state file at the theme pick, before the sign-in. - f.json(join(f.profileHome, '.claude.json'), { theme: 'light' }) - expect((await provision(f)).surfaces['.claude.json']).toBe('absent') - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ theme: 'light' }) - f.json(join(f.profileHome, '.claude.json'), { theme: 'light', oauthAccount: LOGIN }) - expect((await provision(f)).surfaces['.claude.json']).toBe('merged') - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - theme: 'light', - oauthAccount: LOGIN, - mcpServers: { a: {} }, - hasCompletedOnboarding: true - }) - }) - it('still forces onboarding when the personal state is unreadable', async () => { + it('still forces onboarding and trust when the personal state is unreadable', async () => { const f = fixture() fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) - const report = await provision(f) + 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', - oauthAccount: LOGIN, - hasCompletedOnboarding: true + hasCompletedOnboarding: true, + projects: { '/work': { hasTrustDialogAccepted: true } } }) 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') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + projects: 'bad', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) 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', oauthAccount: LOGIN }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) 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', - oauthAccount: LOGIN - }) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) 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') @@ -376,7 +372,9 @@ describe('dormant Claude profile provisioning', () => { 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/CLAUDE.md\n') + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( + CLAUDE_PROFILE_MEMORY_IMPORT + ) fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') }) @@ -390,7 +388,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', oauthAccount: LOGIN }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) 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') diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 8c72cf291a1..752f7fee3f3 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -2,10 +2,10 @@ import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { + applyClaudeFolderTrust, resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' -import { claudeStateLogin } from './claude-account-folder' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' import { lstatIfPresent } from './claude-profile-prompt-history' import { @@ -39,7 +39,7 @@ export const CLAUDE_PROFILE_RESOURCE_DIRS = [ ] 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 -const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' const PRIVATE_KEYS = new Set([ 'apiKeyHelper', 'awsAuthRefresh', @@ -103,6 +103,7 @@ async function mergeState(args: { source: string target: string ledger: ClaudeProfileLedger + trustKeys: readonly string[] report: ClaudeProfileReport }): Promise { if (lstatIfPresent(args.target)?.isSymbolicLink()) { @@ -110,7 +111,7 @@ async function mergeState(args: { } const input = readClaudeProfileObject(args.source) if (input.kind === 'unavailable') { - // Why: onboarding doesn't depend on the personal state; only its shared keys wait. + // 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) } @@ -119,15 +120,7 @@ async function mergeState(args: { SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) ) let written: Record = {} - let signedOut = false const outcome = await updateClaudeGlobalConfig(args.target, (current) => { - // Why oauthAccount: Claude writes it only on a finished sign-in, while it writes this file - // earlier in onboarding (theme pick). It's read under Claude's own lock, on every platform; - // credentials sit in the Keychain on macOS. A flag set before then skips Claude's sign-in. - signedOut = !claudeStateLogin(current) - if (signedOut) { - return { kind: 'unchanged' } - } const config = { ...current } written = { ...args.ledger.keys['.claude.json'] } let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 @@ -142,10 +135,15 @@ async function mergeState(args: { 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' || (outcome === 'unchanged' && signedOut)) { - // Why: no completed login yet; writing would fabricate an account or skip Claude's sign-in. + if (outcome === 'missing-config') { + // Why: no state file means no completed login; writing one would fabricate an account. return 'absent' } if (outcome === 'locked' || outcome === 'unreadable') { @@ -165,6 +163,7 @@ 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) @@ -211,6 +210,7 @@ export async function provisionClaudeProfile(args: { source: statePath(args.userConfigDir), target: statePath(args.profileHome), ledger, + trustKeys: args.trustKeys ?? [], report }) ) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 3db8218f016..5391072d0bb 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -1,6 +1,4 @@ -import { spawnSync } from 'node:child_process' import { - chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -11,44 +9,22 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } 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 -} from '../../shared/claude-profile-routing' -import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' -import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + 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 })) }) @@ -76,19 +52,13 @@ function fixture(env: NodeJS.ProcessEnv = {}) { agentStatusHooksEnabled: false, disabledTuiAgents: [] } - // Stands in for the worker; like it, stays pending until settled and marks the folder last. + // Stands in for the worker; each setup stays pending until the test settles it. const setup: { calls: number; outcome: ClaudeProfileSetupReport['outcome']; settle: () => void } = { calls: 0, outcome: 'prepared', settle: () => {} } - const runSetup = ({ profile }: { profile: ClaudeProfileDescriptor }) => { + const runSetup = () => { setup.calls += 1 - mkdirSync(profile.home, { recursive: true }) return new Promise((resolve) => { - setup.settle = () => { - if (setup.outcome === 'prepared') { - writeFileSync(claudeProfileMarkerPath(profile), '{}') - } - resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) - } + setup.settle = () => resolve({ outcome: setup.outcome, warnings: [], surfaces: {} }) }) } const router = new ClaudeProfileRouter({ @@ -103,36 +73,13 @@ function fixture(env: NodeJS.ProcessEnv = {}) { } describe('ClaudeProfileRouter', () => { - 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 () => { + it('publishes the selected folder, System default as empty, and no file without accounts', () => { 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. - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + expect(f.setup.calls).toBe(1) f.settings.activeClaudeManagedAccountId = null f.router.publish() @@ -143,108 +90,42 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(f.router.pointerPath)).toBe(false) }) - it('makes a launch wait for a first setup that never ran or is still running, reusing it', async () => { + it('makes a launch wait only for a folder that was never set up, reusing the running setup', 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 vi.waitFor(() => expect(f.setup.calls).toBe(1)) - await new Promise((resolve) => setTimeout(resolve, 10)) + await Promise.resolve() expect(launched).toBe(false) - const second = f.router.prepareLaunch() - f.setup.outcome = 'refused' - f.setup.settle() - await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) - await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) expect(f.setup.calls).toBe(1) - }) - - it('never makes a launch wait on, or fail with, a re-run of a set-up folder', 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') }) + await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) - // Startup or a switch re-runs setup; it stays pending, then fails. f.setup.outcome = 'refused' - f.router.publish() - await vi.waitFor(() => expect(f.setup.calls).toBe(2)) - await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + const refused = f.router.prepareLaunch() f.setup.settle() - await new Promise((resolve) => setTimeout(resolve, 10)) + 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) }) - 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 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'), '{}') - 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 () => { + it('names a never-signed-in folder without creating it, and refuses to launch it', 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')) - 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') }) + 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 }) }) it('injects the account with its twin, and nothing over the user’s own System default', () => { @@ -268,92 +149,12 @@ describe('ClaudeProfileRouter', () => { 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('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 fa44e53b563..3b12f6f7c82 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -2,19 +2,13 @@ import { existsSync, mkdirSync, 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_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_POINTER_ENV, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - CLAUDE_USER_CONFIG_DIR_ENV -} from '../../shared/claude-profile-routing' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../shared/claude-profile-routing' import type { GlobalSettings } from '../../shared/global-settings-types' import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, claudeProfileMarkerPath, describeClaudeProfile, type ClaudeProfileDescriptor, @@ -31,8 +25,6 @@ import { } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' -import { removeClaudeAccountFolder } from './claude-account-folder' -import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' export type ClaudeProfileRouterSettings = Pick< GlobalSettings, @@ -43,6 +35,12 @@ 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. @@ -50,25 +48,17 @@ export type ClaudeProfileRouterSettings = Pick< export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() - private env: NodeJS.ProcessEnv - private readonly envReady: Promise 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 } ) { 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.envReady = args.env - ? Promise.resolve() - : resolveLoginShellEnvironment().then((env) => (this.env = env)) } private get userHome(): string { @@ -77,19 +67,24 @@ export class ClaudeProfileRouter { private selectedProfile(): ClaudeProfileDescriptor | null { const id = getSelectedClaudeAccountIdForTarget(this.args.getSettings(), { runtime: 'host' }) - return id ? this.describe(id) : null + return id + ? describeClaudeProfile(this.args.dataRoot, id, { executionHostId: 'local', runtime: 'host' }) + : null } /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ systemDefaultHome(): string { - return resolveClaudeDefaultHome(this.userHome, this.userConfigDir()) + return resolveClaudeDefaultHome( + this.userHome, + readUserClaudeConfigDir(this.args.env ?? process.env) + ) } /** 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 if (home !== null && !isDirectory(home)) { - throw claudeProfileMissing() + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) } return home } @@ -103,72 +98,26 @@ export class ClaudeProfileRouter { const profile = this.selectedProfile() mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) - // Why even a missing folder: setup creates it without a login, so Claude's own first run - // signs in there (an account saved before per-account folders has none yet). - if (profile) { + // 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) }) } } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ async prepareLaunch(): Promise { const profile = this.selectedProfile() - if (!profile) { - // Only System default reads the login shell's env here; setup awaits it itself. - await this.envReady - return this.preparation() - } - // 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 (!existsSync(claudeProfileMarkerPath(profile))) { + if (profile && isDirectory(profile.home) && !existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { - throw claudeProfileSetupFailed() + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) } } 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) @@ -181,14 +130,13 @@ 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 report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, userHome: this.userHome, - userConfigDir: this.userConfigDir(), + userConfigDir: readUserClaudeConfigDir(this.args.env ?? process.env), hooks, claudeVersion: claudeVersion ?? undefined }) @@ -215,12 +163,7 @@ export class ClaudeProfileRouter { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } try { - 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 + return this.launchEnv() } catch { return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } } @@ -244,16 +187,3 @@ export class ClaudeProfileRouter { return listClaudeProfileHomes(this.args.dataRoot) } } - -// 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-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index 0ead45be168..0195805eff3 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, installHooks, setup } + return { root, defaultHome, dataRoot, service, setup } } // Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. @@ -83,24 +83,6 @@ 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') diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts index 7d132e1c86d..1687d9cf0e8 100644 --- a/src/main/claude-accounts/claude-profile-setup.ts +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -51,21 +51,16 @@ export async function provisionClaudeAccountProfile(args: { userConfigDir?: string /** Null when Orca's Claude hooks are turned off. Runs after the settings merge 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') } - markSetUp = prepareClaudeProfileDirectory( - args.dataRoot, - args.profile, - args.userHome, - args.userConfigDir - ) + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) } catch (error) { report.surfaces.profile = 'failed' warnClaudeProfile(report, 'profile', error) @@ -75,7 +70,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 }) + () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) ]) { try { const part = await step() @@ -97,7 +92,5 @@ export async function provisionClaudeAccountProfile(args: { 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-shell-composition.test.ts b/src/main/claude-accounts/claude-profile-shell-composition.test.ts index fb4ed6255e1..4bf99fdac51 100644 --- a/src/main/claude-accounts/claude-profile-shell-composition.test.ts +++ b/src/main/claude-accounts/claude-profile-shell-composition.test.ts @@ -1,13 +1,21 @@ +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 } from 'vitest' +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 +})) 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 { @@ -17,7 +25,9 @@ function fishInit({ args }: { args: string[] | null }): string { } return init } -async function composed() { +async function composed(enabled: boolean) { + gate.enabled = enabled + vi.resetModules() const local = await import('../providers/local-pty-shell-ready') const daemon = await import('../daemon/shell-ready') const powershell = await import('../powershell-osc133-bootstrap') @@ -43,19 +53,35 @@ async function composed() { } } -it('gives every composed shell startup and the plain fish vendor snippet the claude function', async () => { - const on = await composed() +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) for (const text of [...on.bash, ...on.zsh]) { expect(text).toContain('ORCA_CLAUDE_INJECTED_CONFIG_DIR') } - const { getFishVendorConfSnippet } = await import('../fish-xdg-data-dirs-handoff') +}) + +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 { getFishClaudeShellFunction } = await import('../../shared/claude-shell-function') expect(getFishClaudeShellFunction()).toContain('function claude') - expect(getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) + expect(on.getFishVendorConfSnippet()).toContain(getFishClaudeShellFunction()) }) it('starts the PowerShell claude function on its own line after the codex fragment', async () => { - const on = await composed() + const on = await composed(true) expect(on.powershell).toContain( 'Remove-Variable orcaCodexCommand -ErrorAction SilentlyContinue\n$orcaClaudeCommand' ) @@ -69,7 +95,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() + const on = await composed(true) for (const init of on.fish) { const result = spawnSync( '/usr/bin/env', diff --git a/src/main/claude-accounts/claude-profile-wsl-paths.ts b/src/main/claude-accounts/claude-profile-wsl-paths.ts index 39d2bf67728..a5096c040ee 100644 --- a/src/main/claude-accounts/claude-profile-wsl-paths.ts +++ b/src/main/claude-accounts/claude-profile-wsl-paths.ts @@ -25,12 +25,3 @@ 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 00c16d881d2..decde2f9d35 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -6,7 +6,6 @@ import { mkdtempSync, readFileSync, rmSync, - symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -38,12 +37,10 @@ vi.mock('../wsl/wsl-runner', () => ({ import { CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' -import type { ClaudeProfileRouterSettings } from './claude-profile-router' -import { prepareClaudeProfileDirectory } from './claude-profile-paths' + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + type ClaudeProfileRouterSettings +} from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -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' @@ -78,14 +75,12 @@ 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'), '{}') } }) @@ -95,10 +90,14 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { - it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => { + it('writes the guest pointer per build, sets up only a signed-in 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 @@ -110,21 +109,15 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(f.pointer)).toBe(false) }) - it('sets up a missing folder, waits for a first setup never run or still running, then launches at once', async () => { + it('refuses a missing folder, waits for a never-set-up one, then launches at once', async () => { const f = fixture() - await expect(f.router.preparation('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - f.setup.fail = true - 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) + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) + mkdirSync(f.profileHome, { recursive: true }) + f.setup.fail = true + await expect(f.router.prepareLaunch('Ubuntu')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) f.setup.fail = false const prepared = await f.router.prepareLaunch('Ubuntu') expect(prepared).toMatchObject({ @@ -138,94 +131,10 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome } }) - // The failed first setup left no marker, so this launch ran setup again; the next does not. - expect(f.setup.calls).toBe(2) await f.router.prepareLaunch('Ubuntu') expect(f.setup.calls).toBe(2) }) - 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 - }) - expect(f.setup.calls).toBe(1) - }) - - it('sets up a new account folder for sign-in and deletes it without following its links', async () => { - const f = fixture() - const sharedHistory = join(guest.home, '.claude', 'projects') - mkdirSync(sharedHistory) - writeFileSync(join(sharedHistory, 'chat.jsonl'), '{}') - // The stub setup marks account a's folder. - mkdirSync(f.profileHome, { recursive: true }) - const home = await f.router.prepareAccount('Ubuntu', 'b') - expect(home).toBe(join(guest.home, '.local/share/orca/claude-profiles/b/home')) - expect(f.setup.calls).toBe(1) - mkdirSync(home, { recursive: true }) - symlinkSync(sharedHistory, join(home, 'projects')) - - await f.router.removeAccount('Ubuntu', 'b') - expect(existsSync(join(home, '..'))).toBe(false) - expect(existsSync(join(sharedHistory, 'chat.jsonl'))).toBe(true) - - f.setup.fail = true - await expect(f.router.prepareAccount('Ubuntu', 'c')).rejects.toThrow( - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ) - }) - - it('removes a guest folder only after its running setup, so the setup cannot bring it back', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - let release = () => {} - // Like the guest helper, setup recreates the folder it writes into. - f.setup.gate = new Promise((resolve) => (release = resolve)).then(() => { - mkdirSync(f.profileHome, { recursive: true }) - }) - await f.router.publish('Ubuntu') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - const removal = f.router.removeAccount('Ubuntu', 'a') - await new Promise((resolve) => setTimeout(resolve, 50)) - release() - await removal - expect(existsSync(join(f.profileHome, '..'))).toBe(false) - }) - it('writes a missing guest pointer before a launch returns', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 10c8273d919..0fba0eb59a6 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -2,21 +2,21 @@ 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_INJECTED_CONFIG_DIR_ENV, - CLAUDE_PROFILE_POINTER_ENV, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' +import { CLAUDE_PROFILE_POINTER_ENV } 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 { - claudeProfileMissing, - claudeProfileSetupFailed, + 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' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' @@ -90,23 +90,22 @@ export class ClaudeWslProfileRouter { return } await writePointer(distro, this.pointerIn(home), profile?.home ?? '') - // Why even a missing folder: setup creates it without a login, for Claude's own first run. - if (profile) { + // 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) }) } } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + /** Waits for setup only for a folder that was never set up; otherwise launches at once. */ async prepareLaunch(distro: string): Promise { const { home, profile } = await this.resolve(distro) - // 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.assertPresent(distro, profile) + if (profile && !(await guestStat(distro, claudeProfileMarkerPath(profile)))?.isFile()) { await this.setUp(distro, home, profile.accountId).catch((error: unknown) => { console.warn('[claude-profile] WSL account setup failed:', error) - throw claudeProfileSetupFailed() + throw new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) }) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. @@ -124,32 +123,10 @@ export class ClaudeWslProfileRouter { 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 claudeProfileMissing() + throw new Error(CLAUDE_PROFILE_MISSING_MESSAGE) } } @@ -195,10 +172,6 @@ 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-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts new file mode 100644 index 00000000000..69ca24c21a8 --- /dev/null +++ b/src/main/claude-accounts/claude-windows-interactive-login.test.ts @@ -0,0 +1,148 @@ +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 918de5250f5..3fd7871c70d 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,9 +1,4 @@ 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', @@ -13,9 +8,8 @@ export const CLAUDE_AUTH_ENV_VARS = [ ] as const export type ClaudeEnvPatch = { - [CLAUDE_PROFILE_POINTER_ENV]?: string - [CLAUDE_INJECTED_CONFIG_DIR_ENV]?: string - [CLAUDE_USER_CONFIG_DIR_ENV]?: string + ORCA_CLAUDE_PROFILE_POINTER?: string + ORCA_CLAUDE_INJECTED_CONFIG_DIR?: string CLAUDE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -41,15 +35,11 @@ export function applyClaudeEnvPatch( } } - 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.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 } if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR @@ -66,6 +56,9 @@ export function applyClaudeEnvPatch( 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. * diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 3bd725ba068..3ee2c6b7759 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,21 +1,277 @@ -import { afterEach, expect, it, vi } from 'vitest' +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' -const reads = vi.hoisted(() => vi.fn(async () => null)) -vi.mock('../macos-keychain/generic-password', () => ({ readKeychainPassword: reads })) - -import { readActiveClaudeKeychainCredentialsStrict } from './keychain' +vi.mock('node:child_process', () => ({ + execFile: vi.fn() +})) +const execFileMock = vi.mocked(execFile) +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const originalUser = process.env.USER -afterEach(() => { - process.env.USER = originalUser - reads.mockClear() -}) +const originalUsername = process.env.USERNAME +const TEST_USER = 'orca-test-user' +const SSO_USER = 'sso.user@example.com' +let configDir: string -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') +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] + ]) + }) }) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 14070e6bb63..aa4dcfa64db 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -2,18 +2,19 @@ import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' -import { readKeychainPassword } from '../macos-keychain/generic-password' +import { + deleteKeychainPassword, + readKeychainPassword, + writeKeychainPassword +} from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' -export async function readActiveClaudeKeychainCredentialsStrict( +const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' +export async function readActiveClaudeKeychainCredentials( configDir?: string ): Promise { - if (!configDir) { - return readKeychainPassword(claudeKeychainService(), getKeychainUser()) - } - // Keep both lexical and canonical profile aliases scoped; never try System Default. - for (const dir of claudeConfigDirKeychainAliases(configDir)) { - const credentials = await readKeychainPassword(claudeKeychainService(dir), getKeychainUser()) + for (const service of getActiveClaudeServices(configDir)) { + const credentials = await readKeychainPassword(service, getKeychainUser()) if (credentials) { return credentials } @@ -21,6 +22,80 @@ export async function readActiveClaudeKeychainCredentialsStrict( return null } +export async function readActiveClaudeKeychainCredentialsStrict( + configDir?: string +): Promise { + if (!configDir) { + return readKeychainPassword(getActiveClaudeService(), 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. + for (const dir of claudeConfigDirKeychainAliases(configDir)) { + const credentials = await readKeychainPassword(getActiveClaudeService(dir), getKeychainUser()) + if (credentials) { + return credentials + } + } + 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' @@ -36,7 +111,13 @@ function getKeychainUser(): string { return KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_CODE_FALLBACK_USER } -export function claudeKeychainService(configDir?: string): string { +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 { if (!configDir) { return ACTIVE_CLAUDE_SERVICE } @@ -92,16 +173,10 @@ export function claudeConfigDirKeychainAliases(configDir: string): string[] { return aliases } -/** 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}/`) - } +function getActiveClaudeServices(configDir?: string): string[] { + if (!configDir) { + return [ACTIVE_CLAUDE_SERVICE] } - return [...spellings] + const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) + return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] } diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts new file mode 100644 index 00000000000..7359bbd9805 --- /dev/null +++ b/src/main/claude-accounts/live-pty-gate.test.ts @@ -0,0 +1,142 @@ +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 new file mode 100644 index 00000000000..caab66c3430 --- /dev/null +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -0,0 +1,174 @@ +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 new file mode 100644 index 00000000000..2914b72e033 --- /dev/null +++ b/src/main/claude-accounts/managed-auth-path.ts @@ -0,0 +1,112 @@ +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 new file mode 100644 index 00000000000..dd5e01c245a --- /dev/null +++ b/src/main/claude-accounts/oauth-refresh.test.ts @@ -0,0 +1,186 @@ +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 new file mode 100644 index 00000000000..cc717ef51ee --- /dev/null +++ b/src/main/claude-accounts/oauth-refresh.ts @@ -0,0 +1,170 @@ +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 new file mode 100644 index 00000000000..dbccd87cee5 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts @@ -0,0 +1,58 @@ +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 new file mode 100644 index 00000000000..249e403b075 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts @@ -0,0 +1,509 @@ +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 new file mode 100644 index 00000000000..efd650618b4 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts @@ -0,0 +1,611 @@ +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 new file mode 100644 index 00000000000..7a80c5be002 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts @@ -0,0 +1,712 @@ +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 new file mode 100644 index 00000000000..85d68d30e8b --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts @@ -0,0 +1,248 @@ +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 new file mode 100644 index 00000000000..94bdab9b4d8 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts @@ -0,0 +1,490 @@ +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 new file mode 100644 index 00000000000..2806d44e82a --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts @@ -0,0 +1,607 @@ +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 new file mode 100644 index 00000000000..4066063c562 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts @@ -0,0 +1,85 @@ +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 new file mode 100644 index 00000000000..f3bb1f00137 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts @@ -0,0 +1,292 @@ +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 new file mode 100644 index 00000000000..1df2430b8f6 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts @@ -0,0 +1,550 @@ +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 new file mode 100644 index 00000000000..874e7534ff1 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-test-harness.ts @@ -0,0 +1,277 @@ +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 new file mode 100644 index 00000000000..61c3323b632 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts @@ -0,0 +1,312 @@ +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 deleted file mode 100644 index 1cfc4c7d385..00000000000 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -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: () => {} })) - -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 b15bffbc482..adbbce30da0 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,124 +1,96 @@ -import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { toWindowsWslPath } from '../../shared/wsl-paths' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' -import type { Store } from '../persistence' -import { getDefaultWslDistro, getWslHome } from '../wsl' +import { claudeProfileRoutingEnabled } from '../../shared/claude-profile-routing' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { installClaudeProfileRouter } from './claude-profile-installed-router' -import type { ClaudeAccountSelectionTarget } from './runtime-selection' +import { + getClaudeProfileRouter, + installClaudeProfileRouter +} 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 { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -/** `configDir` is the CLAUDE_CONFIG_DIR value; `readPath` is where this host reads it (UNC for WSL). */ -export type ClaudeAccountFolder = { configDir: string; readPath: string } +function routerFor(target: ClaudeAccountSelectionTarget): ClaudeProfileRouter | undefined { + return target.runtime === 'wsl' ? undefined : getClaudeProfileRouter() +} -/** Routes Claude launches on this host and its WSL distros to the selected account folder. */ -export class ClaudeRuntimeAuthService { - readonly router: ClaudeProfileRouter +export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private readonly wslRouter?: ClaudeWslProfileRouter - private readonly dataRoot = getAppEnvironment().getPath('userData') - private mutationQueue: Promise = Promise.resolve() - constructor(private readonly store: Pick) { - const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } - this.router = new ClaudeProfileRouter(args) - installClaudeProfileRouter(this.router) - this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined - void this.publishAll().catch((error: unknown) => { - console.warn('[claude-runtime-auth] Failed to publish the Claude account selection:', error) - }) + 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() } - prepareForClaudeLaunch( + async prepareForClaudeLaunch( target?: ClaudeAccountSelectionTarget ): Promise { - const wsl = this.wslRouteFor(target) - return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() + 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) } async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { - 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: {}, - stripAuthEnv: true, - provenance: 'profile:unavailable', - usageError: error instanceof Error ? error.message : String(error) - } + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + if (wsl) { + return wsl.router.preparation(wsl.distro) } + const router = routerFor(effectiveTarget) + if (router) { + return router.preparation() + } + 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 wsl = this.wslRouteFor(target) + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const wsl = this.wslRouteFor(effectiveTarget) + const router = routerFor(effectiveTarget) if (wsl) { await this.publishWsl(wsl.router, wsl.distro) + } else if (router) { + router.publish() } else { - this.router.publish() + await this.doSyncForCurrentSelection(effectiveTarget) } }) } - /** 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, + /** Null when the target is not a WSL distro routed by account folders. */ + private wslRouteFor( target: ClaudeAccountSelectionTarget - ): Promise { - const wsl = this.wslRouteFor(target) - if (!wsl) { - const home = await this.router.prepareAccount(accountId) - return { configDir: home, readPath: home } - } - const home = await wsl.router.prepareAccount(wsl.distro, accountId) - return { configDir: home, readPath: toWindowsWslPath(home, wsl.distro) } - } - - async 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() + ): { router: ClaudeWslProfileRouter; distro: string } | null { + const distro = + target.runtime === 'wsl' ? this.resolveWslDefaultTarget(target).wslDistro?.trim() : null + return this.wslRouter && distro ? { router: this.wslRouter, distro } : null } // Why never thrown: a guest Orca cannot reach also cannot run a pane, and a deleted distro must @@ -129,28 +101,64 @@ export class ClaudeRuntimeAuthService { }) } - /** 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 + /** 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))) + } } - /** 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 + 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) } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : effective } private serializeMutation(fn: () => Promise): Promise { @@ -158,4 +166,14 @@ export class ClaudeRuntimeAuthService { 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 new file mode 100644 index 00000000000..8cb00420dc6 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts @@ -0,0 +1,51 @@ +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 new file mode 100644 index 00000000000..d029ae0d699 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts @@ -0,0 +1,209 @@ +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 new file mode 100644 index 00000000000..390ec57a8b2 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts @@ -0,0 +1,152 @@ +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 new file mode 100644 index 00000000000..8aa33a88c60 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-file-storage.ts @@ -0,0 +1,82 @@ +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 new file mode 100644 index 00000000000..ef4c54acab8 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts @@ -0,0 +1,135 @@ +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 new file mode 100644 index 00000000000..3a93f63d4dd --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts @@ -0,0 +1,152 @@ +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 new file mode 100644 index 00000000000..dabcd9d472f --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -0,0 +1,119 @@ +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 new file mode 100644 index 00000000000..848ee093dfb --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts @@ -0,0 +1,163 @@ +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 new file mode 100644 index 00000000000..40d4d45395a --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts @@ -0,0 +1,238 @@ +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 new file mode 100644 index 00000000000..e184ec4e751 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts @@ -0,0 +1,119 @@ +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 new file mode 100644 index 00000000000..bb2158d9464 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts @@ -0,0 +1,239 @@ +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 new file mode 100644 index 00000000000..6d6e5e47a48 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000000..23e9112d7ed --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -0,0 +1,289 @@ +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 a6a099dad19..77f0394e571 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -1,3 +1,4 @@ +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import type { ClaudeEnvPatch } from '../environment' export type ClaudeRuntimeAuthPreparation = { @@ -7,7 +8,48 @@ export type ClaudeRuntimeAuthPreparation = { 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 aa2f168b6ea..cd1607e5a31 100644 --- a/src/main/claude-accounts/runtime-paths.test.ts +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -79,6 +79,12 @@ 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 c164df91d9c..d9ff5701f26 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -5,6 +5,7 @@ import type { ClaudeEnvPatch } from './environment' export type ClaudeRuntimePaths = { configDir: string + credentialsPath: string configPath: string envPatch: ClaudeEnvPatch } @@ -17,6 +18,7 @@ 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/service.ts b/src/main/claude-accounts/service.ts index 9d64480cfd4..d1c79eaf17d 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,39 +1,73 @@ -import type { - ClaudeAccountSignIn, - ClaudeRateLimitAccountsState, - ClaudeSignInRequest -} from '../../shared/managed-account-types' +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' import { ClaudeAccountRegistration } from './claude-account-registration' +import { ClaudeAccountSelection } from './claude-account-selection' import { - ClaudeAccountSelection, - type ClaudeAccountRuntime, - type ClaudeAccountStore, - type ClaudeAccountUsage -} from './claude-account-selection' -import { runClaudeCommandProcess } from './claude-command-process' + captureClaudeAuthFromConfigDir, + captureClaudeAuthFromExistingConfigDir, + readCapturedClaudeCredentials, + type CapturedClaudeAuth +} from './claude-auth-capture' +import { + runClaudeCommandProcess, + type ClaudeCommandConfig, + type ClaudeCommandOptions +} 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: ClaudeAccountStore, - rateLimits: ClaudeAccountUsage, - private readonly runtimeAuth: ClaudeAccountRuntime & - Pick, - private readonly runLoginCommand = runClaudeCommandProcess + store: Store, + rateLimits: RateLimitService, + private readonly runtimeAuth: ClaudeRuntimeAuthService ) { - this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth) + this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => + this.safeRemoveManagedAuth(accountId, path) + ) this.registration = new ClaudeAccountRegistration({ store, rateLimits, runtimeAuth, - selection: this.selection + 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) }) } @@ -41,45 +75,34 @@ export class ClaudeAccountService { return this.selection.list() } - /** 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 addAccount(target?: ClaudeAccountAddTarget): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => this.registration.add(target)) } - 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 + async addAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions ): Promise { - return this.serializeMutation(() => this.registration.finish(signIn)) + return this.serializeMutation(() => this.registration.addFromConfigDir(configDir, options)) } - /** 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 reauthenticateAccount(accountId: string): Promise { + this.supersedePendingLogin() + return this.serializeMutation(() => this.registration.reauthenticate(accountId)) } - removeAccount(accountId: string): Promise { + async removeAccount(accountId: string): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.remove(accountId)) } - selectAccount(accountId: string | null): Promise { + async selectAccount(accountId: string | null): Promise { this.supersedePendingLogin() return this.serializeMutation(() => this.selection.select(accountId)) } - selectAccountForTarget( + async selectAccountForTarget( accountId: string | null, target?: ClaudeAccountSelectionTarget ): Promise { @@ -87,26 +110,13 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.selection.select(accountId, target)) } - getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { - return this.runtimeAuth.getRuntimeConfigDir(target) + cancelPendingLogin(): boolean { + return this.cancelPendingClaudeLogin?.() ?? false } - 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. + // 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 supersedePendingLogin(): void { if (this.cancelPendingLogin()) { console.info( @@ -115,9 +125,91 @@ 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 new file mode 100644 index 00000000000..097e1725d12 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.test.ts @@ -0,0 +1,137 @@ +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 new file mode 100644 index 00000000000..e0014f25c4b --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.ts @@ -0,0 +1,166 @@ +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 new file mode 100644 index 00000000000..f772b5634c9 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-reconciliation.test.ts @@ -0,0 +1,129 @@ +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-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 960a78e0236..7a316bd0f40 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -16,6 +16,7 @@ export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { + authSwitchSettleTimeoutMs?: number /** Test seams; production runs the one-shot listing child. */ discover?: typeof discoverModelsLocal spawnAgent?: typeof spawnSourceControlAgent diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 276328b009c..e26e0226303 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -5,6 +5,7 @@ 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 { @@ -760,3 +761,85 @@ 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 e270110fc6c..8e178cb720a 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,7 +1,12 @@ +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 { @@ -153,6 +158,12 @@ 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 @@ -251,6 +262,7 @@ export async function openClaudeStreamJsonConnection( managed.onExit((exit) => { exitStatus = exit + releaseAuthGate() handleUnexpectedEnd() }) child.on('error', (error) => { @@ -260,6 +272,7 @@ export async function openClaudeStreamJsonConnection( handleUnexpectedEnd(error) }) child.on('close', () => { + releaseAuthGate() handleUnexpectedEnd() }) child.stdin.on('error', (error) => { @@ -268,6 +281,13 @@ 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 2a22fae853a..227fb117914 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,8 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' +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< @@ -27,6 +39,7 @@ function record(): AgentSessionRecord { function resolverFor(options: { stripAuthEnv: boolean overlay?: Record + authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], @@ -34,10 +47,41 @@ function resolverFor(options: { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), + authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, ...(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 @@ -51,6 +95,10 @@ function withAmbientAuth(value: string, run: () => Promise): Promise { } describe('claude structured auth parity with the terminal preflight', () => { + afterEach(() => { + endClaudeAuthSwitch() + }) + // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and // runtime/spawn-preflight.ts:139; the structured path used to let the override win. it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { @@ -113,4 +161,91 @@ describe('claude structured auth parity with the terminal preflight', () => { 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-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 2d14a9e0c04..dcdad5b868b 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 @@ -131,8 +131,6 @@ 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 0d7babfb8f5..031559a4438 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,10 +32,7 @@ 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') }, - runSetup: async () => { - throw new Error('setup refused') - } + env: { CLAUDE_CONFIG_DIR: resolvePath('/user/own') } }) installClaudeProfileRouter(router) const record = { @@ -59,12 +56,7 @@ it('launches each acquisition under the current selection, not the account it wa providerHandle: claudeProviderHandle('unused', null) } - // 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 - }) + await expect(resolve({ identity })).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) const home = join(root, 'claude-profiles', 'a', 'home') mkdirSync(home, { recursive: true }) diff --git a/src/main/claude/claude-structured-launch-home.ts b/src/main/claude/claude-structured-launch-home.ts index 14afad9b5c4..0df1b2f599c 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. */ -async function claudeTranscriptExists(input: { +export 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 19067d65a4c..f6d52ddb7eb 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -5,6 +5,9 @@ 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, @@ -15,6 +18,7 @@ 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' @@ -76,6 +80,33 @@ 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: [ @@ -550,11 +581,66 @@ 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[] = [] ) => @@ -566,7 +652,8 @@ describe('readable Claude thinking', () => { resolveAuthPolicy: () => ({ stripAuthEnv: false }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), hasTranscript: async () => false, - ...(cliFlags ? { cliFlags } : {}) + ...(cliFlags ? { cliFlags } : {}), + ...(authSwitchSettleTimeoutMs === undefined ? {} : { authSwitchSettleTimeoutMs }) })({ identity: IDENTITY }) // Whether the CLI's directory holds a `node` decides if the runtime pairing puts that directory @@ -589,7 +676,7 @@ describe('readable Claude thinking', () => { if (sibling) { makeExecutable(join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')) } - const launch = await launchWith({ supports }, command) + const launch = await launchWith({ supports }, undefined, command) const asked = supports.mock.calls[0]?.[1] expect(asked).toMatchObject({ command, cwd: '/repos/workspace-1' }) const segments = (env: Record | undefined) => @@ -621,6 +708,7 @@ 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({ @@ -629,4 +717,21 @@ 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 8914481044b..7e52332fe54 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -14,10 +14,20 @@ 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, @@ -131,6 +141,10 @@ export type ClaudeStructuredLaunchResolverDeps = { 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 @@ -159,13 +173,19 @@ export type ClaudeStructuredInvocation = { * drift there heals on the next refresh. */ export async function resolveClaudeStructuredInvocation( - deps: ClaudeEnvDeps & Pick, + deps: ClaudeEnvDeps & + Pick & { + authSwitchSettleTimeoutMs?: number + }, 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)) { @@ -180,10 +200,29 @@ export async function resolveClaudeStructuredInvocation( } } +/** + * 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' + }) + } +} + 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}`) @@ -204,7 +243,18 @@ 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 head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( @@ -234,6 +284,21 @@ export function createClaudeStructuredLaunchResolver( const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) + // A start that failed before its first turn wrote no transcript, and `--resume` of an absent + // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. + const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null + const resumes = async (claudeConfigDir: string): Promise => + head !== null && + (await claudeLaunchResumesTranscript({ + router, + leafUuid, + providerSessionId, + claudeConfigDir, + hasTranscript: deps.hasTranscript + })) + // Why: without a router the home is fixed, so check it before the recheck that must stay last. + const resumedWithoutRouter = router ? undefined : await resumes(accountHome.path) + // Last: it rechecks the account switch, which may have begun during any await above. const { command, env, account } = await resolveClaudeStructuredInvocation( deps, (base) => @@ -252,18 +317,7 @@ export function createClaudeStructuredLaunchResolver( sources ) const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path) - // A start that failed before its first turn wrote no transcript, and `--resume` of an absent - // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. - const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null - const resumesTranscript = - head !== null && - (await claudeLaunchResumesTranscript({ - router, - leafUuid, - providerSessionId, - claudeConfigDir: launchHome, - hasTranscript: deps.hasTranscript - })) + const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, account, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 418aeba798c..e13c1aa0047 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,7 +1,10 @@ -import type { - AgentSessionAcquisition, - StructuredAgentSessionAcquireInput +import { + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type 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' @@ -59,6 +62,13 @@ 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/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 53e3d13744f..ad7c1edabc8 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(42) + expect(PROTOCOL_VERSION).toBe(43) 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 2de5fd5be84..423ee9ee34f 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -125,6 +125,26 @@ 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 8967d0cae51..540b8494549 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,9 +1,8 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v42 gives every pane the `claude` function that follows the selected Claude account; 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 = 42 -export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 +// 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 // 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. @@ -38,7 +37,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 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 ] 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 dd2d2f95136..9d9a1b5c3e1 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,4 +1,8 @@ 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' @@ -159,6 +163,7 @@ 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. @@ -185,3 +190,29 @@ 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 adapters = + provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider + ? provider.getAllAdapters() + : [provider] + const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions())) + if (results.some((result) => result.status === 'rejected')) { + console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') + return + } + confirmSeededClaudeLivePtys( + results.flatMap((result) => + result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] + ) + ) + } 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 7599ee9cd03..30eff6691f9 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -3,7 +3,7 @@ import { setLocalPtyProvider } from '../ipc/pty' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import { getLegacyDaemonAdapters, type DaemonProvider } from './daemon-provider-routing' +import type { DaemonProvider } from './daemon-provider-routing' import { DaemonPtyRouter } from './daemon-pty-router' import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' import type { DaemonSpawner } from './daemon-spawner' @@ -88,18 +88,6 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } -/** True for a terminal on a daemon kept alive from before `protocolVersion`; never for an SSH pane. */ -export function isTerminalFromBeforeDaemonProtocol( - ptyId: string, - protocolVersion: number -): boolean { - return adapter - ? getLegacyDaemonAdapters(adapter).some( - (legacy) => legacy.protocolVersion < 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 e3c1638ff1f..e29fff5330a 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -34,10 +34,6 @@ 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' @@ -173,8 +169,8 @@ export function createPtyShellLaunchPlan( if (env.CLAUDE_CONFIG_DIR) { addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } - if (env[CLAUDE_PROFILE_POINTER_ENV]) { - addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) } 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 181164a4646..f88f094e46b 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 { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountAddTarget, 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?: ClaudeAccountSelectionTarget) => + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => claudeAccounts.addAccount(args) ) ipcMain.handle('claudeAccounts:cancelPendingLogin', () => claudeAccounts.cancelPendingLogin()) @@ -16,9 +16,11 @@ export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountServi ) ipcMain.handle( 'claudeAccounts:select', - (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => - args.runtime - ? claudeAccounts.selectAccountForTarget(args.accountId, args) - : claudeAccounts.selectAccount(args.accountId) + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { + if (!args.runtime) { + return claudeAccounts.selectAccount(args.accountId) + } + return claudeAccounts.selectAccountForTarget(args.accountId, args) + } ) } 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 470582f3561..7e4178f35d3 100644 --- a/src/main/ipc/pty-listener-teardown-and-orphans.test.ts +++ b/src/main/ipc/pty-listener-teardown-and-orphans.test.ts @@ -8,6 +8,7 @@ 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. @@ -366,6 +367,7 @@ describe('registerPtyHandlers', () => { } spawnMock.mockReturnValue(proc) const isRecoveryReloadInFlight = vi.fn(() => true) + const markClaudePtyExitedSpy = vi.spyOn(livePtyGate, 'markClaudePtyExited') registerPtyHandlers( mainWindow as never, @@ -394,8 +396,11 @@ 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-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 36c02a0f234..d530a9cd194 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,13 +1,14 @@ 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 } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME, makeDisposable } 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 } from './pty' +import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' import { selectShellStartupFeatures } from '../shell-startup-features' @@ -319,6 +320,49 @@ 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 deleted file mode 100644 index 6d8a3e16e00..00000000000 --- a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' - -type Handler = (event: unknown, args: { id: unknown }) => Promise - -const mocks = vi.hoisted(() => ({ - handlers: new Map(), - olderPtyIds: new Set(), - startup: vi.fn<() => Promise | undefined>() -})) -vi.mock('../../pty-host-bindings', () => ({ - getPtyIpc: () => ({ - handle: (channel: string, handler: Handler) => mocks.handlers.set(channel, handler) - }) -})) -vi.mock('../../../daemon/daemon-provider-state', () => ({ - isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => - protocolVersion === 42 && mocks.olderPtyIds.has(id) -})) - -import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' - -beforeEach(() => { - mocks.handlers.clear() - mocks.olderPtyIds = new Set(['old-1']) - installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) -}) - -it('answers true only for a pane owned by a daemon from before per-account Claude folders', async () => { - const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! - expect(await ask({}, { id: 'old-1' })).toBe(true) - // A pane on this build's daemon, or an SSH pane, which no local daemon owns. - expect(await ask({}, { id: 'new-1' })).toBe(false) - expect(await ask({}, { id: 'ssh:conn-1:pty-1' })).toBe(false) - expect(await ask({}, { id: 42 })).toBe(false) - expect(mocks.startup).toHaveBeenCalled() -}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts deleted file mode 100644 index 385800241cb..00000000000 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getPtyIpc } from '../../pty-host-bindings' -import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' -import { isTerminalFromBeforeDaemonProtocol } from '../../../daemon/daemon-provider-state' - -type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } - -/** Whether a pane's daemon predates the claude function that follows the selected account. */ -export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { - getPtyIpc().handle( - 'pty:openedBeforeClaudeAccounts', - async (_event, args: { id: string }): Promise => { - if (typeof args?.id !== 'string') { - return false - } - // Why: the pre-swap provider does not own restored daemon ids. - await deps.getLocalPtyProviderStartupPromise() - return isTerminalFromBeforeDaemonProtocol( - args.id, - CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION - ) - } - ) -} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index ffcc5d158bb..24df59c1201 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,5 +1,6 @@ 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' @@ -124,6 +125,9 @@ 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) } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 4b8d71c0441..b6a50c43bbb 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,6 +4,8 @@ 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' @@ -192,6 +194,9 @@ 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 ec13ad0d36e..31bacec82f7 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -1,3 +1,4 @@ +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { @@ -91,6 +92,7 @@ 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 5c4265bdc6e..8a362b74aaf 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -10,6 +10,7 @@ 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 { @@ -178,5 +179,6 @@ 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 eeb5fef028c..7a494522ec0 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -10,6 +10,7 @@ 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, @@ -125,6 +126,7 @@ 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 aa99d191abc..f70b07dbff8 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -1,5 +1,6 @@ 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' @@ -48,6 +49,8 @@ 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 5e823ac535c..7f29e895e0b 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -13,7 +13,6 @@ 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, @@ -125,7 +124,6 @@ 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') @@ -288,6 +286,5 @@ 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 e6e301aa8af..c38637371c3 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -7,6 +7,7 @@ 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 { @@ -239,6 +240,9 @@ 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-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 88db8879630..cfbace932ab 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,7 +96,6 @@ 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 729cf74af78..c82b0656a37 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,8 +23,10 @@ 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 { @@ -68,6 +70,9 @@ 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 @@ -146,6 +151,9 @@ 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) } diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts index 604fd8b00bd..38ec1b05a72 100644 --- a/src/main/macos-keychain/generic-password.test.ts +++ b/src/main/macos-keychain/generic-password.test.ts @@ -4,7 +4,12 @@ const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { isKeychainNotFoundError, readKeychainPassword } from './generic-password' +import { + deleteKeychainPassword, + isKeychainNotFoundError, + readKeychainPassword, + writeKeychainPassword +} from './generic-password' const originalPlatform = process.platform @@ -66,10 +71,27 @@ 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 1616008f303..8a805edd809 100644 --- a/src/main/macos-keychain/generic-password.ts +++ b/src/main/macos-keychain/generic-password.ts @@ -79,6 +79,20 @@ 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 @@ -106,3 +120,28 @@ 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 52d530c65ec..3ead999cfbd 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 @@ -136,8 +136,6 @@ 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 5c66814475a..fde97b1b09a 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,8 +33,6 @@ 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 0a04d6cddfa..a0f996c80b1 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,10 +2,6 @@ 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' @@ -173,18 +169,6 @@ 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 new file mode 100644 index 00000000000..8d6aa55783f --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -0,0 +1,136 @@ +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 new file mode 100644 index 00000000000..3b42c44c443 --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -0,0 +1,68 @@ +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/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts index a0b61c5cead..61dfd787920 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,5 +1,6 @@ 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 = { @@ -9,11 +10,64 @@ 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 what the adapter supports', () => { + 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', () => { expect( - resolveStructuredAgentSessionCreateSupport({ location: LOCAL, adapterSupportsCreate: true }) - ).toEqual({ supported: true }) + 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 }) }) it.each([ @@ -21,8 +75,22 @@ 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( - resolveStructuredAgentSessionCreateSupport({ location, adapterSupportsCreate: false }) - ).toEqual({ supported: false, 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 }) }) }) 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 37a1fc03386..dc2b40ac1e6 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,16 +1,27 @@ 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: the adapter's location answer, with the reason a refusal names. */ +/** + * 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. + */ export function resolveStructuredAgentSessionCreateSupport(input: { + agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean + getSettings: () => ClaudeManagedAccountGateSettings }): StructuredAgentSessionCreateSupport { if (!input.adapterSupportsCreate) { return { @@ -23,12 +34,26 @@ 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, or the installed agent (its - * binary and version on this host). */ -export type StructuredAgentSessionCreateSupportCheck = 'location' | 'installed-agent' +/** 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' /** 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 a9890f66164..1f750ff1503 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -277,6 +277,67 @@ 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 7f72f2117ab..2f0ccdc8165 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -2,6 +2,7 @@ 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, @@ -18,6 +19,7 @@ export type SshTargetStateOperations = { state: PersistedState protectedSecrets: Pick scheduleSave: () => void + flush: () => void } export function getSshTargets(state: PersistedState): SshTarget[] { @@ -90,6 +92,41 @@ 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 7045f922de8..1a3ba807c5a 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -12,6 +12,7 @@ import { normalizeSshTarget } from '../leasing-ssh-ptys/ssh-normalization' import { + normalizeClaudeLivePtySessionIds, normalizeLegacyPaneKeyAliasEntries, normalizeMigrationUnsupportedPtyEntries } from '../restoring-sessions/pane-alias-normalization' @@ -101,6 +102,7 @@ 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 9e2bed18bc0..9cf3d1ec2d1 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,6 +93,16 @@ 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 ad12108129e..ce81cea373b 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -1,14 +1,17 @@ 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, @@ -23,17 +26,22 @@ 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 +type SshProfileOperationsRuntime = Pick< + StoreRuntimeState, + 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' +> const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { runtime: SshProfileOperationsRuntime scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations repos: RepoLifecycleOperations } @@ -43,9 +51,10 @@ export class SshProfileOperations { constructor( runtime: SshProfileOperationsRuntime, scheduling: WriteSchedulingOperations, + flushBarriers: WriteFlushBarrierOperations, repos: RepoLifecycleOperations ) { - this[sshProfileOperationsContext] = { runtime, scheduling, repos } + this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } } getSshTargets(): SshTarget[] { @@ -79,6 +88,18 @@ 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) } @@ -127,7 +148,13 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar return { state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, - scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling) + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), + flush: () => { + owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'claudeLivePtySessionIds' + ) + owner[sshProfileOperationsContext].flushBarriers.flush() + } } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index cf3063ad37c..4ba2e58881a 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -183,7 +183,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) - const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) + const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, 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 6b9dca94c92..c7e88c48021 100644 --- a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts +++ b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts @@ -40,9 +40,33 @@ 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 a1f194e9af0..63ad18883dc 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -1,8 +1,4 @@ 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' @@ -59,8 +55,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[CLAUDE_PROFILE_POINTER_ENV]) { - addWslEnvKeys(env, [CLAUDE_PROFILE_POINTER_ENV, CLAUDE_INJECTED_CONFIG_DIR_ENV]) + if (env.ORCA_CLAUDE_PROFILE_POINTER) { + addWslEnvKeys(env, ['ORCA_CLAUDE_PROFILE_POINTER', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR']) } 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 e6e573263ab..38352a1b6d4 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('gives a plain Git Bash tab the same rcfile as one with a startup command', () => { + it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { const plain = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice', @@ -322,12 +322,14 @@ describe('resolveWindowsShellLaunchArgs', () => { "codex 'fix the bug'" ) - 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(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(launched.startupCommandDeliveredInShellArgs).toBeUndefined() }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index b905a3fbb30..c5ceade00a4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,9 +29,12 @@ 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 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 { +// 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 + } + ensureShellReadyWrappersAt() const wrapperArgs = getBashWrapperLaunchArgs() if (!wrapperArgs) { @@ -214,7 +217,10 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['-c', getGitBashLaunchCommand()], + shellArgs: [ + '-c', + getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) + ], 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 df9867efd3b..f285ba13ce9 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,86 +1,248 @@ 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 { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' -import { OAuthUsageError } from './claude-oauth-usage-error' +import { + classifyClaudeCredentialAbsence, + classifyClaudeOAuthUsageError +} from './claude-usage-error-classification' import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { abortedClaudeRateLimitResult, makeClaudeUsageResult } from './claude-usage-result' -import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing' +import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' +import { + abortedClaudeRateLimitResult, + makeClaudeUsageResult, + metadataForClaudeUsageAttempt, + recordClaudeUsageAttempt, + warnClaudeUsageFetchFailure +} from './claude-usage-result' -/** 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 credentials = await readClaudeOAuthCredentials( + const attempts = { attemptedSources: [] } + const allowCliFallback = options?.allowPtyFallback !== false + const plan = resolveClaudeUsageRefreshPlan({ + authPreparation: options?.authPreparation, + allowCliFallback + }) + + if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { + return makeClaudeUsageResult( + 'error', + `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, + { + attemptedSources: [], + failureKind: 'cli-unavailable', + authProvenance: options.authPreparation.provenance + } + ) + } + + const oauthCredentials = await readClaudeOAuthCredentials( resolveClaudeOAuthCredentialReadOptions(options?.authPreparation) ) - const metadata = { - credentialSource: credentials.source, - authProvenance: options?.authPreparation?.provenance ?? 'system' + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() } - // 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 (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { + recordClaudeUsageAttempt(attempts, 'oauth') + try { + const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + return await completeClaudeOAuthUsageSuccess({ + oauthLimits, + oauthCredentials, + attempts, + options + }) + } catch (error) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) + const classification = classifyClaudeOAuthUsageError(error) + + if ( + canRetryClaudeOAuthWithLegacyKeychain({ + classification, + oauthCredentials, + authPreparation: options?.authPreparation + }) + ) { + const legacyResult = await retryClaudeOAuthWithLegacyKeychain({ + failedToken: oauthCredentials.token, + attempts, + options + }) + if (legacyResult) { + return legacyResult + } + } + + if (shouldDeferClaudeUsageForLiveSession(options?.authPreparation, classification)) { + return makeLiveClaudeUsageDeferredResult({ + attempts, + oauthCredentials, + authPreparation: options?.authPreparation + }) + } + + if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) { + const repaired = await repairClaudeCredentialsThenRetryOAuth({ + options, + attempts, + oauthCredentials + }) + if (repaired) { + return repaired + } + } + + if (classification.shouldAttemptCliFallback && allowCliFallback) { + try { + return await fetchClaudeUsageViaCli({ + authPreparation: options?.authPreparation, + oauthCredentials, + attempts, + networkProxySettings: options?.networkProxySettings, + signal: options?.signal + }) + } catch (ptyError) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) + } + } + + return makeClaudeUsageClassificationError({ + error, + classification, + attempts, + oauthCredentials, + authPreparation: options?.authPreparation + }) + } + } + + const 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 (!credentials.token) { - return makeClaudeUsageResult( - 'error', - credentials.unavailable - ? 'Claude usage is unavailable.' - : 'Sign in again to use this account.', - { - ...metadata, - failureKind: credentials.unavailable ? 'keychain-unavailable' : 'missing-credentials', - attemptedSources: [] - } - ) + + if ( + oauthCredentials.hasRefreshableCredentials && + credentialClassification.shouldAttemptDelegatedRefresh && + allowCliFallback + ) { + const repaired = await repairClaudeCredentialsThenRetryOAuth({ + options, + attempts, + oauthCredentials + }) + if (repaired) { + return repaired + } } - 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 } : {}) - } - ) + + 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 new file mode 100644 index 00000000000..70d264a4e68 --- /dev/null +++ b/src/main/rate-limits/claude-cli-usage-fetch.ts @@ -0,0 +1,117 @@ +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 new file mode 100644 index 00000000000..428a2910694 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts @@ -0,0 +1,490 @@ +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-oauth-usage-extra-usage.test.ts b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts similarity index 66% rename from src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts rename to src/main/rate-limits/claude-fetcher-extra-usage.test.ts index 9911343080c..fb56512bd8c 100644 --- a/src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts @@ -1,28 +1,94 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' +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' -const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) +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', () => ({ - net: { fetch: netFetchMock }, - session: { defaultSession: {} } + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } })) -vi.mock('../network/proxy-settings', () => ({ - ensureElectronProxyFromEnvironment: vi.fn(async () => {}) +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() })) -async function fetchUsageResponse(body: string) { - netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) - return fetchClaudeOAuthUsage('oauth-token') +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' + } } -describe('Claude OAuth usage extra usage', () => { +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() }) +} + +describe('fetchClaudeRateLimits extra usage', () => { beforeEach(() => { - vi.clearAllMocks() + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() }) 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({ @@ -39,7 +105,7 @@ describe('Claude OAuth usage extra usage', () => { ) ) - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -56,6 +122,14 @@ describe('Claude OAuth usage 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({ @@ -73,7 +147,7 @@ describe('Claude OAuth usage extra usage', () => { ) ) - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { @@ -89,6 +163,14 @@ describe('Claude OAuth usage 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({ @@ -106,7 +188,7 @@ describe('Claude OAuth usage extra usage', () => { ) ) - await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ provider: 'claude', status: 'ok', extraUsage: { diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts new file mode 100644 index 00000000000..2a84cc4f2af --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts @@ -0,0 +1,362 @@ +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 new file mode 100644 index 00000000000..9a75d3826e5 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts @@ -0,0 +1,491 @@ +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 new file mode 100644 index 00000000000..c01bcb00f6e --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts @@ -0,0 +1,425 @@ +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 new file mode 100644 index 00000000000..f679d838833 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-test-harness.ts @@ -0,0 +1,67 @@ +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 9b03a2eb971..42427766a26 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,9 +1,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import { - fetchInactiveClaudeAccountUsage, - type InactiveClaudeAccount -} from './claude-managed-account-usage' +import type { InactiveClaudeAccount } from './claude-managed-account-credentials' +import { fetchInactiveClaudeAccountUsage } 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 new file mode 100644 index 00000000000..71d505510cf --- /dev/null +++ b/src/main/rate-limits/claude-managed-account-credentials.ts @@ -0,0 +1,127 @@ +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 b2e419f8bb0..ddf7b3df750 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -1,66 +1,104 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { savedWslClaudeAccountHome } from '../claude-accounts/claude-profile-wsl-paths' -import { toWindowsWslPath } from '../../shared/wsl-paths' +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' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' -import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' -import { claudeUsageUnavailable, makeClaudeUsageResult } from './claude-usage-result' -import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' +import { + abortedClaudeRateLimitResult, + canSupplementClaudeOAuthUsage, + mergeClaudeUsageWindows, + warnClaudeUsageFetchFailure +} from './claude-usage-result' -/** 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 +function noClaudeManagedCredentialsResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'No credentials', + status: 'error' + } } export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - const configDir = - account.managedAuthRuntime === 'wsl' - ? savedWslClaudeAccountHome(account) - : (getClaudeProfileRouter()?.accountHome(account.id) ?? null) - if (!configDir || (account.managedAuthRuntime === 'wsl' && !account.wslDistro)) { - return makeClaudeUsageResult('error', 'Sign in again to use this account.', { - failureKind: 'missing-credentials', - attemptedSources: [] - }) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() } - const home = - account.managedAuthRuntime === 'wsl' && account.wslDistro - ? toWindowsWslPath(configDir, account.wslDistro) - : configDir - // Why: a stopped distro parks a UNC read for minutes, and the inactive loop is sequential. - if ( - account.managedAuthRuntime === 'wsl' && - (await filterPathsToRunningWslDistrosAsync([home], { requireConfirmed: true })).length === 0 - ) { - return claudeUsageUnavailable() + const location = resolveClaudeManagedCredentialsLocation(account) + let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() } - const read = fetchActiveClaudeRateLimits({ - signal: options.signal, - authPreparation: { - configDir: home, - runtime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - provenance: `profile:${account.id}` - } - }) - if (account.managedAuthRuntime !== 'wsl') { - return read + if (!location || !credentialsJson) { + return noClaudeManagedCredentialsResult() } - const timeout = AbortSignal.timeout(INACTIVE_WSL_READ_TIMEOUT_MS) - return Promise.race([ - read, - new Promise((resolve) => - timeout.addEventListener('abort', () => resolve(claudeUsageUnavailable()), { once: true }) - ) - ]) -} -const INACTIVE_WSL_READ_TIMEOUT_MS = 10_000 + 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() + } + if ( + !canSupplementClaudeOAuthUsage({ + oauthLimits, + authPreparation: undefined, + allowUsagePanelSupplement: options.allowUsagePanelSupplement === true + }) + ) { + return oauthLimits + } + + try { + return mergeClaudeUsageWindows( + oauthLimits, + await fetchClaudeManagedUsagePanelSupplement({ + account, + location, + credentialsJson, + oauthLimits, + networkProxySettings: options.networkProxySettings, + signal: options.signal + }) + ) + } catch (error) { + warnClaudeUsageFetchFailure( + undefined, + parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file'), + error + ) + return oauthLimits + } +} diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts new file mode 100644 index 00000000000..15c1e90169c --- /dev/null +++ b/src/main/rate-limits/claude-managed-usage-panel.ts @@ -0,0 +1,113 @@ +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 7fa4d04fbb9..3e14fb2909e 100644 --- a/src/main/rate-limits/claude-oauth-credentials.ts +++ b/src/main/rate-limits/claude-oauth-credentials.ts @@ -1,137 +1,160 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' -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' +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 + } +} 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 = schema.parse(JSON.parse(raw)).claudeAiOauth - // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. - return { - token: oauth?.accessToken || null, - hasRefreshableCredentials: Boolean(oauth?.refreshToken), - source + 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 } } + // Why: expiresAt is not authoritative for the usage endpoint; let the server decide. + return { token: oauth.accessToken, hasRefreshableCredentials, source } } catch { - return { ...emptyClaudeOAuthCredentialReadResult(), unavailable: true } + return emptyClaudeOAuthCredentialReadResult() } } + 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 contents = await readActiveClaudeKeychainCredentialsStrict(configDir) - return contents - ? parseClaudeOAuthCredentialsJson(contents, source) + const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) + return credentials + ? parseClaudeOAuthCredentialsJson(credentials, source) : emptyClaudeOAuthCredentialReadResult() } catch { - return { - ...emptyClaudeOAuthCredentialReadResult(), - unavailable: true, - keychainUnavailable: true - } + return unavailableKeychainResult() } } -async function readClaudeKeychainCredentials( - options?: ClaudeOAuthCredentialReadOptions + +async function readFromCredentialsFile( + configDir?: string ): Promise { - if (!options?.keychainConfigDir) { - return readClaudeCredentialsFromStrictKeychain(undefined, 'legacy-keychain') - } - const scoped = await readClaudeCredentialsFromStrictKeychain( - options.keychainConfigDir, - 'scoped-keychain' + const credentialPath = path.join( + configDir ?? path.join(homedir(), '.claude'), + '.credentials.json' ) - if (scoped.token || !options.unsuffixedKeychainFallback) { - return scoped + try { + return parseClaudeOAuthCredentialsJson( + await readFile(credentialPath, 'utf-8'), + 'credentials-file' + ) + } catch { + return emptyClaudeOAuthCredentialReadResult() } - 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 = - process.platform === 'darwin' - ? await readClaudeKeychainCredentials(options) - : emptyClaudeOAuthCredentialReadResult() - if (keychain.token) { + const keychain = await readFromKeychain(options?.keychainConfigDir) + if (keychain.token || keychain.hasRefreshableCredentials) { return keychain } - 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 } + + const file = await readFromCredentialsFile(options?.credentialsFileConfigDir) + if (file.token || file.hasRefreshableCredentials) { + return file } + 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, - // 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 + keychainConfigDir: authPreparation.configDir } } diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts new file mode 100644 index 00000000000..58c6bf91cb3 --- /dev/null +++ b/src/main/rate-limits/claude-oauth-recovery.ts @@ -0,0 +1,172 @@ +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-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts deleted file mode 100644 index 36816ea655a..00000000000 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -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 } => ({})) -vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ - getClaudeProfileRouter: () => - profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined -})) -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 }, - stripAuthEnv: true, - 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: {}, stripAuthEnv: false, 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: {}, - stripAuthEnv: false, - 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: {}, - stripAuthEnv: false, - 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('asks an inactive account from an older Orca to sign in again, reading nothing', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - expect( - await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' - }) - ).toMatchObject({ usageMetadata: { failureKind: 'missing-credentials' } }) - expect(calls.keychain).not.toHaveBeenCalled() -}) -it('does not read an inactive WSL account through a stopped distro', async () => { - const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - runningDistros.filter.mockResolvedValueOnce([]) - 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\\home\\u\\.local\\share\\orca\\claude-profiles\\acct\\home'], - { requireConfirmed: true } - ) - expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) - 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 new file mode 100644 index 00000000000..43137a8cea5 --- /dev/null +++ b/src/main/rate-limits/claude-pty-reset-parser.ts @@ -0,0 +1,307 @@ +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 new file mode 100644 index 00000000000..2bba56c6911 --- /dev/null +++ b/src/main/rate-limits/claude-pty-stop-markers.ts @@ -0,0 +1,14 @@ +// 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 new file mode 100644 index 00000000000..3328d947160 --- /dev/null +++ b/src/main/rate-limits/claude-pty-usage-parser.ts @@ -0,0 +1,166 @@ +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 new file mode 100644 index 00000000000..f2fd976821b --- /dev/null +++ b/src/main/rate-limits/claude-pty.test.ts @@ -0,0 +1,554 @@ +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 new file mode 100644 index 00000000000..cbaedbd17f9 --- /dev/null +++ b/src/main/rate-limits/claude-pty.ts @@ -0,0 +1,354 @@ +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 5e60c281674..9371479cfba 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' +import { + classifyClaudeCredentialAbsence, + classifyClaudeOAuthUsageError +} from './claude-usage-error-classification' import { OAuthUsageError } from './claude-oauth-usage-error' describe('classifyClaudeOAuthUsageError', () => { @@ -44,3 +47,38 @@ 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 440d1155d24..71c88c242bd 100644 --- a/src/main/rate-limits/claude-usage-error-classification.ts +++ b/src/main/rate-limits/claude-usage-error-classification.ts @@ -39,6 +39,23 @@ 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 new file mode 100644 index 00000000000..dced9e7815c --- /dev/null +++ b/src/main/rate-limits/claude-usage-refresh-plan.test.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 00000000000..06144a15715 --- /dev/null +++ b/src/main/rate-limits/claude-usage-refresh-plan.ts @@ -0,0 +1,47 @@ +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 e1863be724d..a29cb38d4aa 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -1,4 +1,16 @@ -import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types' +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[] +} export function abortedClaudeRateLimitResult(): ProviderRateLimits { return { @@ -11,6 +23,30 @@ 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, @@ -27,10 +63,76 @@ export function makeClaudeUsageResult( } } -// 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 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) }) } + +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 new file mode 100644 index 00000000000..43e8453fa0f --- /dev/null +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -0,0 +1,135 @@ +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 new file mode 100644 index 00000000000..068b937ee84 --- /dev/null +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -0,0 +1,58 @@ +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 4366417dc3e..27e93208c1a 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -551,7 +551,9 @@ 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' }]) + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) service.setClaudeAuthPreparationResolver(async (target) => ({ configDir: target?.runtime === 'wsl' 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 469b51e4cf1..cae71c569e7 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' } + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } 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' } + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } service.setInactiveClaudeAccountsResolver(() => [account]) vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) @@ -230,7 +230,9 @@ describe('RateLimitService', () => { it('does not start overlapping inactive Claude preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() - service.setInactiveClaudeAccountsResolver(() => [{ id: 'account-1' }]) + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) const firstFetch = service.fetchInactiveClaudeAccountsOnOpen() @@ -399,7 +401,7 @@ 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' }] + let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] service.setInactiveClaudeAccountsResolver(() => inactiveAccounts) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', @@ -418,7 +420,7 @@ describe('RateLimitService', () => { ]) service.evictInactiveClaudeCache('account-1') - inactiveAccounts = [{ id: 'account-1' }] + inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] await service.refreshForClaudeAccountChange('account-1') expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1') @@ -433,7 +435,9 @@ 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' }]) + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, 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 f6cbc700620..8c35ab77acc 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -14,12 +14,6 @@ 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() @@ -286,60 +280,6 @@ 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: {}, - stripAuthEnv: false, - 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: {}, - stripAuthEnv: false, - 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 { @@ -537,4 +477,51 @@ 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 944c09be247..6aeacd99583 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -191,4 +191,15 @@ 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 907f0512f00..cfd4a6e9d58 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -10,8 +10,6 @@ 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 { @@ -93,12 +91,7 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT return } this.lastClaudeAuthSnapshot = { - // 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) - ), + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), provenance: authPreparation?.provenance ?? 'system' } } 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 c7e3c1d79a1..7fd0dec42ad 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,7 +41,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent, worktreeSelector, location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), - runtime: this + runtime: this, + getSettings: () => this.requireStore().getSettings() }) } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4fb79e83cd4..1698676a3cb 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -260,6 +260,8 @@ 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-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts new file mode 100644 index 00000000000..64b451e9ea9 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts @@ -0,0 +1,112 @@ +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 new file mode 100644 index 00000000000..0d9b12f7c52 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts @@ -0,0 +1,64 @@ +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 c10d70b7ad9..b8473d00e01 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -15,6 +15,20 @@ 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') }, @@ -36,37 +50,29 @@ describe('account RPC methods', () => { }) it.each([ - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }, /only available/], + ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], [ 'accounts.addDataFromHome', - { 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) => { + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } + ] + ])('rejects paired-device calls to %s', async (methodName, params) => { const runtime = { - 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 + addClaudeAccountFromConfigDir: vi.fn(), + addCodexAccountFromHome: vi.fn() + } 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: runtimeService, clientKind }) - ).rejects.toThrow(refusal) + await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( + /only available on the Orca host runtime/ + ) } + 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 () => { @@ -221,14 +227,3 @@ 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 7c97c36ac40..10c39aacd2f 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -4,8 +4,6 @@ import { SelectDataAccountParams, RemoveDataAccountParams, AccountsUnsubscribeParams, - BeginClaudeSignInParams, - FinishClaudeSignInParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, ConsumeCodexResetCreditParams, @@ -21,42 +19,15 @@ 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', @@ -147,10 +118,17 @@ export const ACCOUNT_METHODS = [ name: 'accounts.addClaudeFromConfigDir', permission: 'accounts-admin', params: AddClaudeFromConfigDirParams, - handler: async () => { - throw new Error( - 'Update the Orca CLI to add accounts. Importing Claude logins is no longer supported.' - ) + 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 + }) } }), defineMethod({ diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 248adf2c18f..c824301e5a5 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -2,9 +2,7 @@ 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 @@ -166,18 +164,15 @@ export class RuntimeAccountController { return this.requireServices().claudeAccounts.removeAccount(accountId) } - beginClaudeSignIn(request: ClaudeSignInRequest): Promise { - return this.requireServices().claudeAccounts.beginSignIn(request) - } - - finishClaudeSignIn( - signIn: Omit + addClaudeFromConfigDir( + configDir: string, + options?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + previousLegacyCredentialsSha256?: string | null + } ): Promise { - return this.requireServices().claudeAccounts.finishSignIn(signIn) - } - - cancelClaudeSignIn(signIn: Omit): Promise { - return this.requireServices().claudeAccounts.cancelSignIn(signIn) + return this.requireServices().claudeAccounts.addAccountFromConfigDir(configDir, options) } 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 966976e1a94..c62fc799dd4 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -54,9 +54,7 @@ export type RuntimeServiceCommandSurface = { selectCodexAccountForTarget: RuntimeAccountController['selectCodexForTarget'] consumeCodexRateLimitResetCredit: RuntimeAccountController['consumeCodexResetCredit'] removeClaudeAccount: RuntimeAccountController['removeClaude'] - beginClaudeSignIn: RuntimeAccountController['beginClaudeSignIn'] - finishClaudeSignIn: RuntimeAccountController['finishClaudeSignIn'] - cancelClaudeSignIn: RuntimeAccountController['cancelClaudeSignIn'] + addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] @@ -156,9 +154,7 @@ export function installRuntimeServiceCommandSurface( selectCodexAccountForTarget: accounts.selectCodexForTarget.bind(accounts), consumeCodexRateLimitResetCredit: accounts.consumeCodexResetCredit.bind(accounts), removeClaudeAccount: accounts.removeClaude.bind(accounts), - beginClaudeSignIn: accounts.beginClaudeSignIn.bind(accounts), - finishClaudeSignIn: accounts.finishClaudeSignIn.bind(accounts), - cancelClaudeSignIn: accounts.cancelClaudeSignIn.bind(accounts), + addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.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-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index 97f25812072..aecff69f178 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,7 +66,8 @@ 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-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 5f81b2c91c5..14a60f024d1 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -4,6 +4,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { resolveStructuredAgentSessionCreateSupport, warnStructuredAgentSessionCreateUnsupported, @@ -59,12 +60,13 @@ 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. A refusal logs which check said no. */ + * host, then Claude's managed-account gate. 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 = @@ -73,13 +75,15 @@ export async function resolveHostStructuredAgentCreateSupport(input: { supportsLocation && (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) const support = resolveStructuredAgentSessionCreateSupport({ + agent, location, - adapterSupportsCreate: supportsLaunch + adapterSupportsCreate: supportsLaunch, + getSettings: input.getSettings }) warnStructuredAgentSessionCreateUnsupported( agent, support, - supportsLocation ? 'installed-agent' : 'location' + !supportsLocation ? 'location' : !supportsLaunch ? 'installed-agent' : 'managed-account' ) return support } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 20cc9ee2523..deed5bc0134 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -21,6 +21,7 @@ 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 { @@ -171,6 +172,12 @@ 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-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index efcdab0f22c..8853d7428f2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,6 +34,7 @@ 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 @@ -133,6 +134,8 @@ 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-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index d9abf1cf388..269fd842314 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -15,6 +15,7 @@ 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' @@ -39,6 +40,7 @@ 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'] @@ -115,6 +117,9 @@ 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/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index edc24c7a44a..9e86ebdd15c 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -68,8 +68,6 @@ 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 015a7eba654..0b6ac644a25 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -24,6 +24,11 @@ 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, @@ -260,6 +265,21 @@ 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 19bb07714e9..4d2285e3b8e 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -73,6 +73,9 @@ vi.mock('../persistence', () => ({ return {} } onSettingsChanged() {} + getClaudeLivePtySessionIds() { + return [] + } getSshTargets() { return [] } @@ -84,6 +87,7 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ store: { getSettings: () => ({}), onSettingsChanged: () => {}, + getClaudeLivePtySessionIds: () => [], getSshTargets: () => [] } }) @@ -142,6 +146,11 @@ 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 aedb8dfccad..ef986e43ab8 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -112,6 +112,7 @@ 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 96c8c65c18e..8f1cac48de6 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -91,7 +91,11 @@ export function attachMainWindowCoreServices( onBeforeRelaunch: async () => { state.isQuitting = true state.desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome, + claudeRuntimeAuth, + store + }) }, onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), // Sign-out is the one fence a paired phone can be told about; quit and @@ -127,7 +131,7 @@ export function attachMainWindowCoreServices( onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, store }) + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) await store.flushPendingOrThrowAsync({ fullCheckpoint: true }) }, onBeforeUpdateQuitFailure: 'abort', diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index cbcf78a4abe..229ef1a5414 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -145,8 +145,6 @@ 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 predates per-account Claude folders; false for any other pane. */ - openedBeforeClaudeAccounts: (id: 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 3d72ac5e518..e0b2e7f2bf6 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -23,8 +23,6 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), stopCodexSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), - openedBeforeClaudeAccounts: (id: string): Promise => - ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id }), 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/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 9f833364ab1..bab4c09e1e8 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -22,7 +22,6 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' -import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' @@ -65,5 +64,4 @@ export function useAppShellServices(): void { useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() useCodexSharedSettingsNotice() - useClaudeAccountSignInNotice() } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 47856cdaae8..1bd21e9ec01 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -15,12 +15,6 @@ 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' const HOST_STATUS_WORDS: Record string> = { 'history-repaired': () => @@ -45,7 +39,6 @@ export function NativeChatNoticeRow({ allowFileUriLinks?: boolean }): React.JSX.Element { const orcaStopView = useNativeChatOrcaStopView() - const claudeSignIn = useNativeChatClaudeSignInView() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -130,17 +123,6 @@ export function NativeChatNoticeRow({ {Icon ?