diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index eb280f1900bf..754e1eee90d9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -158,7 +158,7 @@ if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && 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 "$@" ) + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi unset __orca_claude_binary diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index 42fb21032ecc..076fd8f1a694 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -40,11 +40,7 @@ if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file 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 + env CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv end end set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 22c221bb9434..c73d9a73062f 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -240,7 +240,7 @@ __orca_deferred_init() { 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 "$@" ) + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi unset __orca_claude_binary diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index a43c7bf2c396..1b85c0f08d9d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -168,7 +168,7 @@ if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && 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 "$@" ) + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi unset __orca_claude_binary diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index 42fb21032ecc..076fd8f1a694 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -40,11 +40,7 @@ if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file 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 + env CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv end end set -e __orca_claude_type diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index e6e492c48bcf..52bbfa7b3272 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -247,7 +247,7 @@ __orca_deferred_init() { 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 "$@" ) + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi unset __orca_claude_binary diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 1fcf476a1a0b..f52ef39a6767 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -210,7 +210,7 @@ __orca_deferred_init() { 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 "$@" ) + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi unset __orca_claude_binary diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts index 8aead85ff78e..d3faefdd9397 100644 --- a/src/main/agent-workspace-trust.test.ts +++ b/src/main/agent-workspace-trust.test.ts @@ -203,7 +203,6 @@ describe('applyAgentWorkspaceTrust on this machine', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/u/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'test' } as const const wsl = { ...local, claudeAuth: wslAuth, wslDistro: 'Ubuntu' } diff --git a/src/main/claude-accounts/claude-account-folder.test.ts b/src/main/claude-accounts/claude-account-folder.test.ts index 5374afed213a..f449dbcd231f 100644 --- a/src/main/claude-accounts/claude-account-folder.test.ts +++ b/src/main/claude-accounts/claude-account-folder.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -21,7 +29,11 @@ vi.mock('../macos-keychain/generic-password', () => ({ } })) -import { readClaudeFolderLogin, removeClaudeAccountFolder } from './claude-account-folder' +import { + readClaudeFolderLogin, + readClaudeFolderLoginAsync, + removeClaudeAccountFolder +} from './claude-account-folder' import { claudeKeychainService } from './keychain' const roots: string[] = [] @@ -65,6 +77,25 @@ describe('claude account folder', () => { expect(readClaudeFolderLogin(stateFile)?.email).toBe('b@example.test!') }) + it('rereads a state file over a share only when its mtime or size changes', async () => { + const { home } = fixture() + const stateFile = join(home, '.claude.json') + await expect(readClaudeFolderLoginAsync(stateFile)).resolves.toBeNull() + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'a@example.test' } })) + // Whole seconds, which every filesystem stores exactly. + const mtime = new Date(Math.floor(Date.now() / 1000) * 1000) + utimesSync(stateFile, mtime, mtime) + expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('a@example.test') + // Same size and mtime: the cached answer, so the file was not read again. + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.test' } })) + utimesSync(stateFile, mtime, mtime) + expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('a@example.test') + utimesSync(stateFile, mtime, new Date(mtime.getTime() + 2_000)) + expect((await readClaudeFolderLoginAsync(stateFile))?.email).toBe('b@example.test') + rmSync(stateFile) + await expect(readClaudeFolderLoginAsync(stateFile)).resolves.toBeNull() + }) + // A real symlink needs Developer Mode on Windows (EPERM otherwise). it.skipIf(process.platform === 'win32')( 'unlinks shared history before deleting, and leaves the older layout alone', diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts index 7e4bfe8e3efd..1d6e60b4e04c 100644 --- a/src/main/claude-accounts/claude-account-folder.ts +++ b/src/main/claude-accounts/claude-account-folder.ts @@ -7,6 +7,7 @@ import { statSync, unlinkSync } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { z } from 'zod' @@ -48,6 +49,20 @@ const logins = new Map< { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } >() +/** Whether two logins name one account: the same email, and the same organization when both + * name one, so one email in two organizations stays two accounts. */ +export function isSameClaudeLogin( + left: { email: string; organizationUuid?: string | null }, + right: { email: string; organizationUuid?: string | null } +): boolean { + return ( + left.email.trim().toLowerCase() === right.email.trim().toLowerCase() && + (!left.organizationUuid || + !right.organizationUuid || + left.organizationUuid === right.organizationUuid) + ) +} + /** The login parsed Claude state names; null before Claude finishes a sign-in. */ export function claudeStateLogin(state: Record): ClaudeFolderLogin | null { const parsed = oauthAccount.safeParse(state.oauthAccount) @@ -87,6 +102,37 @@ export function readClaudeFolderLogin(stateFile: string, maxAgeMs = 0): ClaudeFo return login } +/** readClaudeFolderLogin without blocking, for a state file across a WSL share. */ +export async function readClaudeFolderLoginAsync( + stateFile: string +): Promise { + const current = await stat(stateFile).catch(() => null) + if (!current) { + logins.delete(stateFile) + return null + } + const recent = logins.get(stateFile) + if (recent?.mtimeMs === current.mtimeMs && recent.size === current.size) { + return recent.login + } + let login: ClaudeFolderLogin | null = null + try { + const state: unknown = JSON.parse(await readFile(stateFile, 'utf8')) + if (state && typeof state === 'object' && !Array.isArray(state)) { + login = claudeStateLogin(Object.fromEntries(Object.entries(state))) + } + } catch { + // Unreadable reads as signed out, as on the host. + } + logins.set(stateFile, { + mtimeMs: current.mtimeMs, + size: current.size, + readAt: Date.now(), + login + }) + return login +} + /** * Deletes a host account's folder and, on macOS, the Keychain items Claude keyed to it * (superset profile-remove.ts). Only `claude-profiles/` is reachable from an id, so an diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index c0b089e902cf..b26010420781 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -46,7 +46,7 @@ export type ClaudeAccountRuntime = Pick< > & { router: Pick< ClaudeRuntimeAuthService['router'], - 'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' + 'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' | 'coveredBySystemDefault' > } @@ -107,13 +107,17 @@ export class ClaudeAccountSelection { const login = readClaudeFolderLogin( claudeStateFile(this.runtimeAuth.router.accountHome(account.id)) ) - return login - ? { - ...summary, - email: login.email, - organizationUuid: login.organizationUuid, - organizationName: login.organizationName - } + if (login) { + return { + ...summary, + email: login.email, + organizationUuid: login.organizationUuid, + organizationName: login.organizationName + } + } + // Why: an account System default is signed in to runs there until it signs in on its own. + return this.runtimeAuth.router.coveredBySystemDefault(account.id) + ? summary : { ...summary, needsSignIn: true } } diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index 55c4304ca626..80810512a23d 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -44,6 +44,7 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')] JSON.stringify({ oauthAccount: { emailAddress: email } }) ) } + const covered = new Set() let settings: GlobalSettings = { ...getDefaultSettings('/tmp'), claudeManagedAccounts: accounts, @@ -54,7 +55,8 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')] router: { accountHome: home, userConfigDir: () => join(root, 'personal'), - copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')) + copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')), + coveredBySystemDefault: (id: string) => covered.has(id) }, syncForCurrentSelection: vi.fn(async (_target?: ClaudeAccountSelectionTarget) => {}), publishAll: vi.fn(async () => {}), @@ -88,7 +90,7 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')] runtimeAuth, runLogin ) - return { root, home, service, runtimeAuth, runLogin, signIn, settings: () => settings } + return { root, home, service, runtimeAuth, runLogin, signIn, covered, settings: () => settings } } // The id the last sign-in prepared a folder for. @@ -121,6 +123,15 @@ describe('ClaudeAccountService', () => { expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined() }) + it('asks no sign-in of an account System default runs, and lets it be selected', async () => { + const f = fixture() + f.covered.add('b') + expect(f.service.listAccounts().accounts.find((row) => row.id === 'b')?.needsSignIn).toBe( + undefined + ) + await expect(f.service.selectAccount('b')).resolves.toMatchObject({ activeAccountId: 'b' }) + }) + it("reports System default's login from the user's own folder", () => { const f = fixture() mkdirSync(join(f.root, 'personal')) diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts index 4156b58ced4b..f462c710c5df 100644 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -33,7 +33,8 @@ const fsAllowances: Record = { 'mkdirSync', 'renameSync', 'rmdirSync', - 'symlinkSync' + 'symlinkSync', + 'unlinkSync' ], 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], // The which-account file. @@ -51,7 +52,11 @@ const fsAllowances: Record = { 'writeFileSync', 'writeFileAtomically' ], - 'claude-accounts/claude-profile-provisioning.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + 'claude-accounts/claude-profile-provisioning.ts': [ + 'publishFileWithoutOverwrite', + 'rmSync', + 'writeFileSync' + ], 'claude-accounts/claude-profile-sharing.ts': [ 'mkdirSync', 'rmdirSync', diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts new file mode 100644 index 000000000000..c4cc2f42c605 --- /dev/null +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -0,0 +1,235 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { resolveClaudeStructuredInvocation } from '../claude/claude-structured-launch-resolution' +import { resolveClaudeStructuredLaunchHome } from '../claude/claude-structured-launch-home' +import { resolveStructuredClaudeAccountHomePath } from '../runtime/structured-agent-account-home' +import { + installClaudeProfileRouter, + withClaudeProfileTerminalEnv +} from './claude-profile-installed-router' +import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' +import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' +import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' +import { applyClaudeEnvPatch } from './environment' + +const roots: string[] = [] +afterEach(() => { + installClaudeProfileRouter(undefined) + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function signIn(stateDir: string, email: string): void { + mkdirSync(stateDir, { recursive: true }) + writeFileSync( + join(stateDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) +} + +/** Account a is selected and set up but signed in only in System default. */ +function coveredAccount() { + const root = mkdtempSync(join(tmpdir(), 'claude-launch-routing-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + const account: ClaudeManagedAccount = { + id: 'a', + email: 'a@example.test', + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account], + activeClaudeManagedAccountId: 'a', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot, + userHome, + env: {}, + runSetup: async () => ({ outcome: 'prepared', warnings: [], surfaces: {} }) + }) + const accountHome = router.accountHome('a') + mkdirSync(accountHome, { recursive: true }) + writeFileSync(join(accountHome, '..', 'profile.json'), '{}') + signIn(userHome, 'a@example.test') + installClaudeProfileRouter(router) + return { router, settings, accountHome, systemHome: join(userHome, '.claude') } +} + +/** Where every Orca-started Claude would run right now, read through each entry point. */ +async function launchHomes(f: ReturnType) { + const chatEnv: Record = {} + return { + terminal: (await f.router.prepareLaunch()).configDir, + terminalEnv: withClaudeProfileTerminalEnv>({}, null, { + runtime: 'host' + }).CLAUDE_CONFIG_DIR, + chat: await resolveClaudeStructuredLaunchHome(f.router, chatEnv, '/recorded'), + chatEnv: chatEnv.CLAUDE_CONFIG_DIR, + chatRecord: resolveStructuredClaudeAccountHomePath({ + launchEnv: {}, + wslDistro: null, + getClaudeConfigDirectory: () => f.router.systemDefaultHome() + }), + chatAuth: claudeStructuredAuthPolicyForSettings(f.settings), + usage: f.router.preparation().configDir, + inactiveUsage: f.router.accountUsagePreparation('a').configDir + } +} + +const mainRoot = join(__dirname, '..') + +function mainSourceFiles(): string[] { + const files: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + walk(path) + } else if ( + entry.name.endsWith('.ts') && + !/\.test\.|test-(?:support|fixture|harness)/.test(entry.name) + ) { + files.push(relative(mainRoot, path).split('\\').join('/')) + } + } + } + walk(mainRoot) + return files +} + +describe('one router decision for every Claude launch', () => { + it('sends every entry point to System default, then to the account once it signs in', async () => { + const f = coveredAccount() + expect(await launchHomes(f)).toEqual({ + terminal: f.systemHome, + terminalEnv: undefined, + chat: f.systemHome, + chatEnv: undefined, + chatRecord: f.systemHome, + chatAuth: { account: 'system' }, + usage: f.systemHome, + inactiveUsage: f.systemHome + }) + signIn(f.accountHome, 'a@example.test') + expect(await launchHomes(f)).toEqual({ + terminal: f.accountHome, + terminalEnv: f.accountHome, + chat: f.accountHome, + chatEnv: f.accountHome, + chatRecord: f.accountHome, + // A shell proxy's key stays with its address on the account too. + chatAuth: { account: 'managed' }, + usage: f.accountHome, + inactiveUsage: f.accountHome + }) + }) + + // The shell function's half is in claude-shell-function.test.ts; WSL's in the WSL router test. + it.each(['signed-in account', 'System default fallback'])( + "keeps a shell proxy's key with its address on every launch: %s", + async (scenario) => { + const f = coveredAccount() + if (scenario === 'signed-in account') { + signIn(f.accountHome, 'a@example.test') + } + const home = scenario === 'signed-in account' ? f.accountHome : undefined + const shell = { + ANTHROPIC_BASE_URL: 'https://proxy.example.test', + ANTHROPIC_API_KEY: 'proxy-key', + ANTHROPIC_AUTH_TOKEN: 'proxy-token' + } + // A terminal applies the launch's patch to its own env and deletes nothing more. + const terminal = applyClaudeEnvPatch( + { PATH: '/usr/bin', ...shell }, + (await f.router.prepareLaunch()).envPatch + ) + expect(terminal).toMatchObject(shell) + expect(terminal.CLAUDE_CONFIG_DIR).toBe(home) + const chat = await resolveClaudeStructuredInvocation({ + resolveCommand: () => 'claude', + resolveInheritedEnv: async () => ({ PATH: '/usr/bin', ...shell }), + resolveAuthPolicy: () => claudeStructuredAuthPolicyForSettings(f.settings) + }) + expect(chat.env).toMatchObject(shell) + const saved = Object.fromEntries(Object.keys(shell).map((key) => [key, process.env[key]])) + Object.assign(process.env, shell) + try { + const commit = await prepareLocalCommitMessageAgentEnv('claude', { + prepareForClaudeLaunch: () => f.router.prepareLaunch() + }) + expect(commit).toMatchObject({ ok: true, env: shell }) + if (home) { + expect(commit.ok && commit.env?.CLAUDE_CONFIG_DIR).toBe(home) + } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + } + ) + + // Terminals, chats, AI commit messages and automations launch through + // ClaudeRuntimeAuthService.prepareForClaudeLaunch; usage through prepareForRateLimitFetch. + it('keeps the Claude selection and account folders out of every launch path but the router', () => { + const routing = + /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|isHostManagedClaudeAccount)\b|\.accountHome\(/ + // Both map settings keys to model-catalog expiry or prewarm; they route nothing. + const allowed = new Set([ + 'native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts', + 'startup/main-process-account-services.ts' + ]) + const files = mainSourceFiles() + // Presence: the scan reaches the router, and the pattern finds what the router reads. + expect(files).toContain('claude-accounts/claude-profile-router.ts') + expect( + readFileSync(join(mainRoot, 'claude-accounts/claude-profile-router.ts'), 'utf8') + ).toMatch(routing) + const bypassing = files.filter( + (file) => + !file.startsWith('claude-accounts/') && + !allowed.has(file) && + routing.test(readFileSync(join(mainRoot, file), 'utf8')) + ) + expect(bypassing).toEqual([]) + }) + + // Terminals and AI commit messages run `claude` by agent id through generic launchers, which call + // prepareForClaudeLaunch for it; a new generic launcher is covered by the entry-point test above. + it('lets only router-aware modules resolve the claude binary or load the Agent SDK', () => { + const spawning = /\bresolveClaudeCommand\b|\bloadClaudeAgentSdk\b/ + const reviewed: Record = { + 'claude/claude-structured-child-env.ts': + 'chat child; its home comes from router.prepareLaunch', + 'claude/claude-stream-json-connection.ts': 'spawns the chat child with that launch env', + 'claude-accounts/claude-profile-router.ts': 'the router: a `--version` probe only', + 'claude-accounts/claude-command-process.ts': 'sign-in into a named account folder', + 'runtime/structured-claude-runtime-adapter.ts': 'wires the routed chat resolver', + 'runtime/structured-agent-session-runtime.ts': 'passes the resolver through', + 'runtime/structured-agent-runtime-registrations.ts': 'passes the resolver through', + 'runtime/structured-agent-model-catalog-discovery.ts': 'model listing on the routed chat env', + 'runtime/orca-runtime-get-worktree-ps.ts': 'wires the routed chat resolver' + } + const found = mainSourceFiles().filter((file) => + spawning.test(readFileSync(join(mainRoot, file), 'utf8')) + ) + // Reach: every reviewed module still exists and still matches, so the list cannot rot quietly. + expect(found.filter((file) => file in reviewed).sort()).toEqual(Object.keys(reviewed).sort()) + expect(found.filter((file) => !(file in reviewed))).toEqual([]) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts index 6727996886ff..957542fdd54e 100644 --- a/src/main/claude-accounts/claude-profile-history.test.ts +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -63,6 +63,23 @@ describe('Claude profile history sharing', () => { expect(fs.readFileSync(join(f.profileHome, 'projects/later.jsonl'), 'utf8')).toBe('later') } ) + itLinks( + "keeps Claude's live sessions per folder and undoes an earlier build's link", + async () => { + const f = fixture() + fs.mkdirSync(join(f.defaultHome, 'sessions')) + fs.writeFileSync(join(f.defaultHome, 'sessions/123.json'), 'default daemon') + fs.symlinkSync(join(f.defaultHome, 'sessions'), join(f.profileHome, 'sessions')) + expect((await f.share()).surfaces.sessions).toBe('synced') + expect(fs.existsSync(join(f.profileHome, 'sessions'))).toBe(false) + expect(fs.readFileSync(join(f.defaultHome, 'sessions/123.json'), 'utf8')).toBe( + 'default daemon' + ) + fs.mkdirSync(join(f.profileHome, 'sessions')) + expect((await f.share()).surfaces.sessions).toBe('unchanged') + expect(fs.lstatSync(join(f.profileHome, 'sessions')).isSymbolicLink()).toBe(false) + } + ) itLinks('recovers a directory swap interrupted before link publication', async () => { const f = fixture() fs.mkdirSync(join(f.profileHome, 'projects.orca-profile-merge')) diff --git a/src/main/claude-accounts/claude-profile-history.ts b/src/main/claude-accounts/claude-profile-history.ts index 03f13644fcff..dec67e9e0a90 100644 --- a/src/main/claude-accounts/claude-profile-history.ts +++ b/src/main/claude-accounts/claude-profile-history.ts @@ -1,4 +1,12 @@ -import { mkdirSync, readdirSync, realpathSync, rmdirSync, statSync, symlinkSync } from 'node:fs' +import { + mkdirSync, + readdirSync, + realpathSync, + rmdirSync, + statSync, + symlinkSync, + unlinkSync +} from 'node:fs' import { join } from 'node:path' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' import { resolveClaudeDefaultHome } from './claude-profile-paths' @@ -17,9 +25,9 @@ import { mergeClaudeProfilePromptHistory } from './claude-profile-prompt-history' +// Not `sessions`: Claude's live-process registry, bound to its own folder's daemon. export const CLAUDE_PROFILE_HISTORY_DIRS = [ 'projects', - 'sessions', 'session-env', 'file-history', 'shell-snapshots', @@ -136,6 +144,24 @@ function mergeDirectory( return 'linked' } +/** Undoes an earlier build's `sessions` link into the default home; Claude recreates the folder. */ +function unlinkSharedSessions(profile: string, home: string): ClaudeProfileSurfaceOutcome { + const source = join(profile, 'sessions') + if (!lstatIfPresent(source)?.isSymbolicLink()) { + return 'unchanged' + } + if (realpathSync(source) !== realpathSync(join(home, 'sessions'))) { + return 'user-owned' + } + try { + unlinkSync(source) + } catch { + // A Windows junction is removed as a directory. + rmdirSync(source) + } + return 'synced' +} + /** * Pools a profile's sessions and prompt history into the default home. Execution-host paths only; * callers go through provisionClaudeAccountProfile, which gates and creates the profile. @@ -156,6 +182,9 @@ export async function shareClaudeProfileHistory(args: { mergeDirectory(args.profileHome, defaultHome, name, platform, report) ) } + await runClaudeProfileSurface(report, 'sessions', () => + unlinkSharedSessions(args.profileHome, defaultHome) + ) await runClaudeProfileSurface(report, 'history.jsonl', () => mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, platform, report) ) diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index c4efc9f39e42..f622d60e9dfb 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -2,6 +2,7 @@ import { lstatSync, readdirSync } from 'node:fs' import { join } from 'node:path' // Why type-only: scan workers import this module, and the router's setup graph must not load there. import type { ClaudeProfileRouter } from './claude-profile-router' +import type { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' /** A real directory; a link is false, so a history folder shared by link is not read twice. */ @@ -35,6 +36,17 @@ export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { return installed } +let installedWsl: ClaudeWslProfileRouter | undefined + +/** Windows only, beside the host router. */ +export function installClaudeWslProfileRouter(router: ClaudeWslProfileRouter | undefined): void { + installedWsl = router +} + +export function getClaudeWslProfileRouter(): ClaudeWslProfileRouter | undefined { + return installedWsl +} + /** A local or WSL pane's env with the routed account's pointer added; SSH panes keep theirs. */ export function withClaudeProfileTerminalEnv | undefined>( env: Env, diff --git a/src/main/claude-accounts/claude-profile-launch-errors.ts b/src/main/claude-accounts/claude-profile-launch-errors.ts new file mode 100644 index 000000000000..9e2d538458e8 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-launch-errors.ts @@ -0,0 +1,18 @@ +import { + CLAUDE_PROFILE_MISSING_MESSAGE, + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE +} from '../../shared/claude-profile-routing' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +// Typed so a chat names the situation; a terminal reads the same message. +export function claudeProfileMissing(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_MISSING_MESSAGE), { + reason: 'claudeAccountFolderMissing' + }) +} + +export function claudeProfileSetupFailed(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE), { + reason: 'claudeAccountSetupFailed' + }) +} diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 4bc12a4738a9..30da554f29cc 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -47,7 +47,7 @@ afterEach(() => { // Why: these create real symlinks, which Windows needs privilege for. const itLinks = it.skipIf(process.platform === 'win32') -describe('dormant Claude profile provisioning', () => { +describe('Claude profile refresh from the default home', () => { itLinks('links resources, keeps private directories and sees later global installs', async () => { const f = fixture() const linked = [ @@ -106,7 +106,7 @@ describe('dormant Claude profile provisioning', () => { expect(fs.readFileSync(join(f.source, 'workflows/saved.js'), 'utf8')).toBe('saved in profile') } ) - it('copies keybindings, keeps a profile edit and follows the default while unedited', async () => { + it('copies top-level files over the profile copy, edited or not', async () => { const f = fixture() const source = join(f.source, 'keybindings.json') const copy = join(f.profileHome, 'keybindings.json') @@ -114,58 +114,47 @@ describe('dormant Claude profile provisioning', () => { expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') expect(fs.lstatSync(copy).isSymbolicLink()).toBe(false) expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[]}') - fs.writeFileSync(source, '{"bindings":[1]}') - expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') - expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[1]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('unchanged') fs.writeFileSync(copy, '{"bindings":["profile"]}') fs.writeFileSync(source, '{"bindings":[2]}') - expect((await provision(f)).surfaces['keybindings.json']).toBe('user-owned') - expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":["profile"]}') - expect(fs.readFileSync(source, 'utf8')).toBe('{"bindings":[2]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[2]}') + // A file Claude does not know yet is shared the same way, with its mode. + fs.writeFileSync(join(f.source, 'statusline.sh'), 'echo hi') + fs.chmodSync(join(f.source, 'statusline.sh'), 0o755) + await provision(f) + expect(fs.statSync(join(f.profileHome, 'statusline.sh')).mode & 0o777).toBe(0o755) }) - it('shares future settings keys and hooks but excludes auth; profile edits survive reprovision', async () => { + it('copies settings whole, proxy key and auth included, over profile edits', async () => { const f = fixture() - f.json(join(f.source, 'settings.json'), { + const settings = { futureFeature: true, model: 'a', hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, - apiKeyHelper: 'secret', - awsAuthRefresh: 'secret', - awsCredentialExport: 'secret', - forceLoginMethod: 'secret', - forceLoginOrgUUID: 'secret', + apiKeyHelper: 'helper', env: { - ANTHROPIC_API_KEY: 'secret', - ANTHROPIC_AUTH_TOKEN: 'secret', - CLAUDE_CODE_OAUTH_TOKEN: 'secret', + ANTHROPIC_BASE_URL: 'https://proxy.example.test', + ANTHROPIC_API_KEY: 'proxy-key', + ANTHROPIC_AUTH_TOKEN: 'token', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth', NORMAL: 'yes' } - }) - fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'source') + } + f.json(join(f.source, 'settings.json'), settings) await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - futureFeature: true, - model: 'a', - hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, - env: { NORMAL: 'yes' } - }) - f.json(join(f.profileHome, 'settings.json'), { - futureFeature: true, - model: 'private', - env: { NORMAL: 'yes' } - }) - fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'private instructions') - f.json(join(f.source, 'settings.json'), { model: 'b', futureFeature: false }) - fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'updated source') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual(settings) + // A `/model` change made on the account lasts only until the next refresh. + f.json(join(f.profileHome, 'settings.json'), { ...settings, model: 'private' }) + f.json(join(f.source, 'settings.json'), { model: 'b' }) + expect((await provision(f)).surfaces['settings.json']).toBe('synced') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'b' }) + expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') + fs.rmSync(join(f.source, 'settings.json')) await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toMatchObject({ - model: 'private', - futureFeature: false - }) - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('private instructions') + expect(fs.existsSync(join(f.profileHome, 'settings.json'))).toBe(false) }) itLinks( - 're-adds a shared key the profile deleted and leaves a linked settings file alone', + 'restores settings the profile changed and leaves a linked settings file alone', async () => { const f = fixture() f.json(join(f.source, 'settings.json'), { model: 'a', theme: 'x' }) @@ -181,129 +170,155 @@ describe('dormant Claude profile provisioning', () => { expect(f.read(elsewhere)).toEqual({ mine: true }) } ) - it('removes a key the default dropped unless the profile changed it, and only keys Orca shared', async () => { - const f = fixture() - const settings = join(f.source, 'settings.json') - f.json(settings, { model: 'a', theme: 'x', apiKeyHelper: 'source-secret' }) - await provision(f) - f.json(join(f.profileHome, 'settings.json'), { - ...f.read(join(f.profileHome, 'settings.json')), - theme: 'mine', - local: true, - apiKeyHelper: 'profile-helper' - }) - f.json(settings, { apiKeyHelper: 'source-secret' }) - expect((await provision(f)).surfaces['settings.json']).toBe('merged') - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - theme: 'mine', - local: true, - apiKeyHelper: 'profile-helper' - }) - expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') - f.json(settings, { model: 'b' }) - await provision(f) - fs.rmSync(settings) - await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - theme: 'mine', - local: true, - apiKeyHelper: 'profile-helper' - }) - const ledger = f.read(join(f.profileHome, '.orca-profile.json')) - expect(JSON.stringify(ledger)).not.toContain('apiKeyHelper') - }) - it('removes nothing while the default settings or state are unreadable', async () => { - const f = fixture() - f.json(join(f.source, 'settings.json'), { model: 'a' }) - f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) - await provision(f) - fs.writeFileSync(join(f.source, 'settings.json'), '{bad') - fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') - await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a' }) - expect(f.read(join(f.profileHome, '.claude.json'))).toMatchObject({ - mcpServers: { a: {} }, - theme: 'dark' - }) - f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) - await provision(f) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - oauthAccount: LOGIN, - theme: 'dark', - hasCompletedOnboarding: true - }) - }) - it('requires existing state, merges MCP/theme/onboarding and never copies or writes credentials', async () => { + it('copies state except the login, caches and install ids, and never copies credentials', async () => { const f = fixture() fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') - f.json(join(f.userHome, '.claude.json'), { + const shared = { mcpServers: { local: { command: 'example' } }, theme: 'dark', + hasCompletedOnboarding: true, + autoModeAccepted: true, + projects: { '/work': { hasTrustDialogAccepted: true } } + } + f.json(join(f.userHome, '.claude.json'), { + ...shared, oauthAccount: { emailAddress: 'source@example.com' }, - userID: 'source-id' + primaryApiKey: 'sk-source', + userID: 'source-id', + machineID: 'source-machine', + firstStartTime: 'source-start', + firstStartVersion: '1.0.0', + groveConfigCache: { a: 1 }, + cachedArtifactRoster: [], + clientDataCacheSlots: {}, + passesEligibilityCache: {}, + passesLastSeen: 1, + claudeCodeFirstTokenDate: 'source-date', + startupPrefetchedAt: 1, + additionalModelOptionsAnsweredAt: 1, + artifactRosterDenied: true, + lastSeenOrgDefaultUpdatedAt: 1, + hasAvailableSubscription: true, + subscriptionNoticeCount: 2 }) - await provision(f) - expect(fs.existsSync(join(f.profileHome, '.claude.json'))).toBe(false) + // A folder with no state file yet gets one without a login, so Claude asks for its own. + expect((await provision(f)).surfaces['.claude.json']).toBe('synced') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual(shared) + // Published whole through a staged file, which is gone afterwards. + expect(fs.readdirSync(f.profileHome).filter((name) => name.endsWith('.tmp'))).toEqual([]) expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) + fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') f.json(join(f.profileHome, '.claude.json'), { oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', - projects: { '/work': { allowedTools: ['Read'] } } + groveConfigCache: { b: 2 }, + theme: 'light', + onlyHere: true }) - await provision(f) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ oauthAccount: { emailAddress: 'profile@example.com' }, userID: 'profile-id', - mcpServers: { local: { command: 'example' } }, - theme: 'dark', - hasCompletedOnboarding: true, - projects: { '/work': { allowedTools: ['Read'] } } + groveConfigCache: { b: 2 }, + onlyHere: true, + ...shared }) + expect((await provision(f)).surfaces['.claude.json']).toBe('unchanged') expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( 'SOURCE_CREDENTIAL_BYTES' ) expect(fs.readFileSync(join(f.profileHome, '.credentials.json'), 'utf8')).toBe( 'PROFILE_CREDENTIAL_BYTES' ) - for (const name of fs.readdirSync(f.profileHome)) { - const file = join(f.profileHome, name) - if (fs.lstatSync(file).isFile()) { - expect(fs.readFileSync(file, 'utf8')).not.toContain('SOURCE_CREDENTIAL_BYTES') + }) + itLinks( + "shares user content, never Claude's runtime state, live sessions or login files", + async () => { + const f = fixture() + const unsharedDirs = [ + 'daemon', + 'jobs', + 'state', + 'sessions', + 'teams', + 'ide', + 'remote-control', + 'shares', + 'uploads', + 'storage-v2', + 'downloads', + 'scratch', + 'agent-memory-local', + '.hidden-dir' + ] + for (const name of [...unsharedDirs, 'skills', 'plugins', 'my-own-dir']) { + fs.mkdirSync(join(f.source, name)) + } + const unsharedFiles = [ + 'daemon.status.json', + '.credentials.json', + '.session_ingress_token', + 'hfi-auth.json', + 'server-sessions.json', + 'active-time.json', + 'computer-use.lock', + 'gh-pr-status-cache.json', + 'loop.md', + 'policy-limits.json', + 'mcp-needs-auth-cache.json', + 'stats-cache.json', + '.last-update-check', + '.config.json', + '.claude.json', + '.claude.json.backup.1' + ] + for (const name of unsharedFiles) { + fs.writeFileSync(join(f.source, name), 'x') + } + // The account's own runtime file is never overwritten from the default home. + fs.writeFileSync(join(f.profileHome, 'server-sessions.json'), 'mine') + await provision(f) + for (const name of ['skills', 'plugins', 'my-own-dir']) { + expect(fs.realpathSync(join(f.profileHome, name))).toBe( + fs.realpathSync(join(f.source, name)) + ) } + for (const name of [...unsharedDirs, ...unsharedFiles].filter( + (name) => name !== 'server-sessions.json' + )) { + expect(fs.existsSync(join(f.profileHome, name))).toBe(false) + } + expect(fs.readFileSync(join(f.profileHome, 'server-sessions.json'), 'utf8')).toBe('mine') } - }) - it('leaves a folder Claude has not signed in yet to Claude’s own onboarding', async () => { + ) + it('merges trusted folders and MCP servers per entry, never untrusting an account folder', 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', + f.json(join(f.userHome, '.claude.json'), { + projects: { + '/shared': { hasTrustDialogAccepted: false, allowedTools: ['Read'] }, + '/default-only': { hasTrustDialogAccepted: true } + }, + mcpServers: { shared: { command: 'default' } } + }) + f.json(join(f.profileHome, '.claude.json'), { oauthAccount: LOGIN, - mcpServers: { a: {} }, - hasCompletedOnboarding: true + projects: { + '/shared': { hasTrustDialogAccepted: true }, + '/account-only': { hasTrustDialogAccepted: true, allowedTools: ['Bash'] } + }, + mcpServers: { shared: { command: 'account' }, mine: { command: 'mine' } } }) - }) - it('still forces onboarding 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) + await provision(f) expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', oauthAccount: LOGIN, - hasCompletedOnboarding: true + projects: { + '/shared': { hasTrustDialogAccepted: true, allowedTools: ['Read'] }, + '/account-only': { hasTrustDialogAccepted: true, allowedTools: ['Bash'] }, + '/default-only': { hasTrustDialogAccepted: true } + }, + mcpServers: { shared: { command: 'default' }, mine: { command: 'mine' } } }) - expect(report.warnings).toContainEqual( - expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) - ) }) it('skips the state write while Claude holds its lock and records nothing for it', async () => { const f = fixture() @@ -323,7 +338,7 @@ describe('dormant Claude profile provisioning', () => { expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') }) - it('records a shared value only after its write succeeded', async () => { + it('retries a failed settings copy on the next refresh', async () => { const f = fixture() f.json(join(f.source, 'settings.json'), { theme: 'dark' }) await provision(f) @@ -332,44 +347,9 @@ describe('dormant Claude profile provisioning', () => { throw Object.assign(new Error('busy'), { code: 'EBUSY' }) }) expect((await provision(f)).surfaces['settings.json']).toBe('failed') - expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect((await provision(f)).surfaces['settings.json']).toBe('synced') expect(f.read(join(f.profileHome, 'settings.json')).theme).toBe('light') }) - itLinks('resets an unreadable ledger instead of blocking every surface', async () => { - const f = fixture() - fs.mkdirSync(join(f.source, 'skills')) - f.json(join(f.source, 'settings.json'), { model: 'a' }) - fs.writeFileSync(join(f.profileHome, '.orca-profile.json'), '') - const report = await provision(f) - expect(report.surfaces.skills).toBe('linked') - expect(report.surfaces['settings.json']).toBe('merged') - expect(report.warnings).toEqual([]) - expect(f.read(join(f.profileHome, '.orca-profile.json')).keys).toEqual({ - 'settings.json': { model: '"a"' } - }) - }) - itLinks( - 'keys shared values by surface, so another spelling of the profile keeps sharing', - async () => { - const f = fixture() - fs.writeFileSync(join(f.source, 'keybindings.json'), 'v1') - f.json(join(f.source, 'settings.json'), { model: 'a' }) - await provision(f) - fs.writeFileSync(join(f.source, 'keybindings.json'), 'v2') - f.json(join(f.source, 'settings.json'), { model: 'b' }) - const aliasRoot = fs.mkdtempSync(join(tmpdir(), 'claude-profile-alias-')) - roots.push(aliasRoot) - const alias = join(aliasRoot, 'link') - fs.symlinkSync(f.root, alias) - const report = await provisionClaudeProfile({ - profileHome: join(alias, 'profile'), - userHome: f.userHome, - platform: 'linux' - }) - expect(report.surfaces['keybindings.json']).toBe('synced') - expect(f.read(join(f.profileHome, 'settings.json')).model).toBe('b') - } - ) it('imports the personal CLAUDE.md instead of copying it, so Claude loads it once', async () => { const f = fixture() await provision(f) @@ -379,6 +359,8 @@ describe('dormant Claude profile provisioning', () => { expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('@~/.claude/CLAUDE.md\n') fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') + fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'edited in the profile') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') }) itLinks( "shares from the user's own CLAUDE_CONFIG_DIR, copying its CLAUDE.md and state", @@ -418,6 +400,7 @@ describe('dormant Claude profile provisioning', () => { }) it('leaves malformed profile state unchanged', async () => { const f = fixture() + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) fs.writeFileSync(join(f.profileHome, '.claude.json'), '{bad') expect((await provision(f)).warnings).toContainEqual( expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 8c72cf291a14..f637c3e74143 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -1,11 +1,14 @@ -import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' -import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' -import { claudeStateLogin } from './claude-account-folder' +import { getManagedScriptFileName, removeManagedHooks } from '../claude/hook-settings' +import { publishFileWithoutOverwrite } from '../codex-accounts/fs-utils' +import { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' import { lstatIfPresent } from './claude-profile-prompt-history' import { @@ -16,147 +19,248 @@ import { type ClaudeProfileReport, type ClaudeProfileSurfaceOutcome } from './claude-profile-report' -import { - claudeProfileLedgerPath, - dropClaudeProfileKeys, - linkClaudeProfileDirectory, - mergeClaudeProfileKeys, - readClaudeProfileLedger, - syncClaudeProfileFile, - writeClaudeProfileLedger, - type ClaudeProfileLedger -} from './claude-profile-sharing' +import { copyClaudeProfileFile, linkClaudeProfileDirectory } from './claude-profile-sharing' -export const CLAUDE_PROFILE_RESOURCE_DIRS = [ - 'skills', - 'plugins', - 'agents', - 'commands', - 'output-styles', - 'rules', - 'themes', - 'workflows' -] 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' -const PRIVATE_KEYS = new Set([ - 'apiKeyHelper', - 'awsAuthRefresh', - 'awsCredentialExport', - 'forceLoginMethod', - 'forceLoginOrgUUID' + +/** + * Mirrors the list Claude itself leaves out when it seeds a fresh config folder from ~/.claude + * (its self-hosted runner's host-config snapshot): login, live runtime state and caches. + */ +const CLAUDE_RUNTIME_STATE_ENTRIES = [ + '.claude.json', + '.claude.json.backup', + '.credentials.json', + '.session_ingress_token', + 'active-time.json', + 'antproto.json', + 'api-dumps', + 'backups', + 'bridge-spawn', + 'cache', + 'ccr', + 'ccr-home-seed.json', + 'chrome', + 'computer-use.lock', + 'daemon', + 'debug', + 'downloads', + 'dump-prompts', + 'feedback', + 'feedback-bundles', + 'file-history', + 'file-transfers', + 'gh-pr-status-cache.json', + 'hfi-auth.json', + 'history.jsonl', + 'ide', + 'image-cache', + 'jobs', + 'local', + 'local-settings', + 'logs', + 'loop.md', + 'mcp-discovery-cache', + 'mcp-needs-auth-cache.json', + 'mcp-skill-archives', + 'paste-cache', + 'plans', + 'policy-limits.json', + 'project-settings', + 'projects', + 'remote', + 'remote-control', + 'remote-settings.json', + 'scratch', + 'seed-admin', + 'server-sessions.json', + 'server.lock', + 'session-env', + 'sessions', + 'shares', + 'shell-snapshots', + 'startup-perf', + 'state', + 'stats-cache.json', + 'statsig', + 'storage-v2', + 'systemd', + 'tasks', + 'teams', + 'telemetry', + 'todos', + 'traces', + 'uploads', + 'usage-data' +] as const + +/** Never linked or copied: runtime state, settings.json (copied whole) and history (linked the other way). */ +const UNSHARED_ENTRIES: ReadonlySet = new Set([ + ...CLAUDE_RUNTIME_STATE_ENTRIES, + 'settings.json', + ...CLAUDE_PROFILE_HISTORY_DIRS ]) -const PRIVATE_ENV = new Set([ - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'CLAUDE_CODE_OAUTH_TOKEN' + +function isSharedEntry(name: string): boolean { + // Why the prefixes: Claude's own seeding skips every hidden entry (state files, tokens, locks), + // daemon files and per-folder agent memory too. + return ( + !UNSHARED_ENTRIES.has(name) && + !name.startsWith('.') && + !name.startsWith('daemon') && + !name.startsWith('agent-memory') + ) +} + +/** + * Mirrors what Claude itself resets in .claude.json when it signs out (its logout, 2.1.295), plus + * the subscription notices it resets with them. Onboarding, which logout also resets, is copied. + */ +const CLAUDE_LOGOUT_STATE_KEYS = [ + 'oauthAccount', + 'additionalModelOptionsCache', + 'additionalModelOptionsAnsweredAt', + 'additionalModelCostsCache', + 'modelAccessCache', + 'orgModelDefaultCache', + 'cachedArtifactRoster', + 'artifactRosterDenied', + 'lastSeenOrgDefaultUpdatedAt', + 'clientDataCache', + 'clientDataCacheSlots', + 'autoCompactWindowsCache', + 'cachedUsageUtilization', + 'metricsStatusCache', + 'metricsStatusCacheByPrincipal', + 'githubWebConnectionStatusCache', + 'startupPrefetchedAt', + 'subscriptionNoticeCount', + 'hasAvailableSubscription' +] as const + +/** Never copied: logout's keys, the install ids Claude writes before any sign-in, and logins. */ +const ACCOUNT_BOUND_STATE_KEYS: ReadonlySet = new Set([ + ...CLAUDE_LOGOUT_STATE_KEYS, + 'userID', + 'machineID', + 'firstStartTime', + 'firstStartVersion', + // Why: a Console API key login; copied, it would outrank the account's own login. + 'primaryApiKey', + // Why: Claude never refetches it. + 'claudeCodeFirstTokenDate' ]) -const SHARED_STATE_KEYS = ['mcpServers', 'theme'] -// Why `hooks` is shared: Orca writes identical entries into every folder, so the installer that -// runs after the merge finds them present and the user's own hooks keep running in every account. -function pickSettings(source: Record): Record { - return Object.fromEntries( - Object.entries(source) - .filter(([key]) => !PRIVATE_KEYS.has(key)) - .map(([key, value]) => { - if (key === 'env' && value && typeof value === 'object' && !Array.isArray(value)) { - return [ - key, - Object.fromEntries(Object.entries(value).filter(([name]) => !PRIVATE_ENV.has(name))) - ] - } - return [key, value] - }) +function isAccountBoundState(key: string): boolean { + // Why the patterns too: account caches logout leaves behind (groveConfigCache, passes*). + return ( + ACCOUNT_BOUND_STATE_KEYS.has(key) || + key.includes('Cache') || + key.startsWith('cached') || + key.startsWith('passes') ) } -function mergeSettings( - source: string, - target: string, - ledger: ClaudeProfileLedger -): ClaudeProfileSurfaceOutcome { - if (lstatIfPresent(target)?.isSymbolicLink()) { - return 'user-owned' - } - const existing = readClaudeProfileObject(target) +// Why without Orca's hooks: the profile's own hook install owns those, so differing there alone +// would recopy, and the install rewrite, the file on every launch. +function settingsWithoutOrcaHooks(value: Record): string { + return JSON.stringify(removeManagedHooks(value, getManagedScriptFileName()).config) +} + +/** The whole file: the default home is the master copy, proxy address and key included. */ +function copySettings(source: string, target: string): ClaudeProfileSurfaceOutcome { const input = readClaudeProfileObject(source) - if (existing.kind === 'unavailable' || input.kind === 'unavailable') { - throw new ClaudeProfileSurfaceError('unreadable', 'Claude settings.json is unreadable') + const current = readClaudeProfileObject(target) + // Why by value: the hook installer rewrites the copy in its own layout after every refresh. + if ( + input.kind === 'present' && + current.kind === 'present' && + !lstatIfPresent(target)?.isSymbolicLink() && + settingsWithoutOrcaHooks(input.value) === settingsWithoutOrcaHooks(current.value) + ) { + return 'unchanged' + } + const outcome = copyClaudeProfileFile(source, target) + if (outcome === 'absent' && lstatIfPresent(target)?.isFile()) { + rmSync(target, { force: true }) + return 'synced' + } + return outcome +} + +/** Keyed by folder path or server name: entries the account recorded on its own are kept. */ +const MERGED_BY_ENTRY: ReadonlySet = new Set(['projects', 'mcpServers']) + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function mergeEntries(key: string, current: unknown, source: unknown): unknown { + if (!isPlainRecord(current) || !isPlainRecord(source)) { + return source } - const config: Record = existing.kind === 'present' ? { ...existing.value } : {} - const desired = pickSettings(input.kind === 'present' ? input.value : {}) - const written = { ...ledger.keys['settings.json'] } - const changed = - mergeClaudeProfileKeys(config, desired, written).length + - dropClaudeProfileKeys(config, desired, written).length - if (changed > 0) { - writeFileAtomically(target, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + const merged: Record = { ...current, ...source } + if (key === 'projects') { + for (const [path, entry] of Object.entries(source)) { + const own = current[path] + // Why: a folder trusted in the account, often by Orca just before launch, stays trusted. + if (isPlainRecord(own) && own.hasTrustDialogAccepted === true && isPlainRecord(entry)) { + merged[path] = { ...entry, hasTrustDialogAccepted: true } + } + } } - // Committed only after the write, so a failed write never marks an unwritten value as shared. - ledger.keys['settings.json'] = written - return changed > 0 ? 'merged' : existing.kind === 'absent' ? 'absent' : 'unchanged' + return merged } -async function mergeState(args: { - source: string - target: string - ledger: ClaudeProfileLedger - report: ClaudeProfileReport -}): Promise { - if (lstatIfPresent(args.target)?.isSymbolicLink()) { +async function copyState(source: string, target: string): Promise { + if (lstatIfPresent(target)?.isSymbolicLink()) { return 'user-owned' } - const input = readClaudeProfileObject(args.source) + const input = readClaudeProfileObject(source) if (input.kind === 'unavailable') { - // Why: onboarding doesn't depend on the personal state; only its shared keys wait. - const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') - warnClaudeProfile(args.report, '.claude.json', error) + throw new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') } - const source = input.kind === 'present' ? input.value : {} - const desired = Object.fromEntries( - SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) - ) - let written: Record = {} - 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' } + if (input.kind === 'absent') { + return 'absent' + } + const shared = Object.entries(input.value).filter(([key]) => !isAccountBoundState(key)) + if (!lstatIfPresent(target)) { + const staged = `${target}.${process.pid}.${randomUUID()}.tmp` + try { + writeFileSync(staged, `${JSON.stringify(Object.fromEntries(shared), null, 2)}\n`, { + flag: 'wx', + mode: 0o600 + }) + // Why a link, not a rename: whole or absent, and Claude's own file, created meanwhile, wins. + if (publishFileWithoutOverwrite(staged, target)) { + return 'synced' + } + } finally { + rmSync(staged, { force: true }) } + } + // Why no deletions: a key the default home lacks may be one Claude added only in this folder. + const outcome = await updateClaudeGlobalConfig(target, (current) => { const config = { ...current } - written = { ...args.ledger.keys['.claude.json'] } - let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 - if ( - input.kind !== 'unavailable' && - dropClaudeProfileKeys(config, desired, written).length > 0 - ) { - changed = true - } - // Why: otherwise first launch opens the onboarding wizard, where a stray Enter starts a login that rebinds the profile. - if (config.hasCompletedOnboarding !== true) { - config.hasCompletedOnboarding = true - changed = true + for (const [key, value] of shared) { + config[key] = MERGED_BY_ENTRY.has(key) ? mergeEntries(key, current[key], value) : value } - return changed ? { kind: 'changed', config } : { kind: 'unchanged' } + return JSON.stringify(config) === JSON.stringify(current) + ? { kind: 'unchanged' } + : { kind: 'changed', config } }) - if (outcome === 'missing-config' || (outcome === 'unchanged' && signedOut)) { - // Why: no completed login yet; writing would fabricate an account or skip Claude's sign-in. - return 'absent' - } - if (outcome === 'locked' || outcome === 'unreadable') { - throw new ClaudeProfileSurfaceError(outcome, `Profile Claude state is ${outcome}`) + if (outcome === 'locked' || outcome === 'unreadable' || outcome === 'missing-config') { + throw new ClaudeProfileSurfaceError( + outcome === 'locked' ? 'locked' : 'unreadable', + `Profile Claude state is ${outcome}` + ) } - args.ledger.keys['.claude.json'] = written return outcome === 'updated' ? 'merged' : 'unchanged' } /** - * Shares the default home's config into a profile. Execution-host paths; never touches credentials. + * Refreshes a profile from the default home, the master copy: everything but the login and + * account-bound items. Execution-host paths; never writes the default home or credentials. * Callers go through provisionClaudeAccountProfile, which gates and creates the profile. */ export async function provisionClaudeProfile(args: { @@ -169,35 +273,36 @@ export async function provisionClaudeProfile(args: { const platform = args.platform ?? process.platform const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) const report = createClaudeProfileReport() - const ledgerPath = claudeProfileLedgerPath(args.profileHome) - const ledger = readClaudeProfileLedger(ledgerPath) - const recorded = JSON.stringify(ledger) - for (const name of CLAUDE_PROFILE_RESOURCE_DIRS) { - await runClaudeProfileSurface(report, name, () => - linkClaudeProfileDirectory(join(defaultHome, name), join(args.profileHome, name), platform) - ) - } // Why only for ~/.claude: Claude also loads it as a parent folder's memory for projects under // home, so a copy would load twice; a custom CLAUDE_CONFIG_DIR is copied, as superset does. const imported = resolve(defaultHome) === resolve(args.userHome, '.claude') - const memory = imported ? () => CLAUDE_PROFILE_MEMORY_IMPORT : undefined - for (const name of CLAUDE_PROFILE_RESOURCE_FILES) { - await runClaudeProfileSurface(report, name, () => - syncClaudeProfileFile( - join(defaultHome, name), - join(args.profileHome, name), - name, - ledger, - name === 'CLAUDE.md' ? memory : undefined - ) - ) + let names: string[] = [] + try { + names = readdirSync(defaultHome).filter(isSharedEntry) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + warnClaudeProfile(report, 'profile', error) + } + } + for (const name of names) { + const source = join(defaultHome, name) + const target = join(args.profileHome, name) + await runClaudeProfileSurface(report, name, () => { + // Why stat, not lstat: a linked entry is shared as what it points at. + const entry = statSync(source, { throwIfNoEntry: false }) + if (entry?.isDirectory()) { + return linkClaudeProfileDirectory(source, target, platform) + } + if (!entry?.isFile()) { + return 'absent' + } + return name === 'CLAUDE.md' && imported + ? copyClaudeProfileFile(source, target, () => Buffer.from(CLAUDE_PROFILE_MEMORY_IMPORT)) + : copyClaudeProfileFile(source, target) + }) } await runClaudeProfileSurface(report, 'settings.json', () => - mergeSettings( - join(defaultHome, 'settings.json'), - join(args.profileHome, 'settings.json'), - ledger - ) + copySettings(join(defaultHome, 'settings.json'), join(args.profileHome, 'settings.json')) ) const statePath = (configDir: string | undefined): string => resolveClaudeGlobalConfigFile({ @@ -207,19 +312,7 @@ export async function provisionClaudeProfile(args: { exists: existsSync }) await runClaudeProfileSurface(report, '.claude.json', () => - mergeState({ - source: statePath(args.userConfigDir), - target: statePath(args.profileHome), - ledger, - report - }) + copyState(statePath(args.userConfigDir), statePath(args.profileHome)) ) - await runClaudeProfileSurface(report, 'ledger', () => { - if (JSON.stringify(ledger) === recorded) { - return 'unchanged' - } - writeClaudeProfileLedger(ledgerPath, ledger) - return 'synced' - }) return report } diff --git a/src/main/claude-accounts/claude-profile-report.ts b/src/main/claude-accounts/claude-profile-report.ts index 2a5baae068ed..0ceb42b504a4 100644 --- a/src/main/claude-accounts/claude-profile-report.ts +++ b/src/main/claude-accounts/claude-profile-report.ts @@ -1,19 +1,5 @@ -import type { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' -import type { - CLAUDE_PROFILE_RESOURCE_DIRS, - CLAUDE_PROFILE_RESOURCE_FILES -} from './claude-profile-provisioning' - -export type ClaudeProfileSurface = - | 'profile' - | (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] - | 'history.jsonl' - | (typeof CLAUDE_PROFILE_RESOURCE_DIRS)[number] - | (typeof CLAUDE_PROFILE_RESOURCE_FILES)[number] - | 'settings.json' - | '.claude.json' - | 'ledger' - | 'hooks' +/** A setup step: 'profile', 'hooks', or the name of what it shares (`skills`, `settings.json`). */ +export type ClaudeProfileSurface = string export type ClaudeProfileSurfaceOutcome = | 'linked' diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 3db8218f016f..f65069ebe7a6 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -96,13 +96,99 @@ function fixture(env: NodeJS.ProcessEnv = {}) { dataRoot, userHome, env, - runSetup + runSetup, + refreshWaitMs: 500 }) const home = (id: string) => join(dataRoot, 'claude-profiles', id, 'home') return { root, userHome, dataRoot, settings, router, home, setup } } +function signIn(stateDir: string, email: string, organizationUuid?: string): void { + mkdirSync(stateDir, { recursive: true }) + writeFileSync( + join(stateDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email, organizationUuid } }) + ) +} + describe('ClaudeProfileRouter', () => { + it('runs an account with no login of its own on System default while that is signed in to it', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + // ~/.claude's state file sits in the home folder; the email matches case-insensitively. + signIn(f.userHome, 'A@Example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ + configDir: join(f.userHome, '.claude'), + provenance: 'system' + }) + expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') + expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) + expect(f.router.routedHome()).toBeNull() + // The pointer names the folder actually chosen, so a typed claude agrees with Orca's launches. + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe('') + expect(f.router.coveredBySystemDefault('a')).toBe(true) + + // After the account signs in to its own folder, the next launch uses it. + signIn(f.home('a'), 'a@example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ + configDir: f.home('a') + }) + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) + expect(f.router.coveredBySystemDefault('a')).toBe(false) + }) + + it('tells whether a terminal opened before routing runs another account than the selected one', () => { + const f = fixture() + expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) + signIn(f.userHome, 'a@example.test') + expect(f.router.systemDefaultRunsAnotherAccount()).toBe(false) + // The account's own login names it once it has one. + const other = fixture() + signIn(other.userHome, 'a@example.test') + signIn(other.home('a'), 'someone-else@example.test') + expect(other.router.systemDefaultRunsAnotherAccount()).toBe(true) + f.settings.activeClaudeManagedAccountId = null + expect(f.router.systemDefaultRunsAnotherAccount()).toBeNull() + }) + + it('keeps an account on its own folder when System default is signed in to another email, or out', async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + signIn(f.userHome, 'b@example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + writeFileSync(join(f.userHome, '.claude.json'), '{}') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + }) + + it('never covers an account saved for another organization of the same email', () => { + const f = fixture() + f.settings.claudeManagedAccounts = f.settings.claudeManagedAccounts.map((account) => ({ + ...account, + organizationUuid: account.id === 'a' ? 'org-a' : null + })) + signIn(f.userHome, 'a@example.test', 'org-b') + expect(f.router.coveredBySystemDefault('a')).toBe(false) + expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) + signIn(f.userHome, 'a@example.test', 'org-a') + expect(f.router.coveredBySystemDefault('a')).toBe(true) + // Either side naming no organization compares by email alone. + signIn(f.userHome, 'b@example.test', 'org-b') + expect(f.router.coveredBySystemDefault('b')).toBe(true) + }) + + it("compares against the user's own CLAUDE_CONFIG_DIR login when they set one", async () => { + const own = resolve(mkdtempSync(join(tmpdir(), 'claude-router-own-'))) + roots.push(own) + const f = fixture({ CLAUDE_CONFIG_DIR: own }) + signIn(f.userHome, 'b@example.test') + signIn(own, 'a@example.test') + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: own }) + // A covered account's folder is neither needed nor refreshed for the launch. + expect(f.setup.calls).toBe(0) + }) + it('prepares an account folder for sign-in only when setup succeeds', async () => { const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/custom/claude') }) expect(f.router.accountHome('b')).toBe(f.home('b')) @@ -162,7 +248,7 @@ describe('ClaudeProfileRouter', () => { 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 () => { + it('refreshes a set-up folder before each launch, which never fails or hangs on it', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) const first = f.router.prepareLaunch() @@ -170,15 +256,27 @@ describe('ClaudeProfileRouter', () => { f.setup.settle() await expect(first).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() + // The next launch waits for its refresh. + let launched = false + const second = f.router.prepareLaunch().then((prepared) => { + launched = true + return prepared + }) await vi.waitFor(() => expect(f.setup.calls).toBe(2)) + expect(launched).toBe(false) + f.setup.settle() + await expect(second).resolves.toMatchObject({ configDir: f.home('a') }) + + // A refresh that hangs, then fails, only delays a launch briefly. + f.setup.outcome = 'refused' await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) + expect(f.setup.calls).toBe(3) f.setup.settle() await new Promise((resolve) => setTimeout(resolve, 10)) - await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a') }) - expect(f.setup.calls).toBe(2) + const fourth = f.router.prepareLaunch() + await vi.waitFor(() => expect(f.setup.calls).toBe(4)) + f.setup.settle() + await expect(fourth).resolves.toMatchObject({ configDir: f.home('a') }) }) it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { @@ -208,11 +306,12 @@ describe('ClaudeProfileRouter', () => { await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) }) - it("launches a set-up account without waiting for the login shell's env", async () => { + it("launches a set-up, signed-in account without waiting for the login shell's env", async () => { shell.hang = true const f = fixture() mkdirSync(f.home('a'), { recursive: true }) writeFileSync(join(f.home('a'), '..', 'profile.json'), '{}') + signIn(f.home('a'), 'a@example.test') const router = new ClaudeProfileRouter({ getSettings: () => f.settings, dataRoot: f.dataRoot, @@ -252,7 +351,6 @@ describe('ClaudeProfileRouter', () => { mkdirSync(f.home('a'), { recursive: true }) expect(f.router.preparation()).toMatchObject({ configDir: f.home('a'), - stripAuthEnv: true, envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, CLAUDE_CONFIG_DIR: f.home('a'), @@ -262,7 +360,6 @@ describe('ClaudeProfileRouter', () => { f.settings.activeClaudeManagedAccountId = null expect(f.router.preparation()).toMatchObject({ configDir: resolve('/user/own'), - stripAuthEnv: false, envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath } }) expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') @@ -328,6 +425,21 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) }) + it("gives a pane only the pointer until the login shell's env arrives, then rewrites it", async () => { + const f = fixture() + mkdirSync(f.home('a'), { recursive: true }) + signIn(f.home('a'), 'a@example.test') + const router = new ClaudeProfileRouter({ + getSettings: () => f.settings, + dataRoot: f.dataRoot, + userHome: f.userHome, + runSetup: async () => ({ outcome: 'prepared', warnings: [], surfaces: {} }) + }) + expect(router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: router.pointerPath }) + await vi.waitFor(() => expect(readFileSync(router.pointerPath, 'utf8')).toBe(f.home('a'))) + expect(router.terminalEnv()).toMatchObject({ CLAUDE_CONFIG_DIR: f.home('a') }) + }) + it('takes System default from the login shell, which a Dock launch does not inherit', async () => { const f = fixture() f.settings.activeClaudeManagedAccountId = null diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index fa44e53b5630..3a7d3f81f42d 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -1,19 +1,18 @@ -import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import { CLAUDE_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 { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' import type { GlobalSettings } from '../../shared/global-settings-types' -import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' +import { probeRecentClaudeCliVersion } from '../claude/claude-hook-event-versions' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeProfileMarkerPath, describeClaudeProfile, @@ -26,14 +25,23 @@ import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker' import type { ClaudeEnvPatch } from './environment' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' import { + findClaudeAccount, getSelectedClaudeAccountIdForTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' -import { removeClaudeAccountFolder } from './claude-account-folder' +import { + claudeStateFile, + readClaudeFolderLogin, + removeClaudeAccountFolder, + isSameClaudeLogin, + type ClaudeFolderLogin +} from './claude-account-folder' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +const REFRESH_WAIT_MS = 5_000 + export type ClaudeProfileRouterSettings = Pick< GlobalSettings, | 'claudeManagedAccounts' @@ -52,6 +60,7 @@ export class ClaudeProfileRouter { private readonly setups = new Map>() private env: NodeJS.ProcessEnv private readonly envReady: Promise + private envResolved: boolean constructor( private readonly args: { getSettings: () => ClaudeProfileRouterSettings @@ -61,14 +70,26 @@ export class ClaudeProfileRouter { env?: NodeJS.ProcessEnv /** Tests replace the worker. */ runSetup?: typeof runClaudeProfileSetupInWorker + /** Tests shorten how long a launch waits on a refresh. */ + refreshWaitMs?: number } ) { this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') this.env = args.env ?? process.env // Why the login shell's: a Dock launch lacks the CLAUDE_CONFIG_DIR an rc exports. Chats share it. + this.envResolved = Boolean(args.env) this.envReady = args.env ? Promise.resolve() - : resolveLoginShellEnvironment().then((env) => (this.env = env)) + : resolveLoginShellEnvironment().then((env) => { + this.env = env + this.envResolved = true + // Why: a pointer written before then compared against the wrong System default. + try { + this.writePointer() + } catch (error) { + console.warn('[claude-profile] Could not update the Claude account pointer:', error) + } + }) } private get userHome(): string { @@ -80,14 +101,43 @@ export class ClaudeProfileRouter { return id ? this.describe(id) : null } + /** The folder launches run in: the selected account's, unless System default covers it. */ + private routedProfile(): ClaudeProfileDescriptor | null { + const profile = this.selectedProfile() + return profile && !this.coveredBySystemDefault(profile.accountId) ? profile : null + } + + /** The login an account's own folder holds; null until it signs in there. */ + accountLogin(accountId: string): ClaudeFolderLogin | null { + return readClaudeFolderLogin(claudeStateFile(this.accountHome(accountId), this.userHome)) + } + + /** The login System default holds. */ + systemDefaultLogin(maxAgeMs = 0): ClaudeFolderLogin | null { + return readClaudeFolderLogin(claudeStateFile(this.userConfigDir(), this.userHome), maxAgeMs) + } + + /** + * An account with no login of its own runs on System default while that is signed in to the + * same email, so an account saved before per-account folders logs nobody out. + */ + coveredBySystemDefault(accountId: string): boolean { + if (this.accountLogin(accountId)) { + return false + } + const saved = findClaudeAccount(this.args.getSettings(), accountId) + const systemDefault = this.systemDefaultLogin() + return Boolean(saved && systemDefault && isSameClaudeLogin(saved, systemDefault)) + } + /** The user's System default: their own CLAUDE_CONFIG_DIR, else ~/.claude. */ systemDefaultHome(): string { return resolveClaudeDefaultHome(this.userHome, this.userConfigDir()) } /** Null for System default. Throws for a missing folder: falling back would run the wrong account. */ - selectedHome(): string | null { - const home = this.selectedProfile()?.home ?? null + routedHome(): string | null { + const home = this.routedProfile()?.home ?? null if (home !== null && !isDirectory(home)) { throw claudeProfileMissing() } @@ -96,38 +146,66 @@ export class ClaudeProfileRouter { /** Pointer first, then setup in the background, as superset does. No saved accounts means no pointer. */ publish(): void { - if (this.args.getSettings().claudeManagedAccounts.length === 0) { - rmSync(this.pointerPath, { force: true }) + if (!this.writePointer()) { return } + // Why even a missing or covered folder: setup creates it without a login, so a sign-in, + // or Claude's own first run, signs in there. const profile = this.selectedProfile() - mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) - writeFileAtomically(this.pointerPath, profile?.home ?? '', { mode: 0o600 }) - // Why 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) { - this.setUp(profile).catch((error: unknown) => { - console.warn('[claude-profile] Account setup failed:', error) - }) + this.setUpInBackground(profile) + } + } + + /** The routed folder for the `claude` function; false when no account is saved, so no pointer. */ + private writePointer(): boolean { + if (this.args.getSettings().claudeManagedAccounts.length === 0) { + rmSync(this.pointerPath, { force: true }) + return false } + const home = this.routedProfile()?.home ?? '' + // Why compare first: launches re-sync it, and most find it unchanged. + if (!existsSync(this.pointerPath) || readFileSync(this.pointerPath, 'utf8') !== home) { + mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) + writeFileAtomically(this.pointerPath, home, { mode: 0o600 }) + } + return true + } + + private setUpInBackground(profile: ClaudeProfileDescriptor): void { + this.setUp(profile).catch((error: unknown) => { + console.warn('[claude-profile] Account setup failed:', error) + }) } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + /** Refreshes the routed folder first; only a first setup that fails stops the launch. */ async prepareLaunch(): Promise { - const profile = this.selectedProfile() - if (!profile) { - // Only System default reads the login shell's env here; setup awaits it itself. + const selected = this.selectedProfile() + // Why wait only without the account's own login: System default, and whether it covers the + // account, depend on the login shell's CLAUDE_CONFIG_DIR; setup awaits it itself. + if (!selected || !this.accountLogin(selected.accountId)) { await this.envReady + } + // Why: a sign-in or a System default login change since the last publish moves the route. + this.writePointer() + const profile = this.routedProfile() + if (!profile) { return this.preparation() } - // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a - // set-up folder never blocks. + // Why the marker: setup writes it last, so a missing folder is set up too. if (!existsSync(claudeProfileMarkerPath(profile))) { const report = await this.setUp(profile).catch(() => null) if (report?.outcome !== 'prepared') { throw claudeProfileSetupFailed() } + return this.preparation() } + // Why capped and never thrown: a set-up folder runs on its last refresh rather than not at all. + const refresh = this.setUp(profile).catch((error: unknown) => { + console.warn('[claude-profile] Account refresh failed:', error) + }) + const waitMs = this.args.refreshWaitMs ?? REFRESH_WAIT_MS + await Promise.race([refresh, new Promise((resolve) => setTimeout(resolve, waitMs).unref())]) return this.preparation() } @@ -183,7 +261,7 @@ export class ClaudeProfileRouter { private async runSetup(profile: ClaudeProfileDescriptor): Promise { await this.envReady const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') - const claudeVersion = hooks ? await probeClaudeCliVersion(resolveClaudeCommand()) : null + const claudeVersion = hooks ? await probeRecentClaudeCliVersion(resolveClaudeCommand()) : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, @@ -198,9 +276,9 @@ export class ClaudeProfileRouter { return report } - /** Env for a launch Orca makes itself. Throws like selectedHome. */ + /** Env for a launch Orca makes itself. Throws like routedHome. */ launchEnv(): ClaudeEnvPatch { - const home = this.selectedHome() + const home = this.routedHome() return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath, // Why nothing for System default: the user's inherited CLAUDE_CONFIG_DIR must pass through. @@ -214,7 +292,18 @@ export class ClaudeProfileRouter { if (target?.runtime === 'wsl') { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } + // Why only the pointer before the login shell's env: which home runs depends on its + // CLAUDE_CONFIG_DIR, and the pane's `claude` reads the pointer rewritten once it arrives. + if (!this.envResolved) { + return { [CLAUDE_PROFILE_POINTER_ENV]: this.pointerPath } + } try { + this.writePointer() + // Why in the background: a pane never waits; its `claude` starts after the refresh, mostly. + const profile = this.routedProfile() + if (profile) { + this.setUpInBackground(profile) + } const env = this.launchEnv() const userConfigDir = this.userConfigDir() // Why: the injected value replaces the user's own; the claude function restores it on System default. @@ -227,15 +316,54 @@ export class ClaudeProfileRouter { } preparation(): ClaudeRuntimeAuthPreparation { - const home = this.selectedHome() + const profile = this.routedProfile() + const home = this.routedHome() return { configDir: home ?? this.systemDefaultHome(), runtime: 'host', wslDistro: null, wslLinuxConfigDir: null, envPatch: this.launchEnv(), - stripAuthEnv: home !== null, - provenance: home ? `profile:${this.selectedProfile()?.accountId}` : 'system' + provenance: home ? `profile:${profile?.accountId}` : 'system' + } + } + + /** + * Whether `claude` in a terminal opened before routing, which runs System default's login, runs + * another account than the selected host one. Null with no host account selected. + */ + systemDefaultRunsAnotherAccount(): boolean | null { + const profile = this.selectedProfile() + if (!profile) { + return null + } + const selected = + this.accountLogin(profile.accountId) ?? + findClaudeAccount(this.args.getSettings(), profile.accountId) + const systemDefault = this.systemDefaultLogin(5_000) + return !selected || !systemDefault || !isSameClaudeLogin(selected, systemDefault) + } + + /** Whether launches run in an account's folder rather than System default's. */ + routesToAccount(): boolean { + return this.routedProfile() !== null + } + + /** Where an unselected account's usage is read: where its launches would run once selected. */ + accountUsagePreparation(accountId: string): ClaudeRuntimeAuthPreparation { + if (this.coveredBySystemDefault(accountId)) { + // Why no CLAUDE_CONFIG_DIR: naming System default's folder moves Claude to another Keychain item. + return { + configDir: this.systemDefaultHome(), + envPatch: {}, + provenance: 'system' + } + } + const home = this.accountHome(accountId) + return { + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + provenance: `profile:${accountId}` } } @@ -244,16 +372,3 @@ export class ClaudeProfileRouter { return listClaudeProfileHomes(this.args.dataRoot) } } - -// Typed so a chat names the situation; a terminal reads the same message. -export function claudeProfileMissing(): AgentSessionPreSpawnError { - return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_MISSING_MESSAGE), { - reason: 'claudeAccountFolderMissing' - }) -} - -export function claudeProfileSetupFailed(): AgentSessionPreSpawnError { - return new AgentSessionPreSpawnError(new Error(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE), { - reason: 'claudeAccountSetupFailed' - }) -} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index 0ead45be168d..69edd5bc3c12 100644 --- a/src/main/claude-accounts/claude-profile-setup.test.ts +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -67,7 +67,7 @@ describe('Claude account profile setup', () => { projects: 'linked', 'history.jsonl': 'linked', skills: 'linked', - 'settings.json': 'merged', + 'settings.json': 'synced', '.claude.json': 'absent', hooks: 'merged' }) @@ -167,7 +167,8 @@ describe('Claude account profile setup', () => { platform: 'linux' }) expect(skipped.surfaces.hooks).toBe('absent') - writeFileSync(join(f.dataRoot, 'claude-profiles/a/home/settings.json'), '{bad') + // Copied as is from the default home, where Claude reports it the same way. + writeFileSync(join(f.defaultHome, 'settings.json'), '{bad') const failed = await f.setup() expect(failed.surfaces.hooks).toBe('failed') expect(failed.surfaces.projects).toBe('unchanged') diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts index 7d132e1c86d4..422dfe163bcf 100644 --- a/src/main/claude-accounts/claude-profile-setup.ts +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -1,11 +1,6 @@ -import { join } from 'node:path' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { shareClaudeProfileHistory } from './claude-profile-history' -import { - prepareClaudeProfileDirectory, - readClaudeProfileObject, - type ClaudeProfileDescriptor -} from './claude-profile-paths' +import { prepareClaudeProfileDirectory, type ClaudeProfileDescriptor } from './claude-profile-paths' import { provisionClaudeProfile } from './claude-profile-provisioning' import { ClaudeProfileSurfaceError, @@ -14,27 +9,6 @@ import { warnClaudeProfile, type ClaudeProfileReport } from './claude-profile-report' -import { - claudeProfileLedgerPath, - readClaudeProfileLedger, - writeClaudeProfileLedger -} from './claude-profile-sharing' - -/** Without this the installed `hooks` look like a profile edit, so the user's own hooks never arrive. */ -function recordInstalledHooks(home: string): void { - const settings = readClaudeProfileObject(join(home, 'settings.json')) - if (settings.kind !== 'present' || !('hooks' in settings.value)) { - return - } - const ledgerPath = claudeProfileLedgerPath(home) - const ledger = readClaudeProfileLedger(ledgerPath) - const written = (ledger.keys['settings.json'] ??= {}) - const hooks = JSON.stringify(settings.value.hooks) - if (written.hooks !== hooks) { - written.hooks = hooks - writeClaudeProfileLedger(ledgerPath, ledger) - } -} /** `refused`: the profile failed its ownership gate and no surface was touched. */ export type ClaudeProfileSetupReport = ClaudeProfileReport & { outcome: 'refused' | 'prepared' } @@ -49,7 +23,7 @@ export async function provisionClaudeAccountProfile(args: { userHome: string /** The user's own CLAUDE_CONFIG_DIR (see readUserClaudeConfigDir); `~/.claude` when unset. */ userConfigDir?: string - /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ + /** Null when Orca's Claude hooks are turned off. Runs after the settings copy so its entries survive it. */ installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null platform?: NodeJS.Platform }): Promise { @@ -94,7 +68,6 @@ export async function provisionClaudeAccountProfile(args: { if (status.state !== 'installed') { throw new Error(status.detail ?? `Claude hooks ${status.state}`) } - recordInstalledHooks(home) return 'merged' }) // Last: a setup cut off before here leaves no marker, so the next launch waits for a full one. diff --git a/src/main/claude-accounts/claude-profile-sharing.ts b/src/main/claude-accounts/claude-profile-sharing.ts index 69201b62f5b9..be7b4509612b 100644 --- a/src/main/claude-accounts/claude-profile-sharing.ts +++ b/src/main/claude-accounts/claude-profile-sharing.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { lstatSync, mkdirSync, @@ -6,59 +5,15 @@ import { readdirSync, realpathSync, rmdirSync, + statSync, symlinkSync, unlinkSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { dirname } from 'node:path' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { readClaudeProfileObject } from './claude-profile-paths' import type { ClaudeProfileSurfaceOutcome } from './claude-profile-report' -/** Keyed by surface name, not path, so another spelling of the same profile keeps its history. */ -export type ClaudeProfileLedger = { - version: 1 - files: Record - keys: Record> -} - -function stringEntries(value: unknown): Record { - const entries: Record = {} - if (value && typeof value === 'object') { - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === 'string') { - entries[key] = entry - } - } - } - return entries -} - -export function claudeProfileLedgerPath(profileHome: string): string { - return join(profileHome, '.orca-profile.json') -} - -export function writeClaudeProfileLedger(file: string, ledger: ClaudeProfileLedger): void { - writeFileAtomically(file, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 }) -} - -/** Orca's own bookkeeping: an unreadable ledger starts empty, so nothing shared is overwritten. */ -export function readClaudeProfileLedger(file: string): ClaudeProfileLedger { - const ledger: ClaudeProfileLedger = { version: 1, files: {}, keys: {} } - const result = readClaudeProfileObject(file) - if (result.kind !== 'present') { - return ledger - } - ledger.files = stringEntries(result.value.files) - const keys = result.value.keys - if (keys && typeof keys === 'object') { - for (const [surface, entries] of Object.entries(keys)) { - ledger.keys[surface] = stringEntries(entries) - } - } - return ledger -} - export function linkClaudeProfileDirectory( source: string, target: string, @@ -102,88 +57,38 @@ export function linkClaudeProfileDirectory( return 'linked' } -export function syncClaudeProfileFile( +/** + * Copies a file the default home owns over the profile's copy. Copied, not linked: a + * rename-replace save (Claude's own, or an editor's) would cut a link. + */ +export function copyClaudeProfileFile( source: string, target: string, - surface: string, - ledger: ClaudeProfileLedger, - render: (sourceText: string) => string = (sourceText) => sourceText + render: (sourceText: Buffer) => Buffer = (sourceText) => sourceText ): ClaudeProfileSurfaceOutcome { - let desired: string + let desired: Buffer + let mode: number try { - desired = render(readFileSync(source, 'utf8')) + desired = render(readFileSync(source)) + mode = statSync(source).mode & 0o777 } catch (error) { if (isDefinitiveAbsence(error)) { return 'absent' } throw error } - const hash = (value: string): string => createHash('sha256').update(value).digest('hex') try { if (lstatSync(target).isSymbolicLink()) { return 'user-owned' } - const current = hash(readFileSync(target, 'utf8')) - if (current === hash(desired)) { - ledger.files[surface] = current + if (readFileSync(target).equals(desired)) { return 'unchanged' } - if (ledger.files[surface] !== current) { - return 'user-owned' - } } catch (error) { if (!isDefinitiveAbsence(error)) { throw error } } - writeFileAtomically(target, desired, { mode: 0o600 }) - ledger.files[surface] = hash(desired) + writeFileAtomically(target, desired, { mode }) return 'synced' } - -/** Returns the keys it changed in `target`. */ -export function mergeClaudeProfileKeys( - target: Record, - desired: Record, - written: Record -): string[] { - const changed: string[] = [] - for (const [key, value] of Object.entries(desired)) { - const serialized = JSON.stringify(value) - const current = key in target ? JSON.stringify(target[key]) : undefined - if (current !== serialized && current !== undefined && written[key] !== current) { - continue - } - if (current !== serialized) { - target[key] = value - changed.push(key) - } - written[key] = serialized - } - return changed -} - -/** - * A key the default home dropped leaves the profile when the profile still holds what Orca last - * shared; a value changed inside the profile stays. Callers skip this when the source was unreadable. - */ -export function dropClaudeProfileKeys( - target: Record, - desired: Record, - written: Record -): string[] { - const dropped: string[] = [] - for (const key of Object.keys(written)) { - if (key in desired) { - continue - } - if (!(key in target)) { - delete written[key] - } else if (JSON.stringify(target[key]) === written[key]) { - delete target[key] - delete written[key] - dropped.push(key) - } - } - return dropped -} diff --git a/src/main/claude-accounts/claude-profile-wsl-entry.ts b/src/main/claude-accounts/claude-profile-wsl-entry.ts index 47629865c540..cab59aee0798 100644 --- a/src/main/claude-accounts/claude-profile-wsl-entry.ts +++ b/src/main/claude-accounts/claude-profile-wsl-entry.ts @@ -2,7 +2,7 @@ import { provisionClaudeAccountProfile } from './claude-profile-setup' import { wslClaudeProfile } from './claude-profile-wsl-paths' // Runs inside a WSL distro on Orca's pinned Node: ` `. -// Hooks are not installed here: they reach the account through the settings merge from ~/.claude. +// Hooks are not installed here: they reach the account through the settings copy from ~/.claude. async function main(): Promise { const [userHome = '', distro = '', accountId = ''] = process.argv.slice(2) const { dataRoot, profile } = wslClaudeProfile(userHome, distro, accountId) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index 00c16d881d20..be520cac3eeb 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -43,6 +43,7 @@ import { import type { ClaudeProfileRouterSettings } from './claude-profile-router' import { prepareClaudeProfileDirectory } from './claude-profile-paths' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' +import { applyClaudeEnvPatch } from './environment' import { wslClaudeProfile } from './claude-profile-wsl-paths' // Why skipped on Windows: the guest is Linux; these run its scripts and Node bundle as the guest. @@ -138,10 +139,12 @@ 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. + // The failed first setup left no marker, so this launch ran setup again; the next refreshes + // alongside the launch instead of before it. expect(f.setup.calls).toBe(2) + f.setup.gate = new Promise(() => {}) await f.router.prepareLaunch('Ubuntu') - expect(f.setup.calls).toBe(2) + expect(f.setup.calls).toBe(3) }) it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { @@ -183,7 +186,8 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ configDir: f.profileHome }) - expect(f.setup.calls).toBe(1) + // That launch refreshed in the background. + expect(f.setup.calls).toBe(2) }) it('sets up a new account folder for sign-in and deletes it without following its links', async () => { @@ -263,6 +267,61 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) }) + it("runs an account with no login of its own on the guest's ~/.claude while that is signed in to it", async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + const login = (dir: string, email: string) => + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) + login(guest.home, 'A@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: join(guest.home, '.claude'), + provenance: 'wsl:Ubuntu:system' + }) + expect(readFileSync(f.pointer, 'utf8')).toBe('') + await f.router.publish('Ubuntu') + expect(readFileSync(f.pointer, 'utf8')).toBe('') + + login(f.profileHome, 'a@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) + + // Another organization of the same email never stands in for the account. + rmSync(join(f.profileHome, '.claude.json')) + f.settings.claudeManagedAccounts = [{ ...f.account('a'), organizationUuid: 'org-a' }] + writeFileSync( + join(guest.home, '.claude.json'), + JSON.stringify({ + oauthAccount: { emailAddress: 'a@example.test', organizationUuid: 'org-b' } + }) + ) + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + // Another email in the guest's ~/.claude never stands in for the account either. + login(guest.home, 'b@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + }) + + it("keeps a shell proxy's key with its address on WSL launches, account or System default", async () => { + const f = fixture() + mkdirSync(f.profileHome, { recursive: true }) + writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') + const shell = { ANTHROPIC_BASE_URL: 'https://proxy.example.test', ANTHROPIC_API_KEY: 'k' } + for (const selected of ['a', null]) { + f.wsl.Ubuntu = selected + const prepared = await f.router.prepareLaunch('Ubuntu') + expect(applyClaudeEnvPatch({ ...shell }, prepared.envPatch)).toMatchObject(shell) + } + }) + it('launches System default from the guest ~/.claude with no account env', async () => { const f = fixture() f.wsl.Ubuntu = null diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 10c8273d9195..c71a8c76d2fa 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -14,14 +14,20 @@ import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' +import type { ClaudeProfileRouterSettings } from './claude-profile-router' +import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' import { - claudeProfileMissing, - claudeProfileSetupFailed, - type ClaudeProfileRouterSettings -} from './claude-profile-router' + isSameClaudeLogin, + readClaudeFolderLoginAsync, + type ClaudeFolderLogin +} from './claude-account-folder' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -import { getClaudeWslSelectionKey, getSelectedClaudeAccountIdForTarget } from './runtime-selection' +import { + findClaudeAccount, + getClaudeWslSelectionKey, + getSelectedClaudeAccountIdForTarget +} from './runtime-selection' type WslSetup = (distro: string, guestHome: string, accountId: string) => Promise @@ -63,7 +69,23 @@ export class ClaudeWslProfileRouter { if (!home?.startsWith('/')) { throw new Error(`Could not read the home folder of WSL distro ${distro}.`) } - return { home, profile: this.selectedProfile(home, distro) } + const selected = this.selectedProfile(home, distro) + return { home, selected, profile: await this.routed(distro, home, selected) } + } + + /** The host router's fallback: an account with no login of its own runs on the guest's ~/.claude + * while that is signed in to the same email. */ + private async routed( + distro: string, + home: string, + selected: ClaudeProfileDescriptor | null + ): Promise { + if (!selected || (await guestLogin(distro, posix.join(selected.home, '.claude.json')))) { + return selected + } + const saved = findClaudeAccount(this.args.getSettings(), selected.accountId) + const systemDefault = await guestLogin(distro, posix.join(home, '.claude.json')) + return saved && systemDefault && isSameClaudeLogin(saved, systemDefault) ? null : selected } private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { @@ -80,7 +102,7 @@ export class ClaudeWslProfileRouter { /** Pointer first, then setup in the background, as on the host. No accounts here means no pointer. */ async publish(distro: string): Promise { - const { home, profile } = await this.resolve(distro) + const { home, selected, profile } = await this.resolve(distro) if (!this.accountIn(distro)) { await runGuest(distro, { script: 'rm -f -- "$1"', @@ -90,15 +112,19 @@ 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) { - this.setUp(distro, home, profile.accountId).catch((error: unknown) => { - console.warn('[claude-profile] WSL account setup failed:', error) - }) + // Why even a missing or covered folder: setup creates it without a login, for a sign-in. + if (selected) { + this.setUpInBackground(distro, home, selected.accountId) } } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + private setUpInBackground(distro: string, home: string, accountId: string): void { + this.setUp(distro, home, accountId).catch((error: unknown) => { + console.warn('[claude-profile] WSL account setup failed:', error) + }) + } + + /** Waits for a first setup that never finished, running or not; otherwise refreshes alongside. */ async prepareLaunch(distro: string): Promise { const { home, profile } = await this.resolve(distro) // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a @@ -108,16 +134,42 @@ export class ClaudeWslProfileRouter { console.warn('[claude-profile] WSL account setup failed:', error) throw claudeProfileSetupFailed() }) + } else if (profile) { + // Why in the background: a guest refresh starts a guest Node, too slow for every launch. + this.setUpInBackground(distro, home, profile.accountId) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. // Re-read the selection: one made during setup has already published its own pointer. if (this.accountIn(distro)) { - const selected = this.selectedProfile(home, distro) - await writePointer(distro, this.pointerIn(home), selected?.home ?? '') + const current = await this.routed(distro, home, this.selectedProfile(home, distro)) + await writePointer(distro, this.pointerIn(home), current?.home ?? '') } return this.preparationFor(distro, home, profile) } + /** The host router's old-terminal check for a pane in this distro; null with no account selected. */ + async systemDefaultRunsAnotherAccount(distro: string): Promise { + const { home, selected } = await this.resolve(distro) + if (!selected) { + return null + } + const login = + (await guestLogin(distro, posix.join(selected.home, '.claude.json'))) ?? + findClaudeAccount(this.args.getSettings(), selected.accountId) + const systemDefault = await guestLogin(distro, posix.join(home, '.claude.json')) + return !login || !systemDefault || !isSameClaudeLogin(login, systemDefault) + } + + /** Where an unselected account's usage is read in the guest: where its launches would run. */ + async accountUsagePreparation( + distro: string, + accountId: string + ): Promise { + const { home } = await this.resolve(distro) + const { profile } = wslClaudeProfile(home, distro, accountId) + return this.preparationFor(distro, home, await this.routed(distro, home, profile)) + } + async preparation(distro: string): Promise { const { home, profile } = await this.resolve(distro) await this.assertPresent(distro, profile) @@ -171,7 +223,6 @@ export class ClaudeWslProfileRouter { ? { CLAUDE_CONFIG_DIR: profile.home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: profile.home } : {}) }, - stripAuthEnv: true, provenance: profile ? `profile:${profile.accountId}:wsl:${distro}` : `wsl:${distro}:system` } } @@ -190,6 +241,11 @@ export class ClaudeWslProfileRouter { } } +/** The login a guest state file names, read over the share like the marker. */ +function guestLogin(distro: string, stateFile: string): Promise { + return readClaudeFolderLoginAsync(toWindowsWslPath(stateFile, distro)) +} + // Why over the distro's share: a launch must not wait on a guest process for two stats. async function guestStat(distro: string, linuxPath: string) { return lstat(toWindowsWslPath(linuxPath, distro)).catch(() => null) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.test.ts b/src/main/claude-accounts/claude-structured-auth-policy.test.ts index a2d7c7d5da82..66f5f5d7b5d8 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.test.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' -import { - CLAUDE_AUTH_ENV_VARS, - hasClaudeAuthEnvConflict, - shouldStripClaudeAuthEnvForAccount -} from './environment' -import { - normalizeTuiAgentEnvRecord, - resolveTuiAgentLaunchEnv -} from '../../shared/tui-agent-launch-defaults' +import { CLAUDE_AUTH_ENV_VARS, isHostManagedClaudeAccount } from './environment' import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' const HOST_ACCOUNT = { id: 'host-a', managedAuthRuntime: 'host' } as ClaudeManagedAccount @@ -33,34 +25,29 @@ function settings( } as Parameters[0] } -// The predicate now backs BOTH transports (runtime-auth-preparation.ts and the -// structured wiring), so it needs a test of its own: forcing it to a constant used -// to leave ~1000 tests green. -describe('shouldStripClaudeAuthEnvForAccount', () => { - it('does not strip when no managed account is selected', () => { - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], null)).toBe(false) - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], undefined)).toBe(false) - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], '')).toBe(false) +describe('isHostManagedClaudeAccount', () => { + it('is false when no managed account is selected', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], null)).toBe(false) + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], undefined)).toBe(false) + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], '')).toBe(false) }) - it('strips for a host-managed account', () => { - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true) + it('is true for a host-managed account', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true) }) - it('strips for an account with no explicit runtime (the legacy host shape)', () => { - expect(shouldStripClaudeAuthEnvForAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true) + it('is true for an account with no explicit runtime (the legacy host shape)', () => { + expect(isHostManagedClaudeAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true) }) - it('does not strip for a WSL-managed account, matching runtime-auth-preparation', () => { - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false) + it('is false for a WSL-managed account', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false) }) - it('strips for a selected id no account list explains', () => { - // Fail-safe: an id we cannot resolve is treated as a pinned account, never as - // "no account", so an unreadable settings blob cannot open the strip. - expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true) - expect(shouldStripClaudeAuthEnvForAccount(undefined, 'deleted-d')).toBe(true) - expect(shouldStripClaudeAuthEnvForAccount([], 'deleted-d')).toBe(true) + it('is true for a selected id no account list explains', () => { + expect(isHostManagedClaudeAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true) + expect(isHostManagedClaudeAccount(undefined, 'deleted-d')).toBe(true) + expect(isHostManagedClaudeAccount([], 'deleted-d')).toBe(true) }) }) @@ -73,19 +60,21 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } }) ) - ).toEqual({ stripAuthEnv: true }) + ).toEqual({ account: 'managed' }) }) - it('strips when a host account is pinned by runtime selection', () => { + it('names a host account pinned by runtime selection', () => { expect( claudeStructuredAuthPolicyForSettings( settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } }) ) - ).toEqual({ stripAuthEnv: true }) + ).toEqual({ account: 'managed' }) }) - it('does not strip for system auth, so an API-key-only user keeps their sign-in', () => { - expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ stripAuthEnv: false }) + it('names System default when no account is selected', () => { + expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ + account: 'system' + }) }) it('ignores a WSL-only selection: the structured child is always a native host process', () => { @@ -95,14 +84,13 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } } }) ) - ).toEqual({ stripAuthEnv: false }) + ).toEqual({ account: 'system' }) }) }) -describe('the strip vocabulary the policy governs', () => { +describe('the Anthropic auth variables Orca keeps out of its own processes', () => { it('covers every Anthropic auth variable the terminal path knows about', () => { - // A new auth var added to the list without a matching refusal/strip path is the - // shape of the leak this lane already shipped once. + // Only Orca's own process env and a `--version` probe drop these; launches never do. expect([...CLAUDE_AUTH_ENV_VARS]).toEqual([ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', @@ -111,58 +99,3 @@ describe('the strip vocabulary the policy governs', () => { ]) }) }) - -// The refusal has to cover exactly what the strip removes. Anything narrower lets an -// override reach the child that applyClaudeEnvPatch would have deleted. -describe('hasClaudeAuthEnvConflict matches the strip it guards', () => { - it('refuses each Anthropic auth variable', () => { - for (const key of CLAUDE_AUTH_ENV_VARS) { - expect(hasClaudeAuthEnvConflict({ [key]: 'v' }, 'linux')).toBe(true) - } - }) - - // `ANTHROPIC_API_KEY=` in the agent env box is how a user blanks a variable, and the - // settings pipeline preserves the empty value (agent-default-env-draft.ts assigns - // everything after the `=`; normalizeTuiAgentEnvRecord drops empty KEYS only). An - // empty value cannot beat the pinned account and the strip removes the name anyway, - // so refusing it would break a terminal launch that works today for no security gain. - it('admits an override whose value is empty, the documented way to blank a variable', () => { - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: '' }, 'linux')).toBe(false) - expect(hasClaudeAuthEnvConflict({ anthropic_api_key: '' }, 'win32')).toBe(false) - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: '' }, 'linux')).toBe(false) - }) - - it('still refuses the same names once they carry a value', () => { - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: 'sk-ant' }, 'linux')).toBe(true) - }) - - // The end-to-end shape the regression actually took: settings text -> normalized - // record -> launch env -> the predicate the terminal preflight gates on. - it('admits a blanked variable all the way from the settings record', () => { - const configured = normalizeTuiAgentEnvRecord({ claude: { ANTHROPIC_API_KEY: '' } }) - const launchEnv = resolveTuiAgentLaunchEnv('claude', configured) - - expect(launchEnv).toEqual({ ANTHROPIC_API_KEY: '' }) - expect(hasClaudeAuthEnvConflict(launchEnv, 'linux')).toBe(false) - }) - - it('folds case on win32, where the OS does', () => { - expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'win32')).toBe(true) - expect(hasClaudeAuthEnvConflict({ Anthropic_Custom_Headers: 'x-api-key: v' }, 'win32')).toBe( - true - ) - }) - - it('keeps env names case-sensitive off win32', () => { - expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'linux')).toBe(false) - }) - - it('admits non-auth Anthropic settings on both platforms', () => { - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'linux')).toBe(false) - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'win32')).toBe(false) - expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: 1' }, 'linux')).toBe( - false - ) - expect(hasClaudeAuthEnvConflict(undefined, 'linux')).toBe(false) - }) -}) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts index c30cd69b8276..38ed700bc1fa 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -1,11 +1,13 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { GlobalSettings } from '../../shared/global-settings-types' -import { shouldStripClaudeAuthEnvForAccount } from './environment' +import { getClaudeProfileRouter } from './claude-profile-installed-router' +import { isHostManagedClaudeAccount } from './environment' import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' -/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result: - * the one field a launch resolution needs from the managed-account state. */ +/** What a structured launch needs from the managed-account state: which login a failed + * sign-in names. A chat keeps the shell's Anthropic auth on every account, as terminals do. */ export type ClaudeStructuredAuthPolicy = { - stripAuthEnv: boolean + account: AgentSessionAccountKind } /** @@ -28,10 +30,13 @@ export function claudeStructuredAuthPolicyForSettings( | 'activeClaudeManagedAccountIdsByRuntime' > ): ClaudeStructuredAuthPolicy { - return { - stripAuthEnv: shouldStripClaudeAuthEnvForAccount( - settings.claudeManagedAccounts, - getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - ) - } + // Why the router first: it decides whether the account or System default runs, as for terminals. + const router = getClaudeProfileRouter() + const managed = router + ? router.routesToAccount() + : isHostManagedClaudeAccount( + settings.claudeManagedAccounts, + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + ) + return { account: managed ? 'managed' : 'system' } } diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 918de5250f5e..11059a0f7a32 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -61,19 +61,8 @@ export function applyClaudeEnvPatch( return baseEnv } -/** One string for every transport, so a terminal launch and a structured launch - * cannot drift into telling the user two different things about one refusal. */ -export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - -/** - * Whether a launch on the host runtime must drop inherited Anthropic auth. - * - * Only a pinned host-managed account owns the credential, so only it may strip: - * with no managed account the user's own `ANTHROPIC_*` is their sign-in, and - * removing it signs them out of a CLI that would otherwise have worked. - */ -export function shouldStripClaudeAuthEnvForAccount( +/** Whether the selected account is a host-managed one; an id no account explains counts as one. */ +export function isHostManagedClaudeAccount( accounts: readonly ClaudeManagedAccount[] | undefined, activeAccountId: string | null | undefined ): boolean { @@ -85,39 +74,6 @@ export function shouldStripClaudeAuthEnvForAccount( ) } -/** - * Whether a launch's explicit env carries Anthropic auth a managed account must own. - * - * The key comparison mirrors applyClaudeEnvPatch's strip exactly: case-insensitive on - * win32, where the OS folds env names so `anthropic_api_key` is an effective - * `ANTHROPIC_API_KEY`, and case-sensitive elsewhere. A refusal narrower than the strip - * lets an override through that the strip would have removed. - * - * A non-empty value is what makes it a conflict. `ANTHROPIC_API_KEY=` in the agent env - * box is how a user blanks a variable — the settings pipeline preserves that empty value - * (normalizeTuiAgentEnvRecord drops empty KEYS only) — and an empty override can neither - * authenticate nor beat the pinned account, while the strip removes the name regardless. - * Refusing it would break a terminal launch that works today for no security gain. - */ -export function hasClaudeAuthEnvConflict( - env: Record | undefined, - platform: NodeJS.Platform = process.platform -): boolean { - if (!env) { - return false - } - for (const [key, value] of Object.entries(env)) { - const normalized = platform === 'win32' ? key.toUpperCase() : key - if (value && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) { - return true - } - if (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) { - return true - } - } - return false -} - function isAuthLikeCustomHeaders(value: string | undefined): boolean { if (!value) { return false diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 1cfc4c7d3857..25ecbab4a845 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -31,7 +31,10 @@ vi.mock('./claude-profile-wsl-router', () => ({ return fakes.wsl } })) -vi.mock('./claude-profile-installed-router', () => ({ installClaudeProfileRouter: () => {} })) +vi.mock('./claude-profile-installed-router', () => ({ + installClaudeProfileRouter: () => {}, + installClaudeWslProfileRouter: () => {} +})) import { ClaudeRuntimeAuthService } from './runtime-auth-service' diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index b15bffbc4829..66659ddb53fc 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -6,7 +6,10 @@ import type { Store } from '../persistence' import { getDefaultWslDistro, getWslHome } from '../wsl' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { installClaudeProfileRouter } from './claude-profile-installed-router' +import { + installClaudeProfileRouter, + installClaudeWslProfileRouter +} from './claude-profile-installed-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' @@ -27,6 +30,7 @@ export class ClaudeRuntimeAuthService { this.router = new ClaudeProfileRouter(args) installClaudeProfileRouter(this.router) this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined + installClaudeWslProfileRouter(this.wslRouter) void this.publishAll().catch((error: unknown) => { console.warn('[claude-runtime-auth] Failed to publish the Claude account selection:', error) }) @@ -50,7 +54,6 @@ export class ClaudeRuntimeAuthService { return { configDir: '', envPatch: {}, - stripAuthEnv: true, provenance: 'profile:unavailable', usageError: error instanceof Error ? error.message : String(error) } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index a6a099dad194..73c2a04a50f5 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -6,7 +6,6 @@ export type ClaudeRuntimeAuthPreparation = { wslDistro?: string | null wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch - stripAuthEnv: boolean provenance: string /** Usage only: why the selected account cannot be read; usage polling must not throw for it. */ usageError?: string diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts index eda5b271cb5d..fe2d1373ac46 100644 --- a/src/main/claude-accounts/runtime-selection.ts +++ b/src/main/claude-accounts/runtime-selection.ts @@ -145,3 +145,10 @@ function normalizeWslDistro(wslDistro: string | null | undefined): string | null const trimmed = wslDistro?.trim() return trimmed ? trimmed : null } + +export function findClaudeAccount( + settings: Pick, + accountId: string +): ClaudeManagedAccount | undefined { + return settings.claudeManagedAccounts.find((account) => account.id === accountId) +} diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index acbd988c6141..c34a827fcac3 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -150,7 +150,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, - resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolveAuthPolicy: () => ({ account: 'managed' }), resolvePermissionMode: () => permissionMode })({ identity: { sessionId: record.sessionId } as never }) } diff --git a/src/main/claude/claude-folder-trust-file.test.ts b/src/main/claude/claude-folder-trust-file.test.ts index 456e8938cb6f..4a3f2af6ba1a 100644 --- a/src/main/claude/claude-folder-trust-file.test.ts +++ b/src/main/claude/claude-folder-trust-file.test.ts @@ -274,7 +274,6 @@ describe('resolveLocalClaudeTrustConfig', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/u/.claude', envPatch: {}, - stripAuthEnv: true, provenance: 'wsl:Ubuntu:system' } diff --git a/src/main/claude/claude-hook-event-versions.ts b/src/main/claude/claude-hook-event-versions.ts index 2e980cd04775..2f12556444f1 100644 --- a/src/main/claude/claude-hook-event-versions.ts +++ b/src/main/claude/claude-hook-event-versions.ts @@ -90,3 +90,17 @@ export async function probeClaudeCliVersion( return null } } + +const RECENT_VERSION_MS = 5 * 60_000 +const recentVersions = new Map }>() + +/** Why remembered: account folders refresh before every launch, and a hook plan needs only a recent version. */ +export function probeRecentClaudeCliVersion(executablePath: string): Promise { + const recent = recentVersions.get(executablePath) + if (recent && Date.now() - recent.at <= RECENT_VERSION_MS) { + return recent.value + } + const value = probeClaudeCliVersion(executablePath) + recentVersions.set(executablePath, { at: Date.now(), value }) + return value +} diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index e6a337a70a65..065d7657455c 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -18,7 +18,7 @@ import { type ListingCall = Parameters -const AUTH_POLICY = { stripAuthEnv: false } as const +const AUTH_POLICY = { account: 'system' } as const const HOME = (path: string): { variable: string; path: string } => ({ variable: 'CLAUDE_CONFIG_DIR', @@ -198,7 +198,7 @@ describe('Claude catalog availability', () => { const runListing = vi.fn(async () => LISTED) const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - resolveAuthPolicy: () => ({ stripAuthEnv: managed }), + resolveAuthPolicy: () => ({ account: managed ? ('managed' as const) : ('system' as const) }), runListing }) const result = await probe(HOME('/homes/a')) diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts index 35db45340b0c..177d1c6a8855 100644 --- a/src/main/claude/claude-profile-hooks.test.ts +++ b/src/main/claude/claude-profile-hooks.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, symlinkSync, writeFileSync } from 'node:fs' @@ -111,12 +112,31 @@ describe('Claude hooks at an explicit profile', () => { await sync(f) expect(stop(f.profile)[0]).toEqual(hook('notify-v2')) expect(orcaCount(f.profile)).toBe(1) + // The default home is the master copy: an edit made in the profile lasts until the next refresh. setStop(f.profile, ['profile-only']) setStop(f.defaultDir, ['notify-v3']) await sync(f) - expect(stop(f.profile)[0]).toEqual(hook('profile-only')) + expect(stop(f.profile)[0]).toEqual(hook('notify-v3')) expect(orcaCount(f.profile)).toBe(1) }) + it.each(['with', 'without'])( + 'leaves the profile settings untouched by a refresh that changes nothing, %s Orca hooks in the default home', + async (variant) => { + const f = fixture() + writeFileSync(join(f.defaultDir, 'settings.json'), JSON.stringify({ model: 'opus' })) + if (variant === 'with') { + f.service.install(CURRENT) + } + await sync(f) + const file = join(f.profile, 'settings.json') + const before = { text: readFileSync(file, 'utf8'), stat: statSync(file) } + await sync(f) + await sync(f) + expect(readFileSync(file, 'utf8')).toBe(before.text) + expect(statSync(file).mtimeMs).toBe(before.stat.mtimeMs) + expect(statSync(file).ino).toBe(before.stat.ino) + } + ) it('carries a default-home statusline opt-out and an old-Claude retire to the profile', async () => { const f = fixture() f.service.install(CURRENT) diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 2a22fae853ae..c2bee84dfc9f 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' const SESSION_ID = 'orca-session-auth' @@ -25,7 +24,7 @@ function record(): AgentSessionRecord { } function resolverFor(options: { - stripAuthEnv: boolean + account: 'managed' | 'system' overlay?: Record }): ReturnType { return createClaudeStructuredLaunchResolver({ @@ -33,7 +32,7 @@ function resolverFor(options: { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), + resolveAuthPolicy: () => ({ account: options.account }), ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) }) } @@ -51,66 +50,31 @@ function withAmbientAuth(value: string, run: () => Promise): Promise { } describe('claude structured auth parity with the terminal preflight', () => { - // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and - // runtime/spawn-preflight.ts:139; the structured path used to let the override win. - it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { - await expect( - resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ - identity: IDENTITY - }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - reason: 'managedAccountEnvOverride' - }) - }) - - it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { - await expect( - resolverFor({ - stripAuthEnv: true, - overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } - })({ identity: IDENTITY }) - ).rejects.toMatchObject({ - message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - reason: 'managedAccountEnvOverride' - }) - }) - - it('still admits a non-auth env overlay under a managed account', async () => { - const launch = await resolverFor({ - stripAuthEnv: true, - overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } - })({ identity: IDENTITY }) - - expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test') - }) + // Like terminals, a chat keeps the shell's Anthropic auth on every account: a proxy's key must + // travel with its ANTHROPIC_BASE_URL. + for (const account of ['managed', 'system'] as const) { + it(`passes an ambient Anthropic key through on a ${account} account`, async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ account })({ identity: IDENTITY }) - // Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a - // system-auth user's own shell key is their sign-in and must survive. - it('passes an ambient Anthropic key through when no managed account is active', async () => { - await withAmbientAuth('sk-ant-SHELL', async () => { - const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY }) - - expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') - }) - }) - - it('lets an explicit overlay override the ambient key when no managed account is active', async () => { - await withAmbientAuth('sk-ant-SHELL', async () => { - const launch = await resolverFor({ - stripAuthEnv: false, - overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } - })({ identity: IDENTITY }) - - expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + expect(launch.account).toBe(account) + }) }) - }) - it('still strips the ambient Anthropic key when a managed account is pinned', async () => { - await withAmbientAuth('sk-ant-SHELL', async () => { - const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY }) + it(`lets an explicit auth overlay through on a ${account} account`, async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ + account, + overlay: { + ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + } + })({ identity: IDENTITY }) - expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test') + }) }) - }) + } }) diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 5f787ca60604..8c9cc21cfc8d 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -42,22 +42,16 @@ export async function resolveClaudeChildEnvSources( // Why the overlay merges onto the inherited env rather than replacing it: the child // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath -// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the -// inherited half only when a managed account owns the credential; a system-auth -// user's own key is their sign-in and must reach the child. +// derives PATH from what it is handed. The shell's Anthropic auth reaches the child on +// every account, as it does in a terminal. export function claudeChildEnv( sources: ClaudeChildEnvSources, - stripAuthEnv: boolean, decorateEnv: (env: Record) => Record = (env) => env ): Record { return withCliRuntimeOnPath( sources.command, decorateEnv({ - ...applyClaudeEnvPatch( - withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), - {}, - { stripAuthEnv, platform: process.platform } - ), + ...withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), ...sources.overlay }), { platform: process.platform } @@ -69,7 +63,7 @@ export function claudeChildEnv( * a version needs none of. Everything else is what this same binary receives at launch anyway. */ export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { return applyClaudeEnvPatch( - claudeChildEnv(sources, true), + claudeChildEnv(sources), {}, { stripAuthEnv: true, diff --git a/src/main/claude/claude-structured-launch-account-routing.test.ts b/src/main/claude/claude-structured-launch-account-routing.test.ts index 0d7babfb8f5d..eda02246f191 100644 --- a/src/main/claude/claude-structured-launch-account-routing.test.ts +++ b/src/main/claude/claude-structured-launch-account-routing.test.ts @@ -49,7 +49,7 @@ it('launches each acquisition under the current selection, not the account it wa resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), - resolveAuthPolicy: () => ({ stripAuthEnv: false }) + resolveAuthPolicy: () => ({ account: 'system' }) }) const identity = { sessionId: record.sessionId, @@ -115,7 +115,7 @@ function routedResumeFixture() { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveInheritedEnv: async () => ({ PATH: '/usr/bin' }), - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), hasTranscript: async ({ claudeConfigDir }) => transcriptHomes.has(claudeConfigDir) }) const identity = { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 0dea1d73bdd5..963df6ce7699 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -56,7 +56,7 @@ function makeExecutable(path: string): void { function resolverFor( value: AgentSessionRecord | null, resolveEnv?: () => Record, - stripAuthEnv = false, + account: 'managed' | 'system' = 'system', // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, hasTranscript: () => Promise = async () => true, @@ -67,7 +67,7 @@ function resolverFor( store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv }), + resolveAuthPolicy: () => ({ account }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), @@ -136,7 +136,7 @@ describe('claude structured launch resolution', () => { const launch = await resolverFor( value, undefined, - false, + 'system', undefined, hasTranscript )({ identity: identityAt('old-leaf') }) @@ -153,7 +153,7 @@ describe('claude structured launch resolution', () => { const retry = await resolverFor( value, undefined, - false, + 'system', undefined, hasTranscript )({ identity: IDENTITY }) @@ -161,7 +161,7 @@ describe('claude structured launch resolution', () => { const next = await resolverFor( { ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } }, undefined, - false, + 'system', undefined, async () => false )({ identity: IDENTITY }) @@ -260,7 +260,7 @@ describe('claude structured launch resolution', () => { ] as AgentSessionRecord['providerHandleChain'] }), undefined, - false, + 'system', { claude: '' }, hasTranscript )({ identity: identityAt(null) }) @@ -287,7 +287,9 @@ describe('claude structured launch resolution', () => { ['--dangerously-skip-permissions --model Opus'], ['--model Opus --dangerously-skip-permissions'] ])('starts a Yolo session in bypassPermissions for args %s', async (claude) => { - const launch = await resolverFor(record(), undefined, false, { claude })({ identity: IDENTITY }) + const launch = await resolverFor(record(), undefined, 'system', { claude })({ + identity: IDENTITY + }) expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null, @@ -301,7 +303,7 @@ describe('claude structured launch resolution', () => { // default for the key it did not write is the bypass flag — the posture the terminal has // always given these users. it('starts a session that never opened Agent settings in bypassPermissions', async () => { - const launch = await resolverFor(record(), undefined, false, {})({ identity: IDENTITY }) + const launch = await resolverFor(record(), undefined, 'system', {})({ identity: IDENTITY }) expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null, @@ -313,7 +315,7 @@ describe('claude structured launch resolution', () => { it.each([[''], ['--model Opus']])( 'leaves a Manual session prompting for args %s', async (claude) => { - const launch = await resolverFor(record(), undefined, false, { claude })({ + const launch = await resolverFor(record(), undefined, 'system', { claude })({ identity: IDENTITY }) @@ -351,7 +353,7 @@ describe('claude structured launch resolution', () => { const resolve = resolverFor( record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }), undefined, - false, + 'system', { claude: '' }, async () => true, () => args @@ -407,45 +409,11 @@ describe('claude structured launch resolution', () => { expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') }) - // Stripping is the managed-account rule the terminal preflight computes at - // runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers - // the system-auth half, where the user's own key has to survive. - it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => { - const restore = { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, - CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, - ORCA_LAUNCH_RESOLUTION_MARKER: process.env.ORCA_LAUNCH_RESOLUTION_MARKER - } - process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' - process.env.ANTHROPIC_AUTH_TOKEN = 'tok-SHELL-LEAK' - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK' - process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited' - try { - const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY }) - - expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() - expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() - expect(launch.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() - // The inherited env is still the base — only auth is removed from it. - expect(launch.env?.ORCA_LAUNCH_RESOLUTION_MARKER).toBe('inherited') - expect(launch.env?.PATH ?? launch.env?.Path).toBeTruthy() - } finally { - for (const [key, value] of Object.entries(restore)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - } - }) - it("lets the agent read the host's chat attachment store, outside the workspace", async () => { const launch = await resolverFor( record(), undefined, - false, + 'system', { claude: '' }, async () => true, undefined, @@ -460,7 +428,7 @@ describe('claude structured launch resolution', () => { const launch = await resolverFor( record(), undefined, - false, + 'system', { claude: '' }, async () => true, () => ['--add-dir', '/extra'], @@ -479,7 +447,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveInheritedEnv: async () => ({ PATH: '/shell/bin', SHELL_ONLY_MARKER: 'from-shell' }) })({ identity: IDENTITY }) @@ -492,7 +460,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveInheritedEnv: async () => ({ PATH: '/shell/bin', CLAUDE_CONFIG_DIR: '/shell/claude', @@ -511,7 +479,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveEnv: () => ({ CLAUDE_CONFIG_DIR: '/accounts/selected/home' }), resolveInheritedEnv: async () => ({ PATH: '/shell/bin', CLAUDE_CONFIG_DIR: '/shell/claude' }) })({ identity: IDENTITY }) @@ -519,17 +487,24 @@ describe('claude structured launch resolution', () => { expect(launch.env?.CLAUDE_CONFIG_DIR).toBe('/accounts/selected/home') }) - it('still strips an inherited auth key under a managed account', async () => { + it("keeps the shell's auth key under a managed account, beside its proxy address", async () => { const launch = await createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: true }), - resolveInheritedEnv: async () => ({ PATH: '/shell/bin', ANTHROPIC_API_KEY: 'listed-key' }) + resolveAuthPolicy: () => ({ account: 'managed' }), + resolveInheritedEnv: async () => ({ + PATH: '/shell/bin', + ANTHROPIC_API_KEY: 'listed-key', + ANTHROPIC_BASE_URL: 'https://proxy.example.test' + }) })({ identity: IDENTITY }) - expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(launch.env).toMatchObject({ + ANTHROPIC_API_KEY: 'listed-key', + ANTHROPIC_BASE_URL: 'https://proxy.example.test' + }) }) it('lets an explicit Claude env overlay override ambient auth under system auth', async () => { @@ -563,7 +538,7 @@ describe('claude structured launch resolution', () => { store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => claudeCommand, - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveEnv: () => ({ PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/selected/home' @@ -608,7 +583,7 @@ describe('readable Claude thinking', () => { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveLaunchArgs: () => launchArgs, resolveCommand: () => command, - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), hasTranscript: async () => false, ...(cliFlags ? { cliFlags } : {}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 2cd6fa531e11..eb2cf04ae97b 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -10,12 +10,7 @@ import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provi import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - hasClaudeAuthEnvConflict -} from '../claude-accounts/environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeChildEnv, claudeProbeEnv, @@ -120,12 +115,8 @@ export type ClaudeStructuredLaunchResolverDeps = { | undefined /** The env the child inherits before auth stripping; absent inherits Orca's own process env. */ resolveInheritedEnv?: () => Promise> - /** - * Required, and deliberately not defaulted. `stripAuthEnv` used to be a literal - * `true` here, so a missing dependency could not under-strip. Now it can, and the - * failure is silent — so every caller states the account's policy rather than - * inherit a guess. Build it with claudeStructuredAuthPolicyForSettings. - */ + /** Required, so every caller states which login a failed sign-in names rather than inherit a + * guess. Build it with claudeStructuredAuthPolicyForSettings. */ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy /** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */ resolvePermissionMode?: () => Promise | PermissionMode @@ -164,17 +155,11 @@ export async function resolveClaudeStructuredInvocation( ): Promise { const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps)) const auth = await deps.resolveAuthPolicy() - // Under a managed account the pinned credential is the only auth this launch may - // use, so an explicit override is refused rather than silently beating the pin. - if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(sources.overlay)) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { - reason: 'managedAccountEnvOverride' - }) - } + // Why never strip: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, on every account. return { command: sources.command, - env: claudeChildEnv(sources, auth.stripAuthEnv, decorateEnv), - account: auth.stripAuthEnv ? 'managed' : 'system' + env: claudeChildEnv(sources, decorateEnv), + account: auth.account } } diff --git a/src/main/claude/claude-structured-launch-visuals.test.ts b/src/main/claude/claude-structured-launch-visuals.test.ts index 0411523b191c..b75967036017 100644 --- a/src/main/claude/claude-structured-launch-visuals.test.ts +++ b/src/main/claude/claude-structured-launch-visuals.test.ts @@ -39,7 +39,7 @@ function launch(options: { store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveLaunchArgs: () => options.launchArgs ?? [], resolveEnv: () => options.env ?? {}, hasTranscript: async () => false, diff --git a/src/main/claude/claude-structured-real-cli-visuals.test.ts b/src/main/claude/claude-structured-real-cli-visuals.test.ts index a74677ddacf6..7e3ff7c5c957 100644 --- a/src/main/claude/claude-structured-real-cli-visuals.test.ts +++ b/src/main/claude/claude-structured-real-cli-visuals.test.ts @@ -51,7 +51,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { resolveCommand: () => realClaudeCommand, resolveLaunchArgs: () => [], resolveEnv: () => realClaudeLaunchHome().env, - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), hasTranscript: async () => false, cliFlags: createClaudeCliFlagSupport(), prepareVisuals: createNativeChatVisualsDelivery({ stateDirectory, logger }) diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index f4538da4b07b..f24757cd6ab5 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -143,7 +143,7 @@ describe('Claude durable resume point at turn end', () => { store: { getRecord: () => store.record } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }) + resolveAuthPolicy: () => ({ account: 'system' }) }) const launch = await resolve({ identity: { diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index e71fd3b56739..58a62d797e6e 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -200,7 +200,7 @@ export class ClaudeHookService { } else { writeManagedScript(scriptPath, payload) } - // Why: a profile's statusLine arrives with the settings merge from the default home. + // Why: a profile's statusLine arrives with the settings copy from the default home. if (options.configDir === undefined && plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) } else if (options.configDir === undefined && plan.statusLine === 'retire') { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 0f96932ecb85..965bde775cd3 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -13,7 +13,7 @@ export { copyFileWithWindowsRetry, renameFileWithWindowsRetry, renameFileWithWin export function writeFileAtomically( targetPath: string, - contents: string, + contents: string | Uint8Array, options?: { mode?: number } ): void { const tmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp` diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 818b4a36ef34..2504b90232b7 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -4,7 +4,8 @@ // the #25130/#24636 shell-wrapper changes and the wider agent list into a fresh daemon; older owners // stay attachable. export const PROTOCOL_VERSION = 44 -export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 44 +export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 +export const CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION = 43 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index f4f49b0bfea1..14b76e2af7a7 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -83,14 +83,14 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } -/** True for a terminal on a daemon kept alive from before `protocolVersion`; never for an SSH pane. */ -export function isTerminalFromBeforeDaemonProtocol( +/** True for a terminal on a kept-alive older daemon whose protocol matches; never for an SSH pane. */ +export function isTerminalOnLegacyDaemon( ptyId: string, - protocolVersion: number + matches: (protocolVersion: number) => boolean ): boolean { return adapter ? getLegacyDaemonAdapters(adapter).some( - (legacy) => legacy.protocolVersion < protocolVersion && legacy.hasPty(ptyId) + (legacy) => matches(legacy.protocolVersion) && legacy.hasPty(ptyId) ) : false } diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 0a7547619b38..1d72a21505c1 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -393,7 +393,7 @@ describe('registerFilesystemHandlers', () => { it('prepares the selected Claude auth environment before local generation', async () => { const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = 'do-not-leak-managed-auth-conflict' + process.env.ANTHROPIC_API_KEY = 'shell-proxy-key' const context = { branch: 'feature/ai', stagedSummary: 'M\tREADME.md', @@ -412,7 +412,6 @@ describe('registerFilesystemHandlers', () => { prepareForClaudeLaunch: async () => ({ configDir: '/managed/claude', envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' }, - stripAuthEnv: true, provenance: 'managed:account-1' }) }) @@ -429,7 +428,8 @@ describe('registerFilesystemHandlers', () => { CLAUDE_CONFIG_DIR: '/managed/claude' }) ) - expect(target?.env?.ANTHROPIC_API_KEY).toBeUndefined() + // The shell's own key stays with the account, as on System default. + expect(target?.env?.ANTHROPIC_API_KEY).toBe('shell-proxy-key') } finally { if (previousAnthropicApiKey === undefined) { delete process.env.ANTHROPIC_API_KEY diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index 73154bddab51..a4c4a17d7f8d 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -168,7 +168,6 @@ describe('registerFilesystemHandlers', () => { const prepareForClaudeLaunch = vi.fn().mockResolvedValue({ configDir: '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.claude', envPatch: { CLAUDE_CONFIG_DIR: '/home/tester/.claude' }, - stripAuthEnv: true, provenance: 'managed:account-1' }) const folderStore = { diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index bdfd41181823..3dcc6cbe85b2 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -186,7 +186,6 @@ describe('registerPtyHandlers', () => { let releaseAuth!: (value: { configDir: string envPatch: Record - stripAuthEnv: false provenance: string }) => void const prepareClaudeAuth = vi.fn( @@ -194,7 +193,6 @@ describe('registerPtyHandlers', () => { new Promise<{ configDir: string envPatch: Record - stripAuthEnv: false provenance: string }>((resolve) => { releaseAuth = resolve @@ -233,7 +231,6 @@ describe('registerPtyHandlers', () => { releaseAuth({ configDir: '/tmp/claude', envPatch: {}, - stripAuthEnv: false, provenance: 'managed:test' }) await new Promise((resolve) => setImmediate(resolve)) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts index 339cfd105200..0164b4a79521 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.test.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts @@ -1,11 +1,23 @@ import { beforeEach, expect, it, vi } from 'vitest' -type Handler = (event: unknown, args: { id: unknown }) => Promise +type Handler = (event: unknown, args: { id: unknown; target?: unknown }) => Promise -const mocks = vi.hoisted(() => ({ - handlers: new Map(), - olderPtyIds: new Set(), - startup: vi.fn<() => Promise | undefined>() +type Mocks = { + wslRunsAnotherAccount: boolean | null + handlers: Map + legacyDaemons: Map> + startup: ReturnType Promise | undefined>> + runsAnotherAccount: boolean | null + router: boolean +} + +const mocks = vi.hoisted((): Mocks => ({ + handlers: new Map(), + legacyDaemons: new Map(), + startup: vi.fn<() => Promise | undefined>(), + runsAnotherAccount: true, + wslRunsAnotherAccount: true, + router: true })) vi.mock('../../pty-host-bindings', () => ({ getPtyIpc: () => ({ @@ -13,24 +25,68 @@ vi.mock('../../pty-host-bindings', () => ({ }) })) vi.mock('../../../daemon/daemon-provider-state', () => ({ - isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => - protocolVersion === 44 && mocks.olderPtyIds.has(id) + isTerminalOnLegacyDaemon: (id: string, matches: (protocolVersion: number) => boolean) => + [...mocks.legacyDaemons].some(([version, ids]) => matches(version) && ids.has(id)) +})) +vi.mock('../../../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + mocks.router ? { systemDefaultRunsAnotherAccount: () => mocks.runsAnotherAccount } : undefined, + getClaudeWslProfileRouter: () => + mocks.router + ? { + systemDefaultRunsAnotherAccount: async (distro: string) => + distro === 'Ubuntu' ? mocks.wslRunsAnotherAccount : null + } + : undefined })) import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' beforeEach(() => { mocks.handlers.clear() - mocks.olderPtyIds = new Set(['old-1']) + // v41 predates the claude function, v42 had it as v44 does, and v43 (the revert) dropped it. + mocks.legacyDaemons = new Map([ + [41, new Set(['old-1'])], + [42, new Set(['v42-1'])], + [43, new Set(['v43-1'])] + ]) + mocks.runsAnotherAccount = true + mocks.wslRunsAnotherAccount = true + mocks.router = true installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) }) -it('answers true only for a pane owned by a daemon from before per-account Claude folders', async () => { +const HOST = { runtime: 'host' } + +it('answers true only for a pane whose daemon lacks the claude function', async () => { const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! - expect(await ask({}, { id: 'old-1' })).toBe(true) + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(true) + expect(await ask({}, { id: 'v43-1', target: HOST })).toBe(true) + expect(await ask({}, { id: 'v42-1', target: HOST })).toBe(false) // A pane on this build's daemon, or an SSH pane, which no local daemon owns. - expect(await ask({}, { id: 'new-1' })).toBe(false) - expect(await ask({}, { id: 'ssh:conn-1:pty-1' })).toBe(false) - expect(await ask({}, { id: 42 })).toBe(false) + expect(await ask({}, { id: 'new-1', target: HOST })).toBe(false) + expect(await ask({}, { id: 'ssh:conn-1:pty-1', target: HOST })).toBe(false) + expect(await ask({}, { id: 42, target: HOST })).toBe(false) expect(mocks.startup).toHaveBeenCalled() }) + +it("judges a pane by its own runtime's selection, and hides it when that is unknown", async () => { + const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! + mocks.runsAnotherAccount = false + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) + // A WSL pane asks its distro's router, not the host selection. + const ubuntu = { runtime: 'wsl', wslDistro: 'Ubuntu' } + expect(await ask({}, { id: 'old-1', target: ubuntu })).toBe(true) + mocks.wslRunsAnotherAccount = false + expect(await ask({}, { id: 'old-1', target: ubuntu })).toBe(false) + // No account selected for the pane's runtime, no router, or no runtime at all: hidden. + mocks.runsAnotherAccount = null + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) + expect(await ask({}, { id: 'old-1', target: { runtime: 'wsl', wslDistro: 'Debian' } })).toBe( + false + ) + expect(await ask({}, { id: 'old-1' })).toBe(false) + mocks.router = false + mocks.runsAnotherAccount = true + expect(await ask({}, { id: 'old-1', target: HOST })).toBe(false) +}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts index 385800241cbd..04ad8c58a022 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -1,23 +1,60 @@ +import { + getClaudeProfileRouter, + getClaudeWslProfileRouter +} from '../../../claude-accounts/claude-profile-installed-router' import { getPtyIpc } from '../../pty-host-bindings' -import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' -import { isTerminalFromBeforeDaemonProtocol } from '../../../daemon/daemon-provider-state' +import { + CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION, + CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION +} from '../../../daemon/daemon-protocol-version' +import { isTerminalOnLegacyDaemon } from '../../../daemon/daemon-provider-state' + +// Why not every older daemon: v42 shipped the same claude function and pointer; only v43, the revert, lacked them. +function lacksClaudeFunction(protocolVersion: number): boolean { + return ( + protocolVersion < CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION || + protocolVersion === CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION + ) +} type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } -/** Whether a pane's daemon predates the claude function that follows the selected account. */ +/** Whether a pane's daemon predates the claude function, so its claude runs another account. */ export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { getPtyIpc().handle( 'pty:openedBeforeClaudeAccounts', - async (_event, args: { id: string }): Promise => { - if (typeof args?.id !== 'string') { + async (_event, args: { id: unknown; target?: unknown }): Promise => { + const target = paneTarget(args?.target) + if (typeof args?.id !== 'string' || !target) { return false } // Why: the pre-swap provider does not own restored daemon ids. await deps.getLocalPtyProviderStartupPromise() - return isTerminalFromBeforeDaemonProtocol( - args.id, - CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION - ) + if (!isTerminalOnLegacyDaemon(args.id, lacksClaudeFunction)) { + return false + } + // Why the pane's own runtime: claude there runs that runtime's System default. + const runsAnother = + target.runtime === 'wsl' + ? await getClaudeWslProfileRouter() + ?.systemDefaultRunsAnotherAccount(target.wslDistro) + .catch(() => null) + : getClaudeProfileRouter()?.systemDefaultRunsAnotherAccount() + return runsAnother === true } ) } + +function paneTarget( + value: unknown +): { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } | null { + if (typeof value !== 'object' || value === null || !('runtime' in value)) { + return null + } + if (value.runtime === 'host') { + return { runtime: 'host' } + } + return value.runtime === 'wsl' && 'wslDistro' in value && typeof value.wslDistro === 'string' + ? { runtime: 'wsl', wslDistro: value.wslDistro } + : null +} diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index 95296659b652..b059c33198de 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -11,10 +11,6 @@ import { } from '../../../../shared/jcode-runtime-dir' import { isOpaqueRemintedPaneKey } from '../../../../shared/pane-key-alias' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - hasClaudeAuthEnvConflict -} from '../../../claude-accounts/environment' import { prewarmJcodeDaemon } from '../../../jcode/daemon-prewarm' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' @@ -28,9 +24,6 @@ import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native- export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) - } // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 8adec688a678..55cce7903408 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,7 +1,6 @@ import { getAppEnvironment } from '../../../../shared/app-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' @@ -39,11 +38,7 @@ export async function buildPtyIpcSpawnOptions( ctx.preAllocatedHandle ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } : ctx.env, ctx.deps.getSettings?.() ) - const envToDelete = ctx.claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined ctx.combinedEnvToDelete = mergePtyEnvDeletions( - envToDelete, args.envToDelete ?? [], // Persistent daemons and older SSH hosts must not resurrect a parent Pi's ownership. PI_PROCESS_OWNER_ENV_KEYS, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 26c3856b45ee..6f041b0686ba 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -19,7 +19,6 @@ import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' @@ -47,9 +46,6 @@ export async function buildRuntimePtySpawnOptions( // Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env. ctx.env = withCodexTerminalServerIsolationEnv(ctx.env, ctx.deps.getSettings?.()) - const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined ctx.spawnOptions = { cols: args.cols, rows: args.rows, @@ -74,7 +70,6 @@ export async function buildRuntimePtySpawnOptions( args.onPtySpawnCommitted?.() } ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( - authEnvToDelete, args.envToDelete ?? [], // Persistent daemons and older SSH hosts must not resurrect a parent Pi's ownership. PI_PROCESS_OWNER_ENV_KEYS, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 729cf74af786..0c8d2371dc01 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,10 +23,6 @@ import { } from '../host-env/fresh-spawn-routing' import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { - CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, - hasClaudeAuthEnvConflict -} from '../../../claude-accounts/environment' import { isSafePtySessionId, mintPtySessionId, @@ -146,9 +142,6 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null - if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) - } ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true if (ctx.shouldPersistHostSessionBinding) { diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index d778f98c247b..9b2d56aa49d4 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -49,7 +49,7 @@ describe('inline visuals launch preference', () => { store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repo', resolveCommand: () => '/bin/claude', - resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAuthPolicy: () => ({ account: 'system' }), resolveLaunchArgs: () => ['--add-dir', '/user/notes', '--plugin-dir', '/user/plugin'], resolveEnv: () => ({ [NATIVE_CHAT_VISUALS_DIR_ENV]: '/inherited', KEEP: 'yes' }), resolveInheritedEnv: async () => ({ PATH: '/bin' }), diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index b2e419f8bb0e..e94265de7f6a 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -1,6 +1,8 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { savedWslClaudeAccountHome } from '../claude-accounts/claude-profile-wsl-paths' +import { + getClaudeProfileRouter, + getClaudeWslProfileRouter +} from '../claude-accounts/claude-profile-installed-router' import { toWindowsWslPath } from '../../shared/wsl-paths' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch' @@ -19,42 +21,33 @@ export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - const configDir = - account.managedAuthRuntime === 'wsl' - ? savedWslClaudeAccountHome(account) - : (getClaudeProfileRouter()?.accountHome(account.id) ?? null) - if (!configDir || (account.managedAuthRuntime === 'wsl' && !account.wslDistro)) { - return makeClaudeUsageResult('error', 'Sign in again to use this account.', { - failureKind: 'missing-credentials', - attemptedSources: [] - }) + if (account.managedAuthRuntime !== 'wsl') { + // Why the router: a covered account's usage is System default's, as its launches are. + const authPreparation = getClaudeProfileRouter()?.accountUsagePreparation(account.id) + return authPreparation + ? fetchActiveClaudeRateLimits({ signal: options.signal, authPreparation }) + : signInAgain() + } + const distro = account.wslDistro + const router = getClaudeWslProfileRouter() + if (!distro || !router) { + return signInAgain() } - const home = - account.managedAuthRuntime === 'wsl' && account.wslDistro - ? toWindowsWslPath(configDir, account.wslDistro) - : configDir // Why: a stopped distro parks a UNC read for minutes, and the inactive loop is sequential. + const guestRoot = toWindowsWslPath('/', distro) if ( - account.managedAuthRuntime === 'wsl' && - (await filterPathsToRunningWslDistrosAsync([home], { requireConfirmed: true })).length === 0 + (await filterPathsToRunningWslDistrosAsync([guestRoot], { requireConfirmed: true })).length === + 0 ) { return claudeUsageUnavailable() } - const read = fetchActiveClaudeRateLimits({ - signal: options.signal, - authPreparation: { - configDir: home, - runtime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - provenance: `profile:${account.id}` - } - }) - if (account.managedAuthRuntime !== 'wsl') { - return read - } const timeout = AbortSignal.timeout(INACTIVE_WSL_READ_TIMEOUT_MS) + const read = router + .accountUsagePreparation(distro, account.id) + .then((authPreparation) => + fetchActiveClaudeRateLimits({ signal: options.signal, authPreparation }) + ) + .catch(() => claudeUsageUnavailable()) return Promise.race([ read, new Promise((resolve) => @@ -63,4 +56,11 @@ export async function fetchInactiveClaudeAccountUsage( ]) } +function signInAgain(): ProviderRateLimits { + return makeClaudeUsageResult('error', 'Sign in again to use this account.', { + failureKind: 'missing-credentials', + attemptedSources: [] + }) +} + const INACTIVE_WSL_READ_TIMEOUT_MS = 10_000 diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts index 36816ea655ae..a87ddf4c9299 100644 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -11,9 +11,11 @@ vi.mock('../claude-accounts/keychain', () => ({ readActiveClaudeKeychainCredentialsStrict: calls.keychain })) const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({})) +const wslRouter = vi.hoisted(() => ({ accountUsagePreparation: vi.fn() })) vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ getClaudeProfileRouter: () => - profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined + profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined, + getClaudeWslProfileRouter: () => wslRouter })) vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage })) const runningDistros = vi.hoisted(() => ({ @@ -51,7 +53,6 @@ function profile() { authPreparation: { configDir: home, envPatch: { CLAUDE_CONFIG_DIR: home }, - stripAuthEnv: true, provenance: 'profile:fake' } } @@ -64,7 +65,7 @@ function systemDefault() { return { home, options: { - authPreparation: { configDir: home, envPatch: {}, stripAuthEnv: false, provenance: 'system' } + authPreparation: { configDir: home, envPatch: {}, provenance: 'system' } } } } @@ -198,7 +199,6 @@ it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuff resolveClaudeOAuthCredentialReadOptions({ configDir: inherited, envPatch: {}, - stripAuthEnv: false, provenance: 'system' }) ) @@ -221,7 +221,6 @@ it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DI resolveClaudeOAuthCredentialReadOptions({ configDir: '/shell/claude-config', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })?.keychainConfigDir ).toBe('/shell/claude-config') @@ -241,31 +240,39 @@ it('reports a host problem as unavailable usage, never as a signed-out account', }) ).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } }) }) -it('asks an inactive account from an older Orca to sign in again, reading nothing', async () => { +it('reads an inactive WSL account where the WSL router says its launches would run', async () => { const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') - expect( - await fetchInactiveClaudeAccountUsage({ - id: 'acct', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' - }) - ).toMatchObject({ usageMetadata: { failureKind: 'missing-credentials' } }) - expect(calls.keychain).not.toHaveBeenCalled() + const authPreparation = { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', + runtime: 'wsl' as const, + wslDistro: 'Ubuntu', + envPatch: {}, + provenance: 'wsl:Ubuntu:system' + } + wslRouter.accountUsagePreparation.mockResolvedValueOnce(authPreparation) + calls.usage.mockResolvedValueOnce({ five_hour: null, seven_day: null }) + await fetchInactiveClaudeAccountUsage({ + id: 'acct', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth' + }) + expect(wslRouter.accountUsagePreparation).toHaveBeenCalledWith('Ubuntu', 'acct') }) it('does not read an inactive WSL account through a stopped distro', async () => { const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage') runningDistros.filter.mockResolvedValueOnce([]) + wslRouter.accountUsagePreparation.mockClear() const result = await fetchInactiveClaudeAccountUsage({ id: 'acct', managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu', wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home' }) - expect(runningDistros.filter).toHaveBeenCalledWith( - ['\\\\wsl.localhost\\Ubuntu\\home\\u\\.local\\share\\orca\\claude-profiles\\acct\\home'], - { requireConfirmed: true } - ) + expect(runningDistros.filter).toHaveBeenCalledWith(['\\\\wsl.localhost\\Ubuntu\\'], { + requireConfirmed: true + }) expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } }) + expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled() expect(calls.keychain).not.toHaveBeenCalled() }) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 4366417dc3e7..dba478291d7f 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -368,7 +368,6 @@ describe('RateLimitService', () => { wslDistro: target?.wslDistro ?? null, wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, envPatch: target?.runtime === 'wsl' ? { CLAUDE_CONFIG_DIR: '/home/jin/.claude' } : {}, - stripAuthEnv: target?.runtime === 'wsl', provenance: target?.runtime === 'wsl' ? 'managed:wsl-account:wsl:Ubuntu' : 'system' })) service.setClaudeAuthPreparationResolver(resolver) @@ -385,8 +384,7 @@ describe('RateLimitService', () => { authPreparation: expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/jin/.claude', - stripAuthEnv: true + wslLinuxConfigDir: '/home/jin/.claude' }), allowPtyFallback: true, allowUsagePanelSupplement: true, @@ -404,7 +402,6 @@ describe('RateLimitService', () => { wslDistro: null, wslLinuxConfigDir: null, envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) @@ -450,7 +447,6 @@ describe('RateLimitService', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/jin/.claude', envPatch: {}, - stripAuthEnv: true, provenance: 'wsl:Ubuntu:system' })) @@ -561,7 +557,6 @@ describe('RateLimitService', () => { wslDistro: target?.wslDistro ?? null, wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, envPatch: {}, - stripAuthEnv: target?.runtime === 'wsl', provenance: target?.runtime === 'wsl' ? 'managed:wsl-account-1:wsl:Ubuntu' : 'system' })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 469b51e4cf1c..ca05c7c0fb92 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -404,7 +404,6 @@ describe('RateLimitService', () => { service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) @@ -437,7 +436,6 @@ describe('RateLimitService', () => { service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index f6cbc7006200..01c341d00f50 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -296,7 +296,6 @@ describe('RateLimitService', () => { service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/own/claude-config', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) await service.refresh() @@ -323,7 +322,6 @@ describe('RateLimitService', () => { service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/shell/claude-config', envPatch: {}, - stripAuthEnv: false, provenance: 'system' })) await service.refresh() @@ -500,7 +498,6 @@ describe('RateLimitService', () => { envPatch: { CLAUDE_CONFIG_DIR: outgoing ? '/outgoing/.claude' : '/incoming/.claude' }, - stripAuthEnv: false, provenance: outgoing ? 'managed:outgoing' : 'managed:incoming' } }) diff --git a/src/main/runtime/agent-session-record-store-slot.test.ts b/src/main/runtime/agent-session-record-store-slot.test.ts index 35005c3badfc..6b69d5848004 100644 --- a/src/main/runtime/agent-session-record-store-slot.test.ts +++ b/src/main/runtime/agent-session-record-store-slot.test.ts @@ -54,7 +54,7 @@ describe('the record store slot', () => { ...location(), claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [] }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index bc44b74a8a16..a2eaee178c4a 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -208,7 +208,7 @@ function textOf(item: AgentJournalRenderItem): string { beforeEach(async () => { operations = 0 - claudeAuthPolicy = { stripAuthEnv: false } + claudeAuthPolicy = { account: 'system' } shellEnv = { PATH: '/shell/bin:/usr/bin' } shellEnvironmentPolicy = { inheritAll: true, names: [] } claudeLaunchEnv = { @@ -318,8 +318,8 @@ describe('a structured Claude session over agentSession.*', () => { ]) }) - it('strips ambient Anthropic auth from the child once a managed account is pinned', async () => { - claudeAuthPolicy = { stripAuthEnv: true } + it("keeps the shell's Anthropic auth with its proxy address once a managed account is pinned", async () => { + claudeAuthPolicy = { account: 'managed' } claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } shellEnv = { ...shellEnv, @@ -331,9 +331,8 @@ describe('a structured Claude session over agentSession.*', () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) const env = claude.live().launch.env - expect(env).not.toHaveProperty('ANTHROPIC_API_KEY') - expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') expect(env).toMatchObject({ + ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK', ANTHROPIC_BASE_URL: 'https://gateway.example.test', CLAUDE_CONFIG_DIR: join(root, 'claude-home') }) @@ -388,36 +387,6 @@ describe('a structured Claude session over agentSession.*', () => { expect(env).not.toHaveProperty('CODEX_LB_API_KEY') }) - it('refuses a create whose configured env overrides the pinned managed account auth', async () => { - claudeAuthPolicy = { stripAuthEnv: true } - // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path - // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const params = createIntentParams() - const sentence = - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - const refused = await call('agentSession.create', params) - - // The thrown answer keeps its wire code; only its words are the ones its replay reads. - expect(refused).toMatchObject({ - ok: false, - error: { code: 'runtime_error', message: sentence } - }) - // Its replay reads the same sentence, beside the situation it names. - expect(await call('agentSession.create', params)).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'managedAccountEnvOverride' }, - message: sentence - } - } - }) - // Refused before spawn: no provider child was ever opened. - expect(claude.connections).toHaveLength(0) - }) - it('publishes, then ends the session with sign-in guidance when initialization has no credentials', async () => { claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 4b63ec021c89..7199bd74a17c 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -133,14 +133,10 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt ...process.env, ...args.baseEnv } - const inheritedEnvKeys = new Set(Object.keys(baseEnv)) const auth = args.prepareAuth && this.prepareClaudeAuth ? await this.prepareClaudeAuth() : null if (auth) { - applyClaudeEnvPatch(baseEnv, auth.envPatch, { stripAuthEnv: auth.stripAuthEnv }) + applyClaudeEnvPatch(baseEnv, auth.envPatch) } - const envToDelete = auth?.stripAuthEnv - ? [...inheritedEnvKeys].filter((key) => !(key in baseEnv)) - : undefined const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) const launch = this.claudeAgentTeams.createLaunchEnv({ @@ -150,7 +146,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt shimBin }) const env = auth ? { ...auth.envPatch, ...launch.env } : launch.env - return envToDelete ? { env, envToDelete } : { env } + return { env } } // Why: a leader handle that never binds to a PTY (lost pane race) has no exit diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 3ba29e29e75f..5eb76a9168df 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -25,7 +25,7 @@ export function resolveStructuredClaudeAccountHomePath( deps: StructuredClaudeAccountHomeDeps ): string { // Why only an account: System default keeps the launch-env and configured homes below. - const accountHome = deps.wslDistro ? null : getClaudeProfileRouter()?.selectedHome() + const accountHome = deps.wslDistro ? null : getClaudeProfileRouter()?.routedHome() if (accountHome) { return accountHome } diff --git a/src/main/runtime/structured-agent-launch-args-wiring.test.ts b/src/main/runtime/structured-agent-launch-args-wiring.test.ts index 6037c26e5732..5b266396d3a8 100644 --- a/src/main/runtime/structured-agent-launch-args-wiring.test.ts +++ b/src/main/runtime/structured-agent-launch-args-wiring.test.ts @@ -30,7 +30,7 @@ it('refuses installation when the host omits the saved Arguments source', async hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), logger: createStructuredAgentSessionLogger() }) ).rejects.toThrow(STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED) diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index 544676dc9290..9845c33166bf 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -113,7 +113,7 @@ describe('the model catalog contract on every registration', () => { stateDirectory: '/state', resolveClaudeCommand: () => resolveStructuredAgentCommand('claude', settings), resolveCodexCommand: (options) => resolveStructuredAgentCommand('codex', settings, options), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveClaudeAuthPolicy: () => ({ account: 'system' as const }), resolveAgentCommandSettings: () => settings } as StructuredAgentModelCatalogContext['deps'], environment: { diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index e28f7f689006..a1095f28ccc9 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -217,7 +217,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index 31dff2777569..6e3e94a3fa14 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -233,7 +233,7 @@ beforeEach(async () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveLaunchArgs: () => [], resolveEnvironment: async () => ({ PATH: process.env.PATH }), diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 13b70638b95a..7c07255edb31 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -161,7 +161,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 3824f6edb6de..02c7ae282f27 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -174,7 +174,7 @@ async function startHost(): Promise { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 9db9b37287ca..b57b6a3e1b52 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -277,7 +277,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index e343a45ab211..a9ba53700dd3 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -309,7 +309,7 @@ describe('installing the runtime', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) } as unknown as Parameters[0] ) ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) @@ -326,7 +326,7 @@ describe('installing the runtime', () => { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), logger: { warn: () => { throw new Error('logger broke') diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 3187b44fa84b..c3a39e8eb4db 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -44,7 +44,7 @@ function installHost(): Promise { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}), logger: createStructuredAgentSessionLogger() }) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 50c22f15b7dd..a33bbb33fd1d 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -45,7 +45,7 @@ function install(): ReturnType { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) } diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index ecc32d4f3837..4e4597aab850 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -41,7 +41,7 @@ function install(): ReturnType { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) } diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2842c2b280fd..49ca125b1d12 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -347,7 +347,7 @@ beforeEach(async () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index d9562e2ffefe..f5ed248e736a 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -88,7 +88,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -194,7 +194,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -226,7 +226,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -301,7 +301,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -409,7 +409,7 @@ describe('structured session runtime provider-exit wiring', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts index 7fe0c5945860..b104e572a6ae 100644 --- a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -50,7 +50,7 @@ describe('the host catalog lifetime', () => { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' as const }) }) expect(service.prewarm).toHaveBeenCalledOnce() expect(service.stop).not.toHaveBeenCalled() diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index c6b663bf0c14..947b73dfdd0c 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -229,7 +229,7 @@ describe('structured agent-session runtime install', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}) }) let finishWrite = (): void => {} @@ -271,7 +271,7 @@ describe('structured agent-session runtime install', () => { resolveWorkspacePath: async () => stateDirectory!, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), readProcessStartTime: async () => 1_700_000_000_000 }) diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 7ebc52cd89a7..eb4e962051ef 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -143,7 +143,7 @@ describe('a process that inherited a spawn token', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({}) }) // The installed host reads the record the seed wrote, so the check below has a lease to act on. diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index ec800315ed6c..6f4c08e815b7 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -81,7 +81,7 @@ function startupRuntime(log = recordingStructuredAgentSessionLogger()) { resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) internal.refreshMobileSessionPtyRecords = async () => new Set() return runtime diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index d29159ec1116..039b80631dda 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -186,7 +186,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str resolveWorkspacePath: async () => root, resolveEnvironment: async () => ({}), resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + resolveClaudeAuthPolicy: () => ({ account: 'managed' }) }) options.afterInstall?.() return installed diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 6e9faebd1cae..8155b889b37e 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -255,7 +255,7 @@ beforeEach(async () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({ PATH: '/usr/bin' }), openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 02079d90360a..294e2fd227a9 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -124,7 +124,7 @@ describe('the chat strip and the session list read the same host child records', claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts index 9971f93ccdbf..8899691f1e68 100644 --- a/src/main/runtime/structured-claude-attachment-grant.test.ts +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -70,7 +70,7 @@ describe("structured Claude's read grant for chat attachments", () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => directory, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveLaunchArgs: () => [], resolveEnvironment: async () => ({}) }) diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts index cc2b5c3b1640..845c5c80dba4 100644 --- a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -8,11 +8,8 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -/** - * The structured host's Claude auth policy has exactly one production wiring. Deleting it - * used to leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached - * the child, because `stripAuthEnv` silently fell back to `false`. - */ +/** The structured host's Claude auth policy has exactly one production wiring; without it a + * failed sign-in would silently name the wrong login. */ describe('structured Claude auth policy wiring', () => { describe('installing without one', () => { let stateDirectory: string | null = null diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 25a0ef4ebe84..90d9b7f19a37 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -118,7 +118,7 @@ beforeEach(async () => { resolveWorkspacePath: async (id) => `/repos/${id}`, resolveClaudeCommand: () => '/usr/local/bin/claude', resolveClaudeLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveClaudeAuthPolicy: () => ({ account: 'system' }), openClaudeConnection: claude.openConnection, readProcessStartTime: async () => HOST_TEST_NOW, onLifecycleEvent: () => {} diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 8d7db18ecfd5..96614dc817d7 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -241,7 +241,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { resolveWorkspacePath: async () => directory, resolveClaudeCommand: () => '/usr/local/bin/claude', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveClaudeAuthPolicy: () => ({ account: 'system' }), openClaudeConnection: openConnection, readProcessStartTime }) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 3bf2dbf7d19d..d32fc1f74d1a 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -77,7 +77,7 @@ describe('structured Codex child work through the production runtime', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 52929392d888..c86419e60540 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -214,7 +214,7 @@ export async function openStructuredCodexRpcHarness( resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({ PATH: '/shell/bin:/usr/bin', EXAMPLE_GATEWAY_TOKEN: 'shell-exported', diff --git a/src/main/runtime/structured-foreground-command-strip.test.ts b/src/main/runtime/structured-foreground-command-strip.test.ts index f21054ef1b64..bba1246a1819 100644 --- a/src/main/runtime/structured-foreground-command-strip.test.ts +++ b/src/main/runtime/structured-foreground-command-strip.test.ts @@ -56,7 +56,7 @@ describe('foreground commands in the background-task channel', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => root, resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: codex.openConnection, diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 588a56058e13..b90dd4434ab6 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -130,9 +130,7 @@ export async function prepareLocalCommitMessageAgentEnv( if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { const preparation = await resolvers.prepareForClaudeLaunch(target) - const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, { - stripAuthEnv: preparation.stripAuthEnv - }) + const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch) return { ok: true, env } } } catch (error) { diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index cbcf78a4abe2..1dc52c949e9c 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -145,8 +145,12 @@ export type PtyApi = { /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise - /** A local pane whose daemon predates per-account Claude folders; false for any other pane. */ - openedBeforeClaudeAccounts: (id: string) => Promise + /** A local pane whose daemon lacks the claude function and whose claude runs another account + * than the one selected for the pane's runtime; false for any other pane. */ + openedBeforeClaudeAccounts: ( + id: string, + target: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } + ) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: (scope?: PtySessionListScope) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 3d72ac5e518f..e01783382b5d 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -23,8 +23,10 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), stopCodexSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), - openedBeforeClaudeAccounts: (id: string): Promise => - ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id }), + openedBeforeClaudeAccounts: ( + id: string, + target: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } + ): Promise => ipcRenderer.invoke('pty:openedBeforeClaudeAccounts', { id, target }), getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), getSize: (id: string): Promise<{ cols: number; rows: number } | null> => ipcRenderer.invoke('pty:getSize', { id }), diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 012a16ebebc2..ebe655728115 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -23,7 +23,6 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' -import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' @@ -67,5 +66,4 @@ export function useAppShellServices(): void { useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() useCodexSharedSettingsNotice() - useClaudeAccountSignInNotice() } diff --git a/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx b/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx index e49d63940de3..76c31cafe73a 100644 --- a/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx +++ b/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx @@ -4,6 +4,8 @@ import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { resolvePaneWslDistro } from './terminal-pane-wsl-distro' import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' import { OldTerminalDialog } from './OldTerminalDialog' import { openTerminalBesideTab } from './open-terminal-beside-tab' @@ -13,29 +15,55 @@ import { PaneBannerLearnMore, PaneTopWarningBanner } from './PaneTopWarningBanne const dismissedPtyIds = new Set() // Why a selection, not just saved accounts: with System default selected, this terminal already -// matches a new one. -function hasSelectedClaudeAccount(state: AppState): boolean { +// matches a new one. A key, so a switch asks again whether this terminal runs another account. +function selectedClaudeAccountKey(state: AppState): string | null { const settings = state.settings if (!settings || settings.claudeManagedAccounts.length === 0) { - return false + return null } const selection = settings.activeClaudeManagedAccountIdsByRuntime - return Boolean( - selection?.host ?? - settings.activeClaudeManagedAccountId ?? - Object.values(selection?.wsl ?? {}).some(Boolean) - ) + const host = selection?.host ?? settings.activeClaudeManagedAccountId ?? null + const wsl = Object.entries(selection?.wsl ?? {}).filter(([, id]) => id) + return host || wsl.length > 0 ? JSON.stringify([host, wsl]) : null +} + +type PaneClaudeTarget = { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string } + +/** The runtime a pane's shell runs in; null when its tab or workspace is unknown. */ +function paneClaudeTarget(tabId: string): PaneClaudeTarget | null { + const state = useAppStore.getState() + const tab = Object.values(state.unifiedTabsByWorktree) + .flat() + .find((candidate) => candidate.contentType === 'terminal' && candidate.entityId === tabId) + if (!tab) { + return null + } + if (tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return { runtime: 'host' } + } + const worktree = state.allWorktrees().find((candidate) => candidate.id === tab.worktreeId) + if (!worktree) { + return null + } + const wslDistro = resolvePaneWslDistro(state, worktree.id, worktree.path) + return wslDistro ? { runtime: 'wsl', wslDistro } : { runtime: 'host' } } -function useOpenedBeforeClaudeAccounts(ptyId: string, enabled: boolean): boolean { +function useOpenedBeforeClaudeAccounts( + ptyId: string, + tabId: string, + selectionKey: string | null +): boolean { const [openedBefore, setOpenedBefore] = useState(false) useEffect(() => { - if (!enabled) { + const target = selectionKey ? paneClaudeTarget(tabId) : null + // Why hidden without a target: only the pane's own runtime says which account it runs. + if (!target) { return } let cancelled = false void window.api.pty - .openedBeforeClaudeAccounts(ptyId) + .openedBeforeClaudeAccounts(ptyId, target) .catch(() => false) .then((answer) => { if (!cancelled) { @@ -46,7 +74,7 @@ function useOpenedBeforeClaudeAccounts(ptyId: string, enabled: boolean): boolean cancelled = true setOpenedBefore(false) } - }, [enabled, ptyId]) + }, [selectionKey, ptyId, tabId]) return openedBefore } @@ -62,7 +90,7 @@ export function ClaudeOldTerminalBanner({ }): React.JSX.Element | null { const paneKey = makePaneKey(tabId, leafId) const [dismissed, setDismissed] = useState(() => dismissedPtyIds.has(ptyId)) - const accountSelected = useAppStore(hasSelectedClaudeAccount) + const selectionKey = useAppStore(selectedClaudeAccountKey) // Why either signal: a typed claude is seen by the process read, or by its hooks. const claudeInPane = useAppStore( (state) => @@ -71,7 +99,8 @@ export function ClaudeOldTerminalBanner({ ) const openedBefore = useOpenedBeforeClaudeAccounts( ptyId, - accountSelected && claudeInPane && !dismissed + tabId, + claudeInPane && !dismissed ? selectionKey : null ) const [dialogOpen, setDialogOpen] = useState(false) // Why: the dialog has no Radix trigger, so closing it would leave focus on document.body. diff --git a/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx b/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx index cbd317a21835..deeb93131846 100644 --- a/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx +++ b/src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx @@ -5,8 +5,9 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { getDefaultSettings } from '../../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import type { ClaudeManagedAccount } from '../../../../shared/managed-account-types' +import type { Worktree } from '../../../../shared/worktree/types' import { makePaneKey } from '../../../../shared/stable-pane-id' import { ClaudeOldTerminalBanner } from './ClaudeOldTerminalBanner' @@ -32,7 +33,9 @@ const ACCOUNT: ClaudeManagedAccount = { } let paneElement: HTMLDivElement let root: Root -let openedBeforeClaudeAccounts: ReturnType Promise>> +let openedBeforeClaudeAccounts: ReturnType< + typeof vi.fn<(id: string, target: { runtime: string }) => Promise> +> let nextPtyId = 0 let ptyId: string @@ -41,10 +44,25 @@ class ResizeObserverStub { disconnect(): void {} } +const FLOATING_TAB = { + id: 'unified-floating', + entityId: TAB_ID, + groupId: 'group-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal' as const, + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 +} + function setState(settings: Partial, agent: 'claude' | 'codex' = 'claude'): void { useAppStore.setState({ settings: { ...getDefaultSettings('/home/me'), ...settings }, - paneForegroundAgentByPaneKey: { [PANE_KEY]: { agent, shellForeground: false } } + paneForegroundAgentByPaneKey: { [PANE_KEY]: { agent, shellForeground: false } }, + // A floating terminal runs on the host. + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [FLOATING_TAB] } }) } @@ -91,7 +109,7 @@ describe('ClaudeOldTerminalBanner', () => { it('says Claude in an older terminal uses System default, with a new terminal and Learn more', async () => { setState(SELECTED) await renderBanner() - expect(openedBeforeClaudeAccounts).toHaveBeenCalledWith(ptyId) + expect(openedBeforeClaudeAccounts).toHaveBeenCalledWith(ptyId, { runtime: 'host' }) expect(paneElement.textContent).toContain(TITLE) expect(paneElement.textContent).toContain("so claude here uses System default's login.") button('Open new terminal') @@ -121,6 +139,8 @@ describe('ClaudeOldTerminalBanner', () => { } ] }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the banner reads only a worktree's id and path. + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', path: '/repos/wt-1' } as Worktree] }, openNewTerminalTabInActiveWorkspace }) await renderBanner() @@ -148,6 +168,10 @@ describe('ClaudeOldTerminalBanner', () => { await renderBanner() setState({ claudeManagedAccounts: [], activeClaudeManagedAccountId: 'a' }) await renderBanner() + // A pane whose tab is unknown has no runtime to judge it by. + setState(SELECTED) + useAppStore.setState({ unifiedTabsByWorktree: {} }) + await renderBanner() expect(openedBeforeClaudeAccounts).not.toHaveBeenCalled() expect(paneElement.textContent).toBe('') }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d43608a92546..27a0aa0927cc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19547,12 +19547,7 @@ "signInAgain": "Sign in again", "usageSignInLabel": "Sign in again", "usageSignInMessage": "The selected Claude account needs you to sign in again. Open Settings > AI Provider Accounts, or choose System default.", - "signIn": "Sign in", - "signInNotice": { - "title": "Finish setting up your Claude accounts", - "description": "Sign in to each saved account once and you're ready to switch anytime.", - "action": "Sign in to {{value0}}" - } + "signIn": "Sign in" }, "managedData": { "description": "Add accounts by running this command in a terminal on the Orca host. Selection applies to new explicit agent launches on that host; direct SSH relay and Windows-hosted WSL launches use their own credentials.", diff --git a/src/renderer/src/lib/claude-account-sign-in-notice.test.ts b/src/renderer/src/lib/claude-account-sign-in-notice.test.ts deleted file mode 100644 index d4515b47a60f..000000000000 --- a/src/renderer/src/lib/claude-account-sign-in-notice.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - ClaudeManagedAccountSummary, - ClaudeRateLimitAccountsState -} from '../../../shared/managed-account-types' -import { - mountHook, - noticeTestStore, - setNoticeState, - unmountHooks -} from '../components/terminal-pane/codex-notice-test-harness' -import { useClaudeAccountSignInNotice } from './claude-account-sign-in-notice' - -const { toastInfo, toastError } = vi.hoisted(() => ({ toastInfo: vi.fn(), toastError: vi.fn() })) -vi.mock('sonner', () => ({ toast: { info: toastInfo, error: toastError } })) -vi.mock('@/store', () => import('../components/terminal-pane/codex-notice-test-harness')) - -function account(id: string, needsSignIn = false): ClaudeManagedAccountSummary { - return { - id, - email: `${id}@example.test`, - authMethod: 'subscription-oauth', - createdAt: 0, - updatedAt: 0, - lastAuthenticatedAt: 0, - ...(needsSignIn ? { needsSignIn: true as const } : {}) - } -} - -let state: ClaudeRateLimitAccountsState -const list = vi.fn(() => Promise.resolve(state)) -const reauthenticate = vi.fn(() => Promise.resolve(state)) -const fetchSettings = vi.fn(() => Promise.resolve()) - -async function settle(): Promise { - await act(async () => { - await Promise.resolve() - }) -} - -beforeEach(() => { - vi.clearAllMocks() - state = { - accounts: [account('a', true), account('b', true)], - activeAccountId: 'b', - activeAccountIdsByRuntime: { host: 'b', wsl: {} } - } - vi.stubGlobal('api', { claudeAccounts: { list, reauthenticate } }) - noticeTestStore.setState( - { - claudeAccountSignInNoticeSeen: true, - markClaudeAccountSignInNoticeSeen: () => - noticeTestStore.setState({ claudeAccountSignInNoticeSeen: true }), - fetchSettings - }, - true - ) -}) - -afterEach(() => { - unmountHooks() - vi.unstubAllGlobals() -}) - -describe('useClaudeAccountSignInNotice', () => { - it('waits for the persisted flag, then shows once with Sign in for the selected account', async () => { - await mountHook(useClaudeAccountSignInNotice) - expect(list).not.toHaveBeenCalled() - - await setNoticeState({ claudeAccountSignInNoticeSeen: false }) - await settle() - expect(noticeTestStore.getState().claudeAccountSignInNoticeSeen).toBe(true) - expect(toastInfo).toHaveBeenCalledTimes(1) - const [title, options] = toastInfo.mock.calls[0] - expect(title).toBe('Finish setting up your Claude accounts') - expect(options).toMatchObject({ - description: "Sign in to each saved account once and you're ready to switch anytime.", - duration: Infinity, - action: { label: 'Sign in to b@example.test' } - }) - - await act(async () => options.action.onClick()) - await settle() - expect(reauthenticate).toHaveBeenCalledWith({ accountId: 'b' }) - expect(fetchSettings).toHaveBeenCalled() - }) - - it('signs in to the first account needing it when the selected one does not', async () => { - state.accounts = [account('a'), account('b', true)] - state.activeAccountIdsByRuntime = { host: 'a', wsl: {} } - await mountHook(useClaudeAccountSignInNotice) - await setNoticeState({ claudeAccountSignInNoticeSeen: false }) - await settle() - await act(async () => toastInfo.mock.calls[0][1].action.onClick()) - await settle() - expect(reauthenticate).toHaveBeenCalledWith({ accountId: 'b' }) - }) - - it('never shows without an account that needs a sign-in, and never asks again', async () => { - state.accounts = [account('a')] - await mountHook(useClaudeAccountSignInNotice) - await setNoticeState({ claudeAccountSignInNoticeSeen: false }) - await settle() - expect(toastInfo).not.toHaveBeenCalled() - expect(noticeTestStore.getState().claudeAccountSignInNoticeSeen).toBe(true) - expect(list).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/lib/claude-account-sign-in-notice.ts b/src/renderer/src/lib/claude-account-sign-in-notice.ts deleted file mode 100644 index f1892652aa92..000000000000 --- a/src/renderer/src/lib/claude-account-sign-in-notice.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect } from 'react' -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { ClaudeManagedAccountSummary } from '../../../shared/managed-account-types' -import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' -import { claudeAccountToSignIn, signInToClaudeAccount } from './claude-account-sign-in' - -const NOTICE_TOAST_ID = 'claude-account-sign-in-notice' - -function showClaudeAccountSignInNotice(account: ClaudeManagedAccountSummary): void { - toast.info( - translate('accounts.claude.signInNotice.title', 'Finish setting up your Claude accounts'), - { - // Why a stable id: a late sync that resets the flag can't stack a second toast. - id: NOTICE_TOAST_ID, - description: translate( - 'accounts.claude.signInNotice.description', - "Sign in to each saved account once and you're ready to switch anytime." - ), - // Why no timeout: it is marked seen before showing, so an auto-close would lose it for good. - duration: Infinity, - action: { - label: translate('accounts.claude.signInNotice.action', 'Sign in to {{value0}}', { - value0: account.email - }), - onClick: () => void signInToClaudeAccount(account.id) - } - } - ) -} - -/** Once, on the first launch after the update: only when a saved account still needs a sign-in. */ -export function useClaudeAccountSignInNotice(): void { - // Why no hydration check: the flag defaults to true until the persisted value arrives. - const seen = useAppStore((s) => s.claudeAccountSignInNoticeSeen) - - useEffect(() => { - // Why skip paired web clients: the accounts are the host's, whose own window shows this. - if (seen || isPairedWebClientWindow()) { - return - } - let cancelled = false - void window.api.claudeAccounts - .list() - .then((state) => { - if (cancelled) { - return - } - // Why mark either way: the update happened once; a later sign-out is not this news. - useAppStore.getState().markClaudeAccountSignInNoticeSeen() - const accountId = claudeAccountToSignIn(state) - const account = state.accounts.find((candidate) => candidate.id === accountId) - if (account) { - showClaudeAccountSignInNotice(account) - } - }) - .catch((error: unknown) => console.warn('[claude-accounts] Sign-in notice skipped:', error)) - return () => { - cancelled = true - } - }, [seen]) -} diff --git a/src/renderer/src/lib/claude-account-sign-in.ts b/src/renderer/src/lib/claude-account-sign-in.ts index fb17da8744c9..1b9da263d49c 100644 --- a/src/renderer/src/lib/claude-account-sign-in.ts +++ b/src/renderer/src/lib/claude-account-sign-in.ts @@ -1,19 +1,11 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' import { getClaudeAccountErrorDescription, isClaudeAccountCancellation } from '@/components/settings/accounts-pane-action-errors' -/** The selected account when it needs a sign-in, else the first saved account that does. */ -export function claudeAccountToSignIn(state: ClaudeRateLimitAccountsState): string | null { - const needing = state.accounts.filter((account) => account.needsSignIn) - const selected = state.activeAccountIdsByRuntime?.host ?? state.activeAccountId - return (needing.find((account) => account.id === selected) ?? needing[0])?.id ?? null -} - /** Settings' Sign in again: a hidden `claude auth login` into the account's own folder. True once * signed in; a failure says why in a toast, and a cancel says nothing. */ export async function signInToClaudeAccount(accountId: string): Promise { diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index 96d80d064d3c..a69a18999c52 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -264,19 +264,6 @@ describe('createUISlice Codex shared-settings notice', () => { }) }) -describe('createUISlice Claude account sign-in notice', () => { - it('counts as seen until hydration, then follows the persisted flag, and persists marking', () => { - vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } }) - const store = createUIStore() - expect(store.getState().claudeAccountSignInNoticeSeen).toBe(true) - store.getState().hydratePersistedUI(makePersistedUI({})) - expect(store.getState().claudeAccountSignInNoticeSeen).toBe(false) - store.getState().markClaudeAccountSignInNoticeSeen() - expect(store.getState().claudeAccountSignInNoticeSeen).toBe(true) - expect(window.api.ui.set).toHaveBeenCalledWith({ claudeAccountSignInNoticeSeen: true }) - }) -}) - describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { it('restores the armed notice from persisted UI', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index ee7b8afcf014..142c64089813 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -123,6 +123,4 @@ export type UISliceContextual = { markCodexTerminalServerIsolationNoticeSeen: () => void codexSharedSettingsNoticeSeen: boolean markCodexSharedSettingsNoticeSeen: () => void - claudeAccountSignInNoticeSeen: boolean - markClaudeAccountSignInNoticeSeen: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 71937e554fd4..368b78866261 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -246,7 +246,6 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par codexTerminalServerIsolationNoticeSeen: ui.codexTerminalServerIsolationNoticeSeen === true, codexSharedSettingsNoticeSeen: ui.codexSharedSettingsNoticeSeen === true, - claudeAccountSignInNoticeSeen: ui.claudeAccountSignInNoticeSeen === true, ...hydrateAgentReadState(ui), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts index ce41e0debaf5..f631c210fd3f 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -166,15 +166,6 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ codexSharedSettingsNoticeSeen: true }).catch(console.error) return { codexSharedSettingsNoticeSeen: true } - }), - claudeAccountSignInNoticeSeen: true, - markClaudeAccountSignInNoticeSeen: () => - set((s) => { - if (s.claudeAccountSignInNoticeSeen) { - return s - } - window.api.ui.set({ claudeAccountSignInNoticeSeen: true }).catch(console.error) - return { claudeAccountSignInNoticeSeen: true } }) } } diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index f45c1f884642..e775c092dae6 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -40,7 +40,10 @@ function fixture() { ) chmodSync(fake, 0o700) const pointer = join(root, 'selected') - const run = (shell: string, env: string[] = []) => { + const run = (shell: string, env: string[] = [], fakeKind: 'summary' | 'printenv' = 'summary') => { + if (fakeKind === 'printenv') { + writeFileSync(fake, '#!/bin/sh\nenv\n') + } const fish = shell.endsWith('fish') const fn = fish ? getFishClaudeShellFunction() : getPosixClaudeShellFunction() // Why: a shell that reads system config can put a real claude ahead of the fake; abort first. @@ -76,16 +79,31 @@ const injected = (home: string) => [ ] describe.each(SHELLS)('the claude function in %s', (shell) => { - it('re-reads the selection on every launch and strips ambient auth for an account', () => { + it("re-reads the selection on every launch and keeps the shell's auth for an account", () => { const f = fixture() writeFileSync(f.pointer, f.a) - expect(f.run(shell, injected(f.b)).stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) + expect(f.run(shell, injected(f.b)).stdout).toBe(`HOME=${f.a} KEY=fake TWIN=${f.a}\n`) writeFileSync(f.pointer, f.b) const next = f.run(shell, injected(f.a)) - expect(next.stdout).toBe(`HOME=${f.b} KEY=none TWIN=${f.b}\n`) + expect(next.stdout).toBe(`HOME=${f.b} KEY=fake TWIN=${f.b}\n`) expect(next.status).toBe(23) }) + it("passes a shell proxy's key and address through to an account", () => { + const f = fixture() + writeFileSync(f.pointer, f.a) + const env = [ + 'ANTHROPIC_API_KEY=proxy-key', + 'ANTHROPIC_AUTH_TOKEN=proxy-token', + 'ANTHROPIC_BASE_URL=https://proxy.example.test', + 'ANTHROPIC_CUSTOM_HEADERS=Authorization: Bearer x' + ] + const printed = f.run(shell, env, 'printenv').stdout + for (const line of [...env, `CLAUDE_CONFIG_DIR=${f.a}`]) { + expect(printed).toContain(`${line}\n`) + } + }) + it('runs System default for an empty or missing selection, dropping only Orca’s own value', () => { const f = fixture() expect(f.run(shell).stdout).toBe('HOME=default KEY=fake TWIN=none\n') @@ -99,7 +117,7 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { const own = ['ORCA_CLAUDE_USER_CONFIG_DIR=/user/own'] writeFileSync(f.pointer, f.a) const account = f.run(shell, [...injected(f.a), ...own]) - expect(account.stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) + expect(account.stdout).toBe(`HOME=${f.a} KEY=fake TWIN=${f.a}\n`) writeFileSync(f.pointer, '') const restored = f.run(shell, [...injected(f.a), ...own]) expect(restored.stdout).toBe('HOME=/user/own KEY=fake TWIN=none\n') @@ -114,7 +132,7 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { const f = fixture() writeFileSync(f.pointer, f.a) const relative = f.run(shell, ['ORCA_CLAUDE_PROFILE_POINTER=~/selected']) - expect(relative.stdout).toBe(`HOME=${f.a} KEY=none TWIN=${f.a}\n`) + expect(relative.stdout).toBe(`HOME=${f.a} KEY=fake TWIN=${f.a}\n`) }) it('creates a missing account folder and runs Claude in it, printing nothing', () => { @@ -123,7 +141,7 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { const fresh = join(f.a, 'claude-profiles', 'id', 'home') writeFileSync(f.pointer, fresh) const result = f.run(shell) - expect(result.stdout).toBe(`HOME=${fresh} KEY=none TWIN=${fresh}\n`) + expect(result.stdout).toBe(`HOME=${fresh} KEY=fake TWIN=${fresh}\n`) expect(result.status).toBe(23) expect(existsSync(fresh)).toBe(true) // Same mode as Orca's own setup: the folder will hold a login. @@ -135,7 +153,7 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { const blocked = join(f.a, 'file', 'home') writeFileSync(join(f.a, 'file'), '') writeFileSync(f.pointer, blocked) - expect(f.run(shell).stdout).toBe(`HOME=${blocked} KEY=none TWIN=${blocked}\n`) + expect(f.run(shell).stdout).toBe(`HOME=${blocked} KEY=fake TWIN=${blocked}\n`) }) it('runs nothing for a pointer that names no absolute folder', () => { @@ -159,6 +177,12 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { }) }) +it("leaves the shell's Anthropic auth alone in the PowerShell function", () => { + const script = getPowerShellClaudeShellFunction() + expect(script).toContain("$names = @('CLAUDE_CONFIG_DIR', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR')") + expect(script).not.toMatch(/ANTHROPIC|CLAUDE_CODE_OAUTH_TOKEN|AWS_BEARER_TOKEN_BEDROCK/) +}) + it('restores PowerShell process env without creating empty variables', () => { const script = getPowerShellClaudeShellFunction() expect(script.startsWith('\n$orcaClaudeCommand = Get-Command claude')).toBe(true) diff --git a/src/shared/claude-shell-function.ts b/src/shared/claude-shell-function.ts index 2cb7307b2573..8e1bc86addfe 100644 --- a/src/shared/claude-shell-function.ts +++ b/src/shared/claude-shell-function.ts @@ -1,16 +1,10 @@ -const authHeaderWords = 'authorization|x-api-key|api-key|bearer' -const posixAuthHeaderPattern = authHeaderWords - .split('|') - .map((word) => `*${word.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`)}*`) - .join('|') - /** * `claude` re-reads the which-account file on every launch, so a switch reaches open terminals * (superset's wrapper rule). Defined only in a pane Orca routed (pointer env set) where `claude` is * a real executable. A missing or empty file is System default, which restores the user's own value * Orca's replaced; a CLAUDE_CONFIG_DIR the user set, as opposed to Orca's twin-marked value, wins. * Prints nothing: an account folder with no login is created, and Claude's own first run signs in - * there. A pointer that names no absolute folder (never written by Orca) runs nothing, since any + * there. The shell's Anthropic auth passes through untouched, as on System default. A pointer that names no absolute folder (never written by Orca) runs nothing, since any * fallback would be another account. */ export function getPosixClaudeShellFunction(): string { @@ -34,7 +28,7 @@ if [[ -n "\${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "\${__orca_claude_binary:-}" 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 ${posixAuthHeaderPattern}) unset ANTHROPIC_CUSTOM_HEADERS ;; esac; export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) + ( export CLAUDE_CONFIG_DIR="$__orca_claude_home" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$__orca_claude_home"; command claude "$@" ) } fi unset __orca_claude_binary @@ -66,11 +60,7 @@ if test -n "$ORCA_CLAUDE_PROFILE_POINTER"; and test "$__orca_claude_type" = file return 1 end test -d "$profile"; or mkdir -p -m 700 -- "$profile" 2>/dev/null - set -l headers - if string match -irq '${authHeaderWords}' -- "$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 + env CLAUDE_CONFIG_DIR="$profile" ORCA_CLAUDE_INJECTED_CONFIG_DIR="$profile" claude $argv end end set -e __orca_claude_type @@ -84,7 +74,7 @@ $orcaClaudeCommand = Get-Command claude -ErrorAction SilentlyContinue | Select-O if ($env:ORCA_CLAUDE_PROFILE_POINTER -and $orcaClaudeCommand -and $orcaClaudeCommand.CommandType -in @("Application", "ExternalScript")) { function Global:claude { - $names = @('CLAUDE_CONFIG_DIR', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK', 'ANTHROPIC_CUSTOM_HEADERS') + $names = @('CLAUDE_CONFIG_DIR', 'ORCA_CLAUDE_INJECTED_CONFIG_DIR') $saved = @{} foreach ($name in $names) { $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } try { @@ -103,9 +93,6 @@ function Global:claude { return } else { if (-not [IO.Directory]::Exists($orcaClaudeHome)) { $null = New-Item -ItemType Directory -Path $orcaClaudeHome -Force -ErrorAction SilentlyContinue } - foreach ($name in $names) { - if ($name -ne 'ANTHROPIC_CUSTOM_HEADERS' -or $env:ANTHROPIC_CUSTOM_HEADERS -match '${authHeaderWords}') { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } - } $env:CLAUDE_CONFIG_DIR = $orcaClaudeHome $env:ORCA_CLAUDE_INJECTED_CONFIG_DIR = $orcaClaudeHome } diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index f142de38e1ba..dfed812eb584 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -181,7 +181,7 @@ export type PersistedUIState = { codexTerminalServerIsolationNoticeSeen?: boolean /** Windows one-shot toast for Codex moving onto ~/.codex; set when shown, so absent means not yet seen. */ codexSharedSettingsNoticeSeen?: boolean - /** One-shot toast asking for one sign-in to each Claude account saved before per-account folders. */ + /** Retired one-shot Claude sign-in toast; kept because a paired client from that build still sends it. */ claudeAccountSignInNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null