mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Users with a saved Claude account were signed out of every Claude feature in Orca until they signed in again, and proxy setups (API key plus ANTHROPIC_BASE_URL) got 401s after signing in. Reverting before release to rework the upgrade path. The terminal daemon protocol moves forward to 43 so dev builds' v42 daemons with the claude function are not reused; v41 and v42 stay attachable. Nothing is deleted: per-account folders are left unused and the old saved-account store was never touched.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <baseline|candidate|explicit-cmd>'
|
||||
)
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This reproduction requires a physical Windows host.')
|
||||
}
|
||||
|
||||
const expectedArgs = [
|
||||
'',
|
||||
'two words',
|
||||
'amp&ersand',
|
||||
'pipe|value',
|
||||
'less<value',
|
||||
'greater>value',
|
||||
'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: 'less<value',
|
||||
greaterThan: 'greater>value',
|
||||
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
|
||||
@@ -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'
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
|
||||
@@ -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<void>((resolvePromise) => {
|
||||
releaseKeychainDelete = () => resolvePromise()
|
||||
})
|
||||
)
|
||||
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
|
||||
let configDir = ''
|
||||
spawnMock.mockImplementation((_command, _args, options: { env: Record<string, string> }) => {
|
||||
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<void>((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<void>((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.
|
||||
|
||||
+69
-51
@@ -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<void> {
|
||||
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<void> {
|
||||
const { result: signIn } = await client.call<ClaudeAccountSignIn>(
|
||||
'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<ClaudeRateLimitAccountsState>(
|
||||
'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<ClaudeRateLimitAccountsState>('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<void> {
|
||||
async function assertAccountImportSupported({ client }: HandlerContext): Promise<void> {
|
||||
const status = await client.call<RuntimeStatus>('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<string, CommandHandler> = {
|
||||
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))
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>((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<void>(() => {}))
|
||||
},
|
||||
store: {
|
||||
flushPendingOrThrowAsync: vi.fn(() => new Promise<void>(() => {}))
|
||||
}
|
||||
}).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(() => {})
|
||||
|
||||
@@ -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<CodexRuntimeHomeService, 'syncForCurrentSelection'> &
|
||||
Partial<Pick<CodexRuntimeHomeService, 'syncActiveWslSelectionsBeforeRestart'>>
|
||||
type ClaudeRuntimeAuthSync = Pick<ClaudeRuntimeAuthService, 'syncForCurrentSelection'>
|
||||
type ShutdownStore = Pick<Store, 'flushPendingOrThrowAsync'>
|
||||
|
||||
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<void> {
|
||||
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',
|
||||
|
||||
@@ -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<string, number>(), userHome: '' }))
|
||||
vi.mock('node:os', async (original) => ({
|
||||
...(await original<typeof Os>()),
|
||||
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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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<string, unknown>): 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/<id>` 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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ClaudeManagedAuthLocation>
|
||||
assertManagedAuth: (path: string, accountId: string) => Promise<string>
|
||||
removeManagedAuth: (accountId: string, path: string) => Promise<void>
|
||||
writeManagedAuth: (accountId: string, path: string, captured: CapturedClaudeAuth) => Promise<void>
|
||||
writeCredentials: (accountId: string, path: string, value: string) => Promise<void>
|
||||
writeOauth: (accountId: string, path: string, value: unknown) => Promise<void>
|
||||
readSnapshot: (accountId: string, path: string) => Promise<ClaudeManagedAuthSnapshot>
|
||||
restoreCredentials: (
|
||||
accountId: string,
|
||||
path: string,
|
||||
snapshot: ClaudeManagedAuthSnapshot
|
||||
) => Promise<void>
|
||||
restoreOauth: (
|
||||
accountId: string,
|
||||
path: string,
|
||||
snapshot: ClaudeManagedAuthSnapshot
|
||||
) => Promise<void>
|
||||
login: (location: ClaudeManagedAuthLocation) => Promise<CapturedClaudeAuth>
|
||||
captureExisting: (
|
||||
configDir: string,
|
||||
previousLegacyCredentialsSha256?: string | null
|
||||
) => Promise<CapturedClaudeAuth>
|
||||
}
|
||||
|
||||
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<ClaudeAccountSignIn> {
|
||||
const { accountId, target, folder } = await this.prepare(request)
|
||||
return { accountId, configDir: folder.configDir, ...target }
|
||||
async add(target?: ClaudeManagedAuthTarget): Promise<ClaudeRateLimitAccountsState> {
|
||||
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<void>
|
||||
async addFromConfigDir(
|
||||
configDir: string,
|
||||
options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null }
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeAccountSignIn, 'configDir'>): Promise<void> {
|
||||
if (!this.deps.selection.findAccount(signIn.accountId)) {
|
||||
await this.deps.runtimeAuth.removeAccountFolder(signIn.accountId, signInTarget(signIn))
|
||||
}
|
||||
}
|
||||
|
||||
async finish(
|
||||
signIn: Omit<ClaudeAccountSignIn, 'configDir'>,
|
||||
notSignedInMessage = CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE
|
||||
private async persist(
|
||||
accountId: string,
|
||||
location: ClaudeManagedAuthLocation,
|
||||
previousSettings: ReturnType<Store['getSettings']>,
|
||||
captured: CapturedClaudeAuth
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
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<Store['getSettings']>,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
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<Store['getSettings']>,
|
||||
nextAccounts: ClaudeManagedAccount[],
|
||||
wroteCredentials: boolean
|
||||
): Promise<void> {
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -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<ClaudeAccountSettings>,
|
||||
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<void>
|
||||
) {}
|
||||
|
||||
/** 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<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeAccountSettings>): void {
|
||||
this.store.updateSettings(patch, { notifyListeners: true })
|
||||
restoreSettings(settings: ReturnType<Store['getSettings']>): void {
|
||||
this.store.updateSettings({
|
||||
claudeManagedAccounts: settings.claudeManagedAccounts,
|
||||
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId,
|
||||
activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime
|
||||
})
|
||||
}
|
||||
|
||||
private pruneSelection(): void {
|
||||
async syncRuntimeAuth(
|
||||
target?: ClaudeAccountSelectionTarget,
|
||||
operation?: () => Promise<void>
|
||||
): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<void> }).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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
return { promise: new Promise<void>((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<ClaudeRateLimitAccountsState>
|
||||
}
|
||||
}
|
||||
).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
|
||||
})
|
||||
})
|
||||
@@ -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<typeof settings>) => {
|
||||
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<string> }).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<string> }).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<string> }).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<string> }).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<string> }).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()
|
||||
})
|
||||
})
|
||||
@@ -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<string | null> {
|
||||
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<string | null>
|
||||
}
|
||||
).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<string>
|
||||
runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }>
|
||||
}
|
||||
testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}')
|
||||
|
||||
await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed')
|
||||
})
|
||||
})
|
||||
@@ -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 = <T>(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<string>
|
||||
}
|
||||
).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<typeof vi.fn>
|
||||
}
|
||||
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<string>
|
||||
}
|
||||
).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<typeof vi.fn>
|
||||
}
|
||||
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<string>
|
||||
}
|
||||
).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<typeof vi.fn>
|
||||
}
|
||||
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<typeof vi.fn>
|
||||
}
|
||||
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<typeof settings>) => {
|
||||
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<typeof vi.fn>
|
||||
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<string>
|
||||
}
|
||||
).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<typeof vi.fn>
|
||||
}
|
||||
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<typeof settings>) => {
|
||||
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<string | null>((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<typeof settings>) => {
|
||||
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<typeof vi.fn> })[] = []
|
||||
// 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<typeof settings>) => {
|
||||
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<typeof vi.fn>
|
||||
}
|
||||
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<number>((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<typeof settings>) => {
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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<typeof settings>) => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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> = {}): 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<string>
|
||||
>(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<typeof fixture>): 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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
|
||||
const STATUS_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function captureClaudeAuthFromExistingConfigDir(
|
||||
configDir: string,
|
||||
previousLegacyCredentialsSha256: string | null | undefined,
|
||||
runCommand: ClaudeCaptureCommand
|
||||
): Promise<CapturedClaudeAuth> {
|
||||
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<CapturedClaudeAuth> {
|
||||
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<string | null> {
|
||||
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<string, unknown>
|
||||
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<string, unknown> | null {
|
||||
try {
|
||||
return asRecord(JSON.parse(value) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function readString(value: Record<string, unknown> | 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, readonly string[]> = {
|
||||
'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<string>()
|
||||
// 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<string> {
|
||||
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<string>()
|
||||
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) ||
|
||||
/(?<!function\s)\bexecSecurityCommand\s*\(\s*(?!\[\s*['"])/.test(text)
|
||||
) {
|
||||
found.push('keychain command with a computed verb')
|
||||
}
|
||||
// A name bound to the credentials file that reaches a write, anywhere in the same file.
|
||||
const names = credentialFileNames(text)
|
||||
for (const call of text.matchAll(WRITE_CALL)) {
|
||||
const args = text.slice(call.index + call[0].length, call.index + call[0].length + 300)
|
||||
if ([...names].some((name) => 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([])
|
||||
})
|
||||
@@ -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<typeof vi.fn>
|
||||
pid: number
|
||||
}
|
||||
|
||||
type CommandRunner = {
|
||||
runClaudeCommand(
|
||||
args: string[],
|
||||
config: CommandConfig,
|
||||
timeoutMs: number,
|
||||
options?: { keepStdinOpen?: boolean; signal?: AbortSignal }
|
||||
): Promise<string>
|
||||
}
|
||||
|
||||
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<CommandRunner> {
|
||||
const { ClaudeAccountService } = await import('./service')
|
||||
return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner
|
||||
}
|
||||
|
||||
async function flushPromiseCallbacks(): Promise<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
capture: (
|
||||
configDir: string,
|
||||
statusOutput: string,
|
||||
previousLegacyKeychain: string | null
|
||||
) => Promise<CapturedClaudeAuth>
|
||||
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<void> {
|
||||
): Promise<CapturedClaudeAuth> {
|
||||
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<ClaudeCommandConfig> {
|
||||
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<void> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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<ClaudeManagedAuthLocation> {
|
||||
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<void> {
|
||||
await this.writeCredentials(accountId, managedAuthPath, captured.credentialsJson)
|
||||
await this.writeOauthAccount(accountId, managedAuthPath, captured.oauthAccount)
|
||||
}
|
||||
|
||||
async writeCredentials(
|
||||
accountId: string,
|
||||
managedAuthPath: string,
|
||||
credentialsJson: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<ClaudeManagedAuthSnapshot> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<ClaudeManagedAuthLocation | null> {
|
||||
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<ReturnType<typeof parseWslUncPath>>,
|
||||
expectedAccountId?: string
|
||||
): Promise<string> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Env extends Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Account `<surface>` 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 `<surface>` 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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> => 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')
|
||||
|
||||
@@ -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<ClaudeProfileSurfaceOutcome> {
|
||||
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<string, string> = {}
|
||||
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<ClaudeProfileReport> {
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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<ClaudeProfileSetupReport>((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, string>): 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.
|
||||
|
||||
@@ -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<string, Promise<ClaudeProfileSetupReport>>()
|
||||
private env: NodeJS.ProcessEnv
|
||||
private readonly envReady: Promise<unknown>
|
||||
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<ClaudeRuntimeAuthPreparation> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<ClaudeProfileSetupReport> {
|
||||
const running = this.setups.get(profile.accountId)
|
||||
@@ -181,14 +130,13 @@ export class ClaudeProfileRouter {
|
||||
}
|
||||
|
||||
private async runSetup(profile: ClaudeProfileDescriptor): Promise<ClaudeProfileSetupReport> {
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ClaudeProfileSetupReport> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<typeof ProfileRouting>()),
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void>((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 })
|
||||
|
||||
@@ -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<ClaudeRuntimeAuthPreparation> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
// Why the timeout: a hung share must not stall startup's serialized publish; the guest write decides.
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
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<string>
|
||||
}
|
||||
).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<typeof vi.fn>
|
||||
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<string>
|
||||
}
|
||||
).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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents)
|
||||
}
|
||||
|
||||
export async function writeActiveClaudeKeychainCredentialsForRuntime(
|
||||
contents: string,
|
||||
configDir: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
for (const service of getActiveClaudeServices(configDir)) {
|
||||
for (const account of getKeychainUsersForCleanup()) {
|
||||
await deleteKeychainPassword(service, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteActiveClaudeKeychainCredentialsStrict(
|
||||
configDir?: string
|
||||
): Promise<void> {
|
||||
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<string | null> {
|
||||
return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId)
|
||||
}
|
||||
|
||||
export async function writeManagedClaudeKeychainCredentials(
|
||||
accountId: string,
|
||||
contents: string
|
||||
): Promise<void> {
|
||||
await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents)
|
||||
}
|
||||
|
||||
export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise<void> {
|
||||
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<string>()
|
||||
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])]
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
const liveClaudePtyIds = new Set<string>()
|
||||
// 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<string>()
|
||||
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<LiveClaudePtyDrainListener>()
|
||||
|
||||
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<boolean> {
|
||||
if (!switchInProgress) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
return new Promise<boolean>((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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, unknown> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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<GlobalSettings>) => {
|
||||
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<GlobalSettings>) => {
|
||||
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<GlobalSettings>) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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<string, unknown>
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('node:os')>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>()
|
||||
}
|
||||
|
||||
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> = {}): GlobalSettings {
|
||||
return {
|
||||
...getDefaultSettings(testState.fakeHomeDir),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createStore(settings: GlobalSettings) {
|
||||
return {
|
||||
getSettings: vi.fn(() => settings),
|
||||
updateSettings: vi.fn((updates: Partial<GlobalSettings>) => {
|
||||
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> = {}
|
||||
): 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<string, unknown>).oauthAccount ?? null
|
||||
)
|
||||
}
|
||||
@@ -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<typeof import('node:os')>('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<typeof ClaudeProfileRouting>()),
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<void> => {},
|
||||
runningDistros: async (): Promise<string[]> => []
|
||||
}
|
||||
}))
|
||||
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<GlobalSettings> = {}) {
|
||||
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<void>((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'])
|
||||
})
|
||||
})
|
||||
@@ -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<unknown> = Promise.resolve()
|
||||
|
||||
constructor(private readonly store: Pick<Store, 'getSettings'>) {
|
||||
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<ClaudeRuntimeAuthPreparation> {
|
||||
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<ClaudeRuntimeAuthPreparation> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ClaudeAccountFolder> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = 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 }
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
} 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<ClaudeReadBackMatch, { kind: 'matched' }>
|
||||
}[]
|
||||
): {
|
||||
credentialsJson: string
|
||||
match: Extract<ClaudeReadBackMatch, { kind: 'matched' }>
|
||||
} {
|
||||
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<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
} 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<string, unknown>
|
||||
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<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
protected readString(value: Record<string, unknown> | null, key: string): string | null {
|
||||
const candidate = value?.[key]
|
||||
return typeof candidate === 'string' ? candidate : null
|
||||
}
|
||||
|
||||
protected readNumber(value: Record<string, unknown> | 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)])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ClaudeReadBackMatch> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown>
|
||||
}
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<ClaudeKeychainReadResult> {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<unknown> {
|
||||
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<string | null> {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<ClaudeReadBackResult> {
|
||||
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<ClaudeReadBackMatch, { kind: 'matched' }>
|
||||
}[] = []
|
||||
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<ClaudeRuntimeCredentialCandidate[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<unknown> = 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) {}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 } : {}
|
||||
}
|
||||
|
||||
@@ -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<unknown> = 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<ClaudeRuntimeAuthService, 'getRuntimeConfigDir'>,
|
||||
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<ClaudeRateLimitAccountsState> {
|
||||
return this.signInHidden({ runtime: target.runtime, wslDistro: target.wslDistro })
|
||||
async addAccount(target?: ClaudeAccountAddTarget): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.registration.add(target))
|
||||
}
|
||||
|
||||
reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.signInHidden({ accountId })
|
||||
}
|
||||
|
||||
cancelPendingLogin(): boolean {
|
||||
return this.cancelPendingClaudeLogin?.() ?? false
|
||||
}
|
||||
|
||||
beginSignIn(request: ClaudeSignInRequest = {}): Promise<ClaudeAccountSignIn> {
|
||||
return this.serializeMutation(() => this.registration.begin(request))
|
||||
}
|
||||
|
||||
finishSignIn(
|
||||
signIn: Omit<ClaudeAccountSignIn, 'configDir'>
|
||||
async addAccountFromConfigDir(
|
||||
configDir: string,
|
||||
options?: ClaudeAccountImportOptions
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeAccountSignIn, 'configDir'>): Promise<void> {
|
||||
return this.serializeMutation(() => this.registration.cancel(signIn))
|
||||
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.registration.reauthenticate(accountId))
|
||||
}
|
||||
|
||||
removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
async removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.remove(accountId))
|
||||
}
|
||||
|
||||
selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||||
async selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.select(accountId))
|
||||
}
|
||||
|
||||
selectAccountForTarget(
|
||||
async selectAccountForTarget(
|
||||
accountId: string | null,
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
@@ -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<ClaudeRateLimitAccountsState> {
|
||||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
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<CapturedClaudeAuth> {
|
||||
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<string> {
|
||||
return runClaudeCommandProcess(args, configDir, timeoutMs, options)
|
||||
}
|
||||
|
||||
private captureFromExistingConfigDir(
|
||||
configDir: string,
|
||||
previousLegacyCredentialsSha256?: string | null
|
||||
): Promise<CapturedClaudeAuth> {
|
||||
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<CapturedClaudeAuth> {
|
||||
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<string | null> {
|
||||
return readCapturedClaudeCredentials(
|
||||
configDir,
|
||||
previousLegacyKeychain,
|
||||
previousLegacyCredentialsSha256
|
||||
)
|
||||
}
|
||||
|
||||
private writeManagedAuth(
|
||||
accountId: string,
|
||||
managedAuthPath: string,
|
||||
captured: CapturedClaudeAuth
|
||||
): Promise<void> {
|
||||
return this.storage.writeAuth(accountId, managedAuthPath, captured)
|
||||
}
|
||||
|
||||
private safeRemoveManagedAuth(accountId: string, path: string): Promise<void> {
|
||||
return this.storage.remove(accountId, path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = { 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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> | 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<string, unknown> {
|
||||
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<string, unknown> = { ...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<string, unknown>, 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<string, unknown>[],
|
||||
baseline: Record<string, unknown> | 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))
|
||||
}
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<string, string>
|
||||
authSwitchSettleTimeoutMs?: number
|
||||
}): ReturnType<typeof createClaudeStructuredLaunchResolver> {
|
||||
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<string, string> } : {})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof fakeClaude>,
|
||||
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<T>(value: string, run: () => Promise<T>): Promise<T> {
|
||||
const restore = process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = value
|
||||
@@ -51,6 +95,10 @@ function withAmbientAuth<T>(value: string, run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,8 +131,6 @@ function restingRecord(options: Record<string, string>): 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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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<string, string> | 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> | ClaudeStructuredAuthPolicy
|
||||
/** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */
|
||||
resolvePermissionMode?: () => Promise<PermissionMode> | 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<ClaudeStructuredLaunchResolverDeps, 'resolveAuthPolicy'>,
|
||||
deps: ClaudeEnvDeps &
|
||||
Pick<ClaudeStructuredLaunchResolverDeps, 'resolveAuthPolicy'> & {
|
||||
authSwitchSettleTimeoutMs?: number
|
||||
},
|
||||
decorateEnv: (env: Record<string, string>) => Record<string, string> = (env) => env,
|
||||
/** Already resolved by a caller that needed them earlier; read again otherwise. */
|
||||
resolvedSources?: ClaudeChildEnvSources
|
||||
): Promise<ClaudeStructuredInvocation> {
|
||||
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<void> {
|
||||
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<ClaudeStructuredLaunch> {
|
||||
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<boolean> =>
|
||||
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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user