From b8c4beb6d42c17df3eb8d0e829dddd42a9c82530 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 17:40:39 -0400 Subject: [PATCH 01/22] Run a Claude account on System default until it has its own login An account saved before per-account folders has no login in its folder. While System default (~/.claude, or the user's own CLAUDE_CONFIG_DIR) is signed in to the same email, the router sends every launch there and writes that choice into the pointer file, so nobody is logged out by the update. Once the account signs in to its own folder, the next launch uses it. The WSL router follows the same rule with the guest's ~/.claude. --- .../claude-profile-router.test.ts | 60 ++++++++- .../claude-accounts/claude-profile-router.ts | 115 +++++++++++++++--- .../claude-profile-wsl-router.test.ts | 32 +++++ .../claude-profile-wsl-router.ts | 58 +++++++-- .../runtime/structured-agent-account-home.ts | 2 +- 5 files changed, 235 insertions(+), 32 deletions(-) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 3db8218f016f..6b44015f7de4 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -102,7 +102,64 @@ function fixture(env: NodeJS.ProcessEnv = {}) { return { root, userHome, dataRoot, settings, router, home, setup } } +function signIn(stateDir: string, email: string): void { + mkdirSync(stateDir, { recursive: true }) + writeFileSync( + join(stateDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) +} + 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'), + stripAuthEnv: false, + 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'), + stripAuthEnv: true + }) + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) + expect(f.router.coveredBySystemDefault('a')).toBe(false) + }) + + 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("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 need not exist yet; it is set up in the background. + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + }) + 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')) @@ -208,11 +265,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, diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index fa44e53b5630..120fb061d329 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -1,4 +1,4 @@ -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' @@ -31,7 +31,12 @@ import { } from './runtime-selection' import { wslClaudeProfilePointer } from './claude-profile-wsl-paths' import { isDirectory, listClaudeProfileHomes } from './claude-profile-installed-router' -import { removeClaudeAccountFolder } from './claude-account-folder' +import { + claudeStateFile, + readClaudeFolderLogin, + removeClaudeAccountFolder, + type ClaudeFolderLogin +} from './claude-account-folder' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' export type ClaudeProfileRouterSettings = Pick< @@ -80,14 +85,45 @@ 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 email = this.args + .getSettings() + .claudeManagedAccounts.find((account) => account.id === accountId)?.email + const systemDefault = this.systemDefaultLogin()?.email + return Boolean(email && systemDefault && sameClaudeEmail(email, 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,28 +132,53 @@ 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 (readFileIfPresent(this.pointerPath) !== 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. */ 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) { + if (selected) { + this.setUpInBackground(selected) + } return this.preparation() } // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a @@ -198,9 +259,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. @@ -215,6 +276,7 @@ export class ClaudeProfileRouter { return { [CLAUDE_PROFILE_POINTER_ENV]: `~/${wslClaudeProfilePointer(this.args.dataRoot)}` } } try { + this.writePointer() 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,7 +289,8 @@ export class ClaudeProfileRouter { } preparation(): ClaudeRuntimeAuthPreparation { - const home = this.selectedHome() + const profile = this.routedProfile() + const home = this.routedHome() return { configDir: home ?? this.systemDefaultHome(), runtime: 'host', @@ -235,7 +298,7 @@ export class ClaudeProfileRouter { wslLinuxConfigDir: null, envPatch: this.launchEnv(), stripAuthEnv: home !== null, - provenance: home ? `profile:${this.selectedProfile()?.accountId}` : 'system' + provenance: home ? `profile:${profile?.accountId}` : 'system' } } @@ -245,6 +308,18 @@ export class ClaudeProfileRouter { } } +export function sameClaudeEmail(left: string, right: string): boolean { + return left.trim().toLowerCase() === right.trim().toLowerCase() +} + +function readFileIfPresent(file: string): string | null { + try { + return readFileSync(file, 'utf8') + } catch { + return null + } +} + // 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), { 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..931e7373d90d 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -263,6 +263,38 @@ 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 email in the guest's ~/.claude never stands in for the account. + rmSync(join(f.profileHome, '.claude.json')) + login(guest.home, 'b@example.test') + await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ + wslLinuxConfigDir: f.profileHome + }) + }) + 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..52cc1a669280 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -17,8 +17,10 @@ import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude- import { claudeProfileMissing, claudeProfileSetupFailed, + sameClaudeEmail, type ClaudeProfileRouterSettings } from './claude-profile-router' +import { claudeStateLogin } 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' @@ -63,7 +65,25 @@ 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 email = this.args + .getSettings() + .claudeManagedAccounts.find((account) => account.id === selected.accountId)?.email + const systemDefault = await guestLogin(distro, posix.join(home, '.claude.json')) + return email && systemDefault && sameClaudeEmail(email, systemDefault) ? null : selected } private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { @@ -80,7 +100,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,17 +110,21 @@ 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) } } + 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 launches at once. */ async prepareLaunch(distro: string): Promise { - const { home, profile } = await this.resolve(distro) + const { home, selected, profile } = await this.resolve(distro) // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a // set-up folder never blocks. if (profile && !(await hasMarker(distro, profile))) { @@ -108,12 +132,14 @@ export class ClaudeWslProfileRouter { console.warn('[claude-profile] WSL account setup failed:', error) throw claudeProfileSetupFailed() }) + } else if (selected && !profile) { + this.setUpInBackground(distro, home, selected.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) } @@ -190,6 +216,18 @@ export class ClaudeWslProfileRouter { } } +/** The login a guest state file names, read over the share like the marker. */ +async function guestLogin(distro: string, stateFile: string): Promise { + try { + const state: unknown = JSON.parse(await readFile(toWindowsWslPath(stateFile, distro), 'utf8')) + return state && typeof state === 'object' && !Array.isArray(state) + ? (claudeStateLogin(Object.fromEntries(Object.entries(state)))?.email ?? null) + : null + } catch { + return null + } +} + // 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/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 } From 26dae32984221f0de727f68bab9212d4867024d3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 17:51:53 -0400 Subject: [PATCH 02/22] Refresh each Claude account folder from ~/.claude before every launch ~/.claude (or the user's own CLAUDE_CONFIG_DIR) is now the master copy. Each account folder gets its whole settings.json, including the proxy address and its key, and every .claude.json key except the login, Claude's account caches and install ids, so first-run questions are not asked again. Every other top-level entry is linked (folders) or copied (files), except Claude's per-folder daemon, jobs, live sessions, login files and throwaways. sessions is no longer shared, and an earlier link is undone. The refresh runs before every Claude Orca starts, for each new terminal and on account switch. Orca never writes back to ~/.claude. The ledger that tracked selective sharing is gone. --- ...ude-credential-persistence-ratchet.test.ts | 5 +- .../claude-profile-history.test.ts | 17 + .../claude-accounts/claude-profile-history.ts | 33 +- .../claude-profile-provisioning.test.ts | 291 +++++++---------- .../claude-profile-provisioning.ts | 293 +++++++++--------- .../claude-accounts/claude-profile-report.ts | 18 +- .../claude-profile-router.test.ts | 27 +- .../claude-accounts/claude-profile-router.ts | 34 +- .../claude-profile-setup.test.ts | 5 +- .../claude-accounts/claude-profile-setup.ts | 31 +- .../claude-accounts/claude-profile-sharing.ts | 123 +------- .../claude-profile-wsl-entry.ts | 2 +- .../claude-profile-wsl-router.test.ts | 9 +- .../claude-profile-wsl-router.ts | 5 +- src/main/claude/claude-profile-hooks.test.ts | 3 +- src/main/claude/hook-service.ts | 2 +- src/main/codex-accounts/fs-utils.ts | 2 +- 17 files changed, 388 insertions(+), 512 deletions(-) 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..1c8211b6d846 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,7 @@ const fsAllowances: Record = { 'writeFileSync', 'writeFileAtomically' ], - 'claude-accounts/claude-profile-provisioning.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + 'claude-accounts/claude-profile-provisioning.ts': ['rmSync', 'writeFileSync'], 'claude-accounts/claude-profile-sharing.ts': [ 'mkdirSync', 'rmdirSync', 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-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 4bc12a4738a9..41e962f1dd7b 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,130 +170,110 @@ 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 }) - 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) 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 every other top-level entry, never Claude's daemon, live sessions or login files", + async () => { + const f = fixture() + const unshared = [ + 'daemon', + 'jobs', + 'state', + 'sessions', + 'cache', + 'debug', + 'telemetry', + 'statsig', + 'backups', + 'usage-data', + 'logs' + ] + for (const name of [...unshared, 'teams', 'ide', 'skills']) { + fs.mkdirSync(join(f.source, name)) + } + const unsharedFiles = [ + 'daemon.json', + 'daemon.log', + 'daemon.lock', + 'daemon.status.json', + '.credentials.json', + 'policy-limits.json', + 'remote-settings.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') + } + await provision(f) + for (const name of ['teams', 'ide', 'skills']) { + expect(fs.realpathSync(join(f.profileHome, name))).toBe( + fs.realpathSync(join(f.source, name)) + ) + } + for (const name of [...unshared, ...unsharedFiles]) { + expect(fs.existsSync(join(f.profileHome, name))).toBe(false) } } - }) - it('leaves a folder Claude has not signed in yet to Claude’s own onboarding', async () => { - const f = fixture() - f.json(join(f.userHome, '.claude.json'), { theme: 'dark', mcpServers: { a: {} } }) - // Claude writes its state file at the theme pick, before the sign-in. - f.json(join(f.profileHome, '.claude.json'), { theme: 'light' }) - expect((await provision(f)).surfaces['.claude.json']).toBe('absent') - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ theme: 'light' }) - f.json(join(f.profileHome, '.claude.json'), { theme: 'light', oauthAccount: LOGIN }) - expect((await provision(f)).surfaces['.claude.json']).toBe('merged') - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - theme: 'light', - oauthAccount: LOGIN, - mcpServers: { a: {} }, - hasCompletedOnboarding: true - }) - }) - it('still forces onboarding when the personal state is unreadable', async () => { - 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) - expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ - userID: 'p', - oauthAccount: LOGIN, - hasCompletedOnboarding: true - }) - 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() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) @@ -323,7 +292,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 +301,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 +313,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 +354,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..e2227f978911 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -1,11 +1,11 @@ -import { existsSync } from 'node:fs' +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 { 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 +16,141 @@ 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' + +/** + * Top-level default-home entries a profile never shares: Claude's per-folder daemon, jobs and + * live-process registry, the login and account-bound files, per-install throwaways, the two + * config files copied below, and history, which setup links the other way. + */ +const UNSHARED_ENTRIES: ReadonlySet = new Set([ + 'daemon', + 'daemon.json', + 'daemon.log', + 'daemon.lock', + 'daemon.status.json', + 'jobs', + 'state', + 'sessions', + '.credentials.json', + 'policy-limits.json', + 'remote-settings.json', + 'mcp-needs-auth-cache.json', + 'cache', + 'debug', + 'telemetry', + 'statsig', + 'backups', + 'stats-cache.json', + 'usage-data', + 'logs', + 'settings.json', + // Claude's legacy state file, which holds the login like .claude.json. + '.config.json', + 'history.jsonl', + ...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 `.claude`: with the user's own CLAUDE_CONFIG_DIR its state file, backups and locks sit here. + return !UNSHARED_ENTRIES.has(name) && !name.startsWith('.last-') && !name.startsWith('.claude') +} + +/** Install ids Claude writes before any sign-in; each folder keeps its own. */ +const INSTALL_STATE_KEYS: ReadonlySet = new Set([ + 'userID', + 'machineID', + 'firstStartTime', + 'firstStartVersion' ]) -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] - }) +/** State that belongs to the folder's login or install, never copied from the default home. */ +function isAccountBoundState(key: string): boolean { + return ( + key === 'oauthAccount' || + // Why: a Console API key login; copied, it would outrank the account's own login. + key === 'primaryApiKey' || + key.includes('Cache') || + key.startsWith('cached') || + key.startsWith('passes') || + // Why: Claude never refetches it; a copied value can also skip startup fetches. + key === 'claudeCodeFirstTokenDate' || + key === 'startupPrefetchedAt' || + INSTALL_STATE_KEYS.has(key) ) } -function mergeSettings( - source: string, - target: string, - ledger: ClaudeProfileLedger -): ClaudeProfileSurfaceOutcome { - if (lstatIfPresent(target)?.isSymbolicLink()) { - return 'user-owned' - } - const existing = readClaudeProfileObject(target) +/** 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() && + JSON.stringify(input.value) === JSON.stringify(current.value) + ) { + return 'unchanged' } - 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 outcome = copyClaudeProfileFile(source, target) + if (outcome === 'absent' && lstatIfPresent(target)?.isFile()) { + rmSync(target, { force: true }) + return 'synced' } - // 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 outcome } -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)) { + try { + // Why exclusive: Claude may create its own state file meanwhile; that one wins. + writeFileSync(target, `${JSON.stringify(Object.fromEntries(shared), null, 2)}\n`, { + flag: 'wx', + mode: 0o600 + }) + return 'synced' + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) { + throw error + } } + } + // 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] = 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 +163,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 +202,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 6b44015f7de4..f123b310c06b 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -96,7 +96,8 @@ 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 } @@ -219,7 +220,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() @@ -227,15 +228,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 () => { diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 120fb061d329..ec7971558ae3 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -39,6 +39,9 @@ import { } from './claude-account-folder' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +const REFRESH_WAIT_MS = 5_000 +const CLAUDE_VERSION_REUSE_MS = 5 * 60_000 + export type ClaudeProfileRouterSettings = Pick< GlobalSettings, | 'claudeManagedAccounts' @@ -55,6 +58,7 @@ export type ClaudeProfileRouterSettings = Pick< export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() + private version: { at: number; value: Promise } | undefined private env: NodeJS.ProcessEnv private readonly envReady: Promise constructor( @@ -66,6 +70,8 @@ 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') @@ -164,7 +170,7 @@ export class ClaudeProfileRouter { }) } - /** 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 selected = this.selectedProfile() // Why wait only without the account's own login: System default, and whether it covers the @@ -181,14 +187,20 @@ export class ClaudeProfileRouter { } 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() } @@ -241,10 +253,19 @@ export class ClaudeProfileRouter { return run } + /** Why remembered: every launch refreshes, and the hook plan needs only a recent version. */ + private claudeVersion(): Promise { + const now = Date.now() + if (!this.version || now - this.version.at > CLAUDE_VERSION_REUSE_MS) { + this.version = { at: now, value: probeClaudeCliVersion(resolveClaudeCommand()) } + } + return this.version.value + } + 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 this.claudeVersion() : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, @@ -277,6 +298,11 @@ export class ClaudeProfileRouter { } 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. 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 931e7373d90d..ddbbfa31d201 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -138,10 +138,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 +185,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 () => { diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 52cc1a669280..e5530233a6f0 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -122,7 +122,7 @@ export class ClaudeWslProfileRouter { }) } - /** Waits for a first setup that never finished, running or not; otherwise launches at once. */ + /** Waits for a first setup that never finished, running or not; otherwise refreshes alongside. */ async prepareLaunch(distro: string): Promise { const { home, selected, 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 @@ -132,7 +132,8 @@ export class ClaudeWslProfileRouter { console.warn('[claude-profile] WSL account setup failed:', error) throw claudeProfileSetupFailed() }) - } else if (selected && !profile) { + } else if (selected) { + // Why in the background: a guest refresh starts a guest Node, too slow for every launch. this.setUpInBackground(distro, home, selected.accountId) } // Why: a missing or stale guest pointer would run the pane's `claude` under another account. diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts index 35db45340b0c..50e64753af5e 100644 --- a/src/main/claude/claude-profile-hooks.test.ts +++ b/src/main/claude/claude-profile-hooks.test.ts @@ -111,10 +111,11 @@ 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('carries a default-home statusline opt-out and an old-Claude retire to the profile', async () => { 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` From 9e2355aeebb354b4224729b87617054d30b97e6a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 17:58:12 -0400 Subject: [PATCH 03/22] Route chat auth and unselected-account usage through the Claude router Chats decided whether to drop inherited Anthropic auth from the selection alone, so an account running on System default lost the user's own key. They now ask the router, as terminals, AI commit messages, automations and usage already do. Usage for an unselected account is read where its launches would run. A test reads every entry point through the fallback and fails if a launch path outside the router reads the Claude selection or builds an account folder. The router's two launch errors move to their own file to keep it under the line limit. --- .../claude-launch-routing.test.ts | 151 ++++++++++++++++++ .../claude-profile-launch-errors.ts | 18 +++ .../claude-accounts/claude-profile-router.ts | 41 +++-- .../claude-profile-wsl-router.ts | 8 +- .../claude-structured-auth-policy.ts | 6 + .../claude-managed-account-usage.ts | 9 +- 6 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 src/main/claude-accounts/claude-launch-routing.test.ts create mode 100644 src/main/claude-accounts/claude-profile-launch-errors.ts 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..ca2c8a62da21 --- /dev/null +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -0,0 +1,151 @@ +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 { 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 { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' + +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() + }), + chatStripsAuth: claudeStructuredAuthPolicyForSettings(f.settings).stripAuthEnv, + usage: f.router.preparation().configDir, + inactiveUsage: f.router.accountUsagePreparation('a').configDir + } +} + +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, + chatStripsAuth: false, + 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, + chatStripsAuth: true, + usage: f.accountHome, + inactiveUsage: f.accountHome + }) + }) + + // 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 mainRoot = join(__dirname, '..') + const routing = + /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|shouldStripClaudeAuthEnvForAccount)\b|\.accountHome\(/ + // Maps settings keys to the provider whose model catalog they expire; it routes nothing. + const allowed = new Set([ + 'native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts' + ]) + 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) + // 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([]) + }) +}) 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-router.ts b/src/main/claude-accounts/claude-profile-router.ts index ec7971558ae3..828178432727 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -4,16 +4,15 @@ 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 { 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, @@ -328,6 +327,31 @@ export class ClaudeProfileRouter { } } + /** Whether launches run in an account's folder, which owns their auth, rather than System default. */ + 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: {}, + stripAuthEnv: false, + provenance: 'system' + } + } + const home = this.accountHome(accountId) + return { + configDir: home, + envPatch: { CLAUDE_CONFIG_DIR: home }, + stripAuthEnv: true, + provenance: `profile:${accountId}` + } + } + /** Every account folder on this host, selected or not. */ accountHomes(): string[] { return listClaudeProfileHomes(this.args.dataRoot) @@ -345,16 +369,3 @@ function readFileIfPresent(file: string): string | null { return null } } - -// 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-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index e5530233a6f0..437008ddac49 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -14,12 +14,8 @@ import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' -import { - claudeProfileMissing, - claudeProfileSetupFailed, - sameClaudeEmail, - type ClaudeProfileRouterSettings -} from './claude-profile-router' +import { sameClaudeEmail, type ClaudeProfileRouterSettings } from './claude-profile-router' +import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' import { claudeStateLogin } from './claude-account-folder' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts index c30cd69b8276..f9ecb7ed46b7 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../shared/global-settings-types' +import { getClaudeProfileRouter } from './claude-profile-installed-router' import { shouldStripClaudeAuthEnvForAccount } from './environment' import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' @@ -28,6 +29,11 @@ export function claudeStructuredAuthPolicyForSettings( | 'activeClaudeManagedAccountIdsByRuntime' > ): ClaudeStructuredAuthPolicy { + // Why the router first: it decides whether the account or System default runs, as for terminals. + const router = getClaudeProfileRouter() + if (router) { + return { stripAuthEnv: router.routesToAccount() } + } return { stripAuthEnv: shouldStripClaudeAuthEnvForAccount( settings.claudeManagedAccounts, diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index b2e419f8bb0e..5b2008f90cf3 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -19,10 +19,15 @@ export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { + // Why the router: a covered account's usage is System default's, as its launches are. + const hostUsage = + account.managedAuthRuntime === 'wsl' + ? undefined + : getClaudeProfileRouter()?.accountUsagePreparation(account.id) const configDir = account.managedAuthRuntime === 'wsl' ? savedWslClaudeAccountHome(account) - : (getClaudeProfileRouter()?.accountHome(account.id) ?? null) + : (hostUsage?.configDir ?? null) if (!configDir || (account.managedAuthRuntime === 'wsl' && !account.wslDistro)) { return makeClaudeUsageResult('error', 'Sign in again to use this account.', { failureKind: 'missing-credentials', @@ -42,7 +47,7 @@ export async function fetchInactiveClaudeAccountUsage( } const read = fetchActiveClaudeRateLimits({ signal: options.signal, - authPreparation: { + authPreparation: hostUsage ?? { configDir: home, runtime: account.managedAuthRuntime, wslDistro: account.wslDistro, From 85aff427871f04fb799682364095042eefee7ab2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 18:04:05 -0400 Subject: [PATCH 04/22] Show Claude account sign-in prompts only when an account cannot run With the fallback, an account System default is signed in to already works, so: - The one-time "Finish setting up your Claude accounts" toast is gone. - Accounts System default covers no longer need a sign-in: no Sign in in the status bar menu or Settings, and they can be selected. - The old-terminal banner shows only when claude in that terminal would run a different account than the selected one, and asks again after a switch. Its wording is unchanged. The retired toast's saved flag stays in the paired-client schema so an older client's write is still accepted. The email comparison moves beside the login reader to keep the router under the line limit. --- .../claude-accounts/claude-account-folder.ts | 4 + .../claude-account-selection.ts | 20 ++-- .../claude-account-service.test.ts | 15 ++- .../claude-profile-router.test.ts | 14 +++ .../claude-accounts/claude-profile-router.ts | 31 +++-- .../claude-profile-wsl-router.ts | 4 +- .../ipc/pty/ipc/claude-old-terminal.test.ts | 38 +++++- src/main/ipc/pty/ipc/claude-old-terminal.ts | 14 ++- .../src/app-shell/use-app-shell-services.ts | 2 - .../terminal-pane/ClaudeOldTerminalBanner.tsx | 24 ++-- src/renderer/src/i18n/locales/en.json | 7 +- .../lib/claude-account-sign-in-notice.test.ts | 111 ------------------ .../src/lib/claude-account-sign-in-notice.ts | 63 ---------- .../src/lib/claude-account-sign-in.ts | 8 -- .../store/slices/ui-notice-dismissals.test.ts | 13 -- .../slices/ui/ui-slice-contract-contextual.ts | 2 - .../slices/ui/ui-slice-hydration-actions.ts | 1 - .../store/slices/ui/ui-slice-trust-actions.ts | 9 -- src/shared/persisted-ui-state-types.ts | 2 +- 19 files changed, 119 insertions(+), 263 deletions(-) delete mode 100644 src/renderer/src/lib/claude-account-sign-in-notice.test.ts delete mode 100644 src/renderer/src/lib/claude-account-sign-in-notice.ts diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts index 7e4bfe8e3efd..1de60e3641fa 100644 --- a/src/main/claude-accounts/claude-account-folder.ts +++ b/src/main/claude-accounts/claude-account-folder.ts @@ -48,6 +48,10 @@ const logins = new Map< { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } >() +export function sameClaudeEmail(left: string, right: string): boolean { + return left.trim().toLowerCase() === right.trim().toLowerCase() +} + /** 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) 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-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index f123b310c06b..5d05edc51c91 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -140,6 +140,20 @@ describe('ClaudeProfileRouter', () => { 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 }) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 828178432727..e41977674b7c 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -34,6 +34,7 @@ import { claudeStateFile, readClaudeFolderLogin, removeClaudeAccountFolder, + sameClaudeEmail, type ClaudeFolderLogin } from './claude-account-folder' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' @@ -156,7 +157,7 @@ export class ClaudeProfileRouter { } const home = this.routedProfile()?.home ?? '' // Why compare first: launches re-sync it, and most find it unchanged. - if (readFileIfPresent(this.pointerPath) !== home) { + if (!existsSync(this.pointerPath) || readFileSync(this.pointerPath, 'utf8') !== home) { mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) writeFileAtomically(this.pointerPath, home, { mode: 0o600 }) } @@ -327,6 +328,22 @@ export class ClaudeProfileRouter { } } + /** + * 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 email = + this.accountLogin(profile.accountId)?.email ?? + this.args.getSettings().claudeManagedAccounts.find((a) => a.id === profile.accountId)?.email + const systemDefault = this.systemDefaultLogin(5_000)?.email + return !email || !systemDefault || !sameClaudeEmail(email, systemDefault) + } + /** Whether launches run in an account's folder, which owns their auth, rather than System default. */ routesToAccount(): boolean { return this.routedProfile() !== null @@ -357,15 +374,3 @@ export class ClaudeProfileRouter { return listClaudeProfileHomes(this.args.dataRoot) } } - -export function sameClaudeEmail(left: string, right: string): boolean { - return left.trim().toLowerCase() === right.trim().toLowerCase() -} - -function readFileIfPresent(file: string): string | null { - try { - return readFileSync(file, 'utf8') - } catch { - return null - } -} diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 437008ddac49..41bfb927a1b4 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -14,9 +14,9 @@ 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 { sameClaudeEmail, type ClaudeProfileRouterSettings } from './claude-profile-router' +import type { ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' -import { claudeStateLogin } from './claude-account-folder' +import { claudeStateLogin, sameClaudeEmail } 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' 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 6d8a3e16e00a..962095fa8ebc 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,22 @@ import { beforeEach, expect, it, vi } from 'vitest' +import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' type Handler = (event: unknown, args: { id: unknown }) => Promise -const mocks = vi.hoisted(() => ({ - handlers: new Map(), - olderPtyIds: new Set(), - startup: vi.fn<() => Promise | undefined>() +type Mocks = { + handlers: Map + olderPtyIds: Set + startup: ReturnType Promise | undefined>> + runsAnotherAccount: boolean | null + router: boolean +} + +const mocks = vi.hoisted((): Mocks => ({ + handlers: new Map(), + olderPtyIds: new Set(), + startup: vi.fn<() => Promise | undefined>(), + runsAnotherAccount: true, + router: true })) vi.mock('../../pty-host-bindings', () => ({ getPtyIpc: () => ({ @@ -14,7 +25,11 @@ vi.mock('../../pty-host-bindings', () => ({ })) vi.mock('../../../daemon/daemon-provider-state', () => ({ isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => - protocolVersion === 42 && mocks.olderPtyIds.has(id) + protocolVersion === CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION && mocks.olderPtyIds.has(id) +})) +vi.mock('../../../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => + mocks.router ? { systemDefaultRunsAnotherAccount: () => mocks.runsAnotherAccount } : undefined })) import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' @@ -22,6 +37,8 @@ import { installPtyClaudeOldTerminalIpcHandler } from './claude-old-terminal' beforeEach(() => { mocks.handlers.clear() mocks.olderPtyIds = new Set(['old-1']) + mocks.runsAnotherAccount = true + mocks.router = true installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) }) @@ -34,3 +51,14 @@ it('answers true only for a pane owned by a daemon from before per-account Claud expect(await ask({}, { id: 42 })).toBe(false) expect(mocks.startup).toHaveBeenCalled() }) + +it("answers false when that pane's claude already runs the selected account", async () => { + const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! + mocks.runsAnotherAccount = false + expect(await ask({}, { id: 'old-1' })).toBe(false) + // With no host account selected (a WSL one), the router cannot tell; the pane still asks. + mocks.runsAnotherAccount = null + expect(await ask({}, { id: 'old-1' })).toBe(true) + mocks.router = false + expect(await ask({}, { id: 'old-1' })).toBe(true) +}) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts index 385800241cbd..91168c425d29 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -1,10 +1,11 @@ +import { getClaudeProfileRouter } 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' 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', @@ -14,9 +15,14 @@ export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { } // Why: the pre-swap provider does not own restored daemon ids. await deps.getLocalPtyProviderStartupPromise() - return isTerminalFromBeforeDaemonProtocol( - args.id, - CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION + // Why also the account: when System default is signed in to the selected one, this + // terminal's claude already runs it. + return ( + isTerminalFromBeforeDaemonProtocol( + args.id, + CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION + ) && + (getClaudeProfileRouter()?.systemDefaultRunsAnotherAccount() ?? true) ) } ) 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 9f833364ab15..bab4c09e1e81 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -22,7 +22,6 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' -import { useClaudeAccountSignInNotice } from '../lib/claude-account-sign-in-notice' import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting' import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh' import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice' @@ -65,5 +64,4 @@ export function useAppShellServices(): void { useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() useCodexSharedSettingsNotice() - useClaudeAccountSignInNotice() } diff --git a/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx b/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx index e49d63940de3..4b1471378583 100644 --- a/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx +++ b/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx @@ -13,24 +13,22 @@ 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 } -function useOpenedBeforeClaudeAccounts(ptyId: string, enabled: boolean): boolean { +function useOpenedBeforeClaudeAccounts(ptyId: string, selectionKey: string | null): boolean { const [openedBefore, setOpenedBefore] = useState(false) useEffect(() => { - if (!enabled) { + if (!selectionKey) { return } let cancelled = false @@ -46,7 +44,7 @@ function useOpenedBeforeClaudeAccounts(ptyId: string, enabled: boolean): boolean cancelled = true setOpenedBefore(false) } - }, [enabled, ptyId]) + }, [selectionKey, ptyId]) return openedBefore } @@ -62,7 +60,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 +69,7 @@ export function ClaudeOldTerminalBanner({ ) const openedBefore = useOpenedBeforeClaudeAccounts( ptyId, - accountSelected && claudeInPane && !dismissed + 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81cf2f502962..1b7b4d66d565 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19499,12 +19499,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 d8ea7fe00046..6ec88e1e4f36 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 @@ -118,6 +118,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 9b13b85a7142..9df5868597d3 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 @@ -245,7 +245,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 41f3cf4f35b0..1e1c25573a27 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 @@ -157,15 +157,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/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 12e27144908d..d5a19f125cc2 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -179,7 +179,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 From 8f216aca259d89d633f938faad83107692daee48 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:02:04 -0400 Subject: [PATCH 05/22] Keep the user's shell Anthropic auth on Claude account launches A signed-in account's launches used to drop ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN from the inherited environment while ANTHROPIC_BASE_URL passed through, so a proxy set in the shell got the subscription login and answered 401. Host launches on an account now keep the shell's auth with its address, as System default does. Chats still name the account kind in a failed sign-in, now from the router rather than from whether auth was dropped. --- .../claude-launch-routing.test.ts | 37 +++++++++++-- .../claude-profile-router.test.ts | 4 +- .../claude-accounts/claude-profile-router.ts | 7 +-- .../claude-structured-auth-policy.test.ts | 52 +++++++++---------- .../claude-structured-auth-policy.ts | 22 ++++---- src/main/claude-accounts/environment.ts | 10 +--- .../claude-structured-launch-resolution.ts | 2 +- 7 files changed, 79 insertions(+), 55 deletions(-) diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index ca2c8a62da21..e56c9acb04a2 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -3,6 +3,7 @@ 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 { claudeChildEnv } from '../claude/claude-structured-child-env' import { resolveClaudeStructuredLaunchHome } from '../claude/claude-structured-launch-home' import { resolveStructuredClaudeAccountHomePath } from '../runtime/structured-agent-account-home' import { @@ -11,6 +12,7 @@ import { } from './claude-profile-installed-router' import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' +import { applyClaudeEnvPatch } from './environment' const roots: string[] = [] afterEach(() => { @@ -78,7 +80,8 @@ async function launchHomes(f: ReturnType) { wslDistro: null, getClaudeConfigDirectory: () => f.router.systemDefaultHome() }), - chatStripsAuth: claudeStructuredAuthPolicyForSettings(f.settings).stripAuthEnv, + chatAuth: claudeStructuredAuthPolicyForSettings(f.settings), + terminalStripsAuth: f.router.preparation().stripAuthEnv, usage: f.router.preparation().configDir, inactiveUsage: f.router.accountUsagePreparation('a').configDir } @@ -93,7 +96,8 @@ describe('one router decision for every Claude launch', () => { chat: f.systemHome, chatEnv: undefined, chatRecord: f.systemHome, - chatStripsAuth: false, + chatAuth: { stripAuthEnv: false, account: 'system' }, + terminalStripsAuth: false, usage: f.systemHome, inactiveUsage: f.systemHome }) @@ -104,18 +108,43 @@ describe('one router decision for every Claude launch', () => { chat: f.accountHome, chatEnv: f.accountHome, chatRecord: f.accountHome, - chatStripsAuth: true, + // A shell proxy's key stays with its address on the account too. + chatAuth: { stripAuthEnv: false, account: 'managed' }, + terminalStripsAuth: false, usage: f.accountHome, inactiveUsage: f.accountHome }) }) + it("keeps a shell proxy's key with its address on a signed-in account's launches", async () => { + const f = coveredAccount() + signIn(f.accountHome, 'a@example.test') + const shell = { + PATH: '/usr/bin', + ANTHROPIC_BASE_URL: 'https://proxy.example.test', + ANTHROPIC_API_KEY: 'proxy-key', + ANTHROPIC_AUTH_TOKEN: 'proxy-token' + } + const prepared = await f.router.prepareLaunch() + // The env patch every launcher (terminal, commit message, split) applies to its own env. + const env = applyClaudeEnvPatch({ ...shell }, prepared.envPatch, { + stripAuthEnv: prepared.stripAuthEnv + }) + expect(env).toMatchObject({ ...shell, CLAUDE_CONFIG_DIR: f.accountHome }) + expect( + claudeChildEnv( + { command: 'claude', inheritedEnv: { ...shell }, overlay: undefined }, + claudeStructuredAuthPolicyForSettings(f.settings).stripAuthEnv + ) + ).toMatchObject(shell) + }) + // 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 mainRoot = join(__dirname, '..') const routing = - /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|shouldStripClaudeAuthEnvForAccount)\b|\.accountHome\(/ + /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|isHostManagedClaudeAccount)\b|\.accountHome\(/ // Maps settings keys to the provider whose model catalog they expire; it routes nothing. const allowed = new Set([ 'native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts' diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 5d05edc51c91..eaeead944de8 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -134,7 +134,7 @@ describe('ClaudeProfileRouter', () => { signIn(f.home('a'), 'a@example.test') await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('a'), - stripAuthEnv: true + stripAuthEnv: false }) expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) expect(f.router.coveredBySystemDefault('a')).toBe(false) @@ -337,7 +337,7 @@ describe('ClaudeProfileRouter', () => { mkdirSync(f.home('a'), { recursive: true }) expect(f.router.preparation()).toMatchObject({ configDir: f.home('a'), - stripAuthEnv: true, + stripAuthEnv: false, envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, CLAUDE_CONFIG_DIR: f.home('a'), diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index e41977674b7c..35e4c8f97378 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -323,7 +323,8 @@ export class ClaudeProfileRouter { wslDistro: null, wslLinuxConfigDir: null, envPatch: this.launchEnv(), - stripAuthEnv: home !== null, + // Why never: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, as on System default. + stripAuthEnv: false, provenance: home ? `profile:${profile?.accountId}` : 'system' } } @@ -344,7 +345,7 @@ export class ClaudeProfileRouter { return !email || !systemDefault || !sameClaudeEmail(email, systemDefault) } - /** Whether launches run in an account's folder, which owns their auth, rather than System default. */ + /** Whether launches run in an account's folder rather than System default's. */ routesToAccount(): boolean { return this.routedProfile() !== null } @@ -364,7 +365,7 @@ export class ClaudeProfileRouter { return { configDir: home, envPatch: { CLAUDE_CONFIG_DIR: home }, - stripAuthEnv: true, + stripAuthEnv: false, provenance: `profile:${accountId}` } } 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..a893483f4dd5 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.test.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts @@ -4,7 +4,7 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict, - shouldStripClaudeAuthEnvForAccount + isHostManagedClaudeAccount } from './environment' import { normalizeTuiAgentEnvRecord, @@ -33,34 +33,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 +68,22 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } }) ) - ).toEqual({ stripAuthEnv: true }) + ).toEqual({ stripAuthEnv: false, account: 'managed' }) }) - it('strips when a host account is pinned by runtime selection', () => { + it('names a host account pinned by runtime selection, keeping its shell auth', () => { expect( claudeStructuredAuthPolicyForSettings( settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } }) ) - ).toEqual({ stripAuthEnv: true }) + ).toEqual({ stripAuthEnv: false, 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({ + stripAuthEnv: false, + account: 'system' + }) }) it('ignores a WSL-only selection: the structured child is always a native host process', () => { @@ -95,7 +93,7 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } } }) ) - ).toEqual({ stripAuthEnv: false }) + ).toEqual({ stripAuthEnv: false, account: 'system' }) }) }) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts index f9ecb7ed46b7..766cbbb2abe8 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -1,12 +1,15 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { GlobalSettings } from '../../shared/global-settings-types' import { getClaudeProfileRouter } from './claude-profile-installed-router' -import { shouldStripClaudeAuthEnvForAccount } from './environment' +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. */ export type ClaudeStructuredAuthPolicy = { stripAuthEnv: boolean + /** Which login a failed sign-in names; absent, it follows `stripAuthEnv`. */ + account?: AgentSessionAccountKind } /** @@ -31,13 +34,12 @@ export function claudeStructuredAuthPolicyForSettings( ): ClaudeStructuredAuthPolicy { // Why the router first: it decides whether the account or System default runs, as for terminals. const router = getClaudeProfileRouter() - if (router) { - return { stripAuthEnv: router.routesToAccount() } - } - return { - stripAuthEnv: shouldStripClaudeAuthEnvForAccount( - settings.claudeManagedAccounts, - getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) - ) - } + const managed = router + ? router.routesToAccount() + : isHostManagedClaudeAccount( + settings.claudeManagedAccounts, + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + ) + // Why never strip: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, on every account. + return { stripAuthEnv: false, account: managed ? 'managed' : 'system' } } diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 918de5250f5e..4a43e04f8eef 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -66,14 +66,8 @@ export function applyClaudeEnvPatch( export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' -/** - * 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 { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 8914481044bd..840c2063b146 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -176,7 +176,7 @@ export async function resolveClaudeStructuredInvocation( return { command: sources.command, env: claudeChildEnv(sources, auth.stripAuthEnv, decorateEnv), - account: auth.stripAuthEnv ? 'managed' : 'system' + account: auth.account ?? (auth.stripAuthEnv ? 'managed' : 'system') } } From f359109dec3782014c2d6b12a56d47ee8d2dd355 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:19:57 -0400 Subject: [PATCH 06/22] Share from ~/.claude only what Claude itself would seed into a new folder The deny list missed much of Claude's runtime state, so tokens, locks and live files such as .session_ingress_token, server-sessions.json and remote-control were copied or linked over each account's own every launch. The list now mirrors the one Claude uses when it seeds a fresh config folder from ~/.claude: its runtime entries, every hidden entry, daemon files and agent memory. teams, ide, downloads and scratch are on it, so they stay per folder. --- .../claude-profile-provisioning.test.ts | 43 +++++--- .../claude-profile-provisioning.ts | 99 ++++++++++++++----- 2 files changed, 102 insertions(+), 40 deletions(-) diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 41e962f1dd7b..1f18b1677f66 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -226,33 +226,39 @@ describe('Claude profile refresh from the default home', () => { ) }) itLinks( - "shares every other top-level entry, never Claude's daemon, live sessions or login files", + "shares user content, never Claude's runtime state, live sessions or login files", async () => { const f = fixture() - const unshared = [ + const unsharedDirs = [ 'daemon', 'jobs', 'state', 'sessions', - 'cache', - 'debug', - 'telemetry', - 'statsig', - 'backups', - 'usage-data', - 'logs' + 'teams', + 'ide', + 'remote-control', + 'shares', + 'uploads', + 'storage-v2', + 'downloads', + 'scratch', + 'agent-memory-local', + '.hidden-dir' ] - for (const name of [...unshared, 'teams', 'ide', 'skills']) { + for (const name of [...unsharedDirs, 'skills', 'plugins', 'my-own-dir']) { fs.mkdirSync(join(f.source, name)) } const unsharedFiles = [ - 'daemon.json', - 'daemon.log', - 'daemon.lock', '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', - 'remote-settings.json', 'mcp-needs-auth-cache.json', 'stats-cache.json', '.last-update-check', @@ -263,15 +269,20 @@ describe('Claude profile refresh from the default home', () => { 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 ['teams', 'ide', 'skills']) { + 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 [...unshared, ...unsharedFiles]) { + 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('skips the state write while Claude holds its lock and records nothing for it', async () => { diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index e2227f978911..e427781568ae 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -21,41 +21,92 @@ import { copyClaudeProfileFile, linkClaudeProfileDirectory } from './claude-prof const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' /** - * Top-level default-home entries a profile never shares: Claude's per-folder daemon, jobs and - * live-process registry, the login and account-bound files, per-install throwaways, the two - * config files copied below, and history, which setup links the other way. + * 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 UNSHARED_ENTRIES: ReadonlySet = new Set([ +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', - 'daemon.json', - 'daemon.log', - 'daemon.lock', - 'daemon.status.json', + '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', - 'state', - 'sessions', - '.credentials.json', + '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', - 'mcp-needs-auth-cache.json', - 'cache', - 'debug', - 'telemetry', - 'statsig', - 'backups', + 'scratch', + 'seed-admin', + 'server-sessions.json', + 'server.lock', + 'session-env', + 'sessions', + 'shares', + 'shell-snapshots', + 'startup-perf', + 'state', 'stats-cache.json', - 'usage-data', - 'logs', + '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's legacy state file, which holds the login like .claude.json. - '.config.json', - 'history.jsonl', ...CLAUDE_PROFILE_HISTORY_DIRS ]) function isSharedEntry(name: string): boolean { - // Why `.claude`: with the user's own CLAUDE_CONFIG_DIR its state file, backups and locks sit here. - return !UNSHARED_ENTRIES.has(name) && !name.startsWith('.last-') && !name.startsWith('.claude') + // 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') + ) } /** Install ids Claude writes before any sign-in; each folder keeps its own. */ From 56f374ea36dbbd310010f95bc9daf1edf60a154f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:20:54 -0400 Subject: [PATCH 07/22] Merge trusted folders and MCP servers into an account instead of replacing them Each refresh replaced the account's projects and mcpServers with ~/.claude's, so folder trust and allowed tools recorded only in the account were lost, and a refresh landing between Orca's folder-trust write and Claude's start brought the trust dialog back. Both are now merged per folder path or server name: ~/.claude wins for entries it has, the account keeps the rest, and a folder trusted in the account stays trusted. Both writers take Claude's lock on the file, so the order no longer matters. --- .../claude-profile-provisioning.test.ts | 28 +++++++++++++++++++ .../claude-profile-provisioning.ts | 26 ++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 1f18b1677f66..9022ea83f331 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -285,6 +285,34 @@ describe('Claude profile refresh from the default home', () => { expect(fs.readFileSync(join(f.profileHome, 'server-sessions.json'), 'utf8')).toBe('mine') } ) + 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'), { + projects: { + '/shared': { hasTrustDialogAccepted: false, allowedTools: ['Read'] }, + '/default-only': { hasTrustDialogAccepted: true } + }, + mcpServers: { shared: { command: 'default' } } + }) + f.json(join(f.profileHome, '.claude.json'), { + oauthAccount: LOGIN, + projects: { + '/shared': { hasTrustDialogAccepted: true }, + '/account-only': { hasTrustDialogAccepted: true, allowedTools: ['Bash'] } + }, + mcpServers: { shared: { command: 'account' }, mine: { command: 'mine' } } + }) + await provision(f) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + oauthAccount: LOGIN, + projects: { + '/shared': { hasTrustDialogAccepted: true, allowedTools: ['Read'] }, + '/account-only': { hasTrustDialogAccepted: true, allowedTools: ['Bash'] }, + '/default-only': { hasTrustDialogAccepted: true } + }, + mcpServers: { shared: { command: 'default' }, mine: { command: 'mine' } } + }) + }) it('skips the state write while Claude holds its lock and records nothing for it', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index e427781568ae..78dce976866d 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -154,6 +154,30 @@ function copySettings(source: string, target: string): ClaudeProfileSurfaceOutco return outcome } +/** Keyed by folder path or server name: entries the account recorded on its own are kept. */ +const MERGED_BY_ENTRY: ReadonlySet = new Set(['projects', 'mcpServers']) + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function mergeEntries(key: string, current: unknown, source: unknown): unknown { + if (!isPlainRecord(current) || !isPlainRecord(source)) { + return source + } + const merged: Record = { ...current, ...source } + if (key === 'projects') { + for (const [path, entry] of Object.entries(source)) { + const own = current[path] + // Why: a folder trusted in the account, often by Orca just before launch, stays trusted. + if (isPlainRecord(own) && own.hasTrustDialogAccepted === true && isPlainRecord(entry)) { + merged[path] = { ...entry, hasTrustDialogAccepted: true } + } + } + } + return merged +} + async function copyState(source: string, target: string): Promise { if (lstatIfPresent(target)?.isSymbolicLink()) { return 'user-owned' @@ -184,7 +208,7 @@ async function copyState(source: string, target: string): Promise { const config = { ...current } for (const [key, value] of shared) { - config[key] = value + config[key] = MERGED_BY_ENTRY.has(key) ? mergeEntries(key, current[key], value) : value } return JSON.stringify(config) === JSON.stringify(current) ? { kind: 'unchanged' } From 40cc9cd1d56bea45c8151662a49f01ae1c2e16a1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:22:58 -0400 Subject: [PATCH 08/22] Require the same organization before System default stands in for an account The fallback compared emails only, so one email saved for two organizations made both accounts run on whichever organization System default was signed in to. When the saved account and System default's login both name an organization, it must now match too, on the host and in WSL, and the old-terminal banner uses the same comparison. The saved-account lookup the three checks repeated is now one helper. --- .../claude-accounts/claude-account-folder.ts | 14 ++++++++++-- .../claude-profile-router.test.ts | 20 +++++++++++++++-- .../claude-accounts/claude-profile-router.ts | 21 +++++++++--------- .../claude-profile-wsl-router.test.ts | 13 ++++++++++- .../claude-profile-wsl-router.ts | 22 ++++++++++++------- src/main/claude-accounts/runtime-selection.ts | 7 ++++++ 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/src/main/claude-accounts/claude-account-folder.ts b/src/main/claude-accounts/claude-account-folder.ts index 1de60e3641fa..e636b5aa97cc 100644 --- a/src/main/claude-accounts/claude-account-folder.ts +++ b/src/main/claude-accounts/claude-account-folder.ts @@ -48,8 +48,18 @@ const logins = new Map< { mtimeMs: number; size: number; readAt: number; login: ClaudeFolderLogin | null } >() -export function sameClaudeEmail(left: string, right: string): boolean { - return left.trim().toLowerCase() === right.trim().toLowerCase() +/** 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. */ diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index eaeead944de8..5af437310f99 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -103,11 +103,11 @@ function fixture(env: NodeJS.ProcessEnv = {}) { return { root, userHome, dataRoot, settings, router, home, setup } } -function signIn(stateDir: string, email: string): void { +function signIn(stateDir: string, email: string, organizationUuid?: string): void { mkdirSync(stateDir, { recursive: true }) writeFileSync( join(stateDir, '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: email } }) + JSON.stringify({ oauthAccount: { emailAddress: email, organizationUuid } }) ) } @@ -164,6 +164,22 @@ describe('ClaudeProfileRouter', () => { 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) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 35e4c8f97378..1fedcee05457 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -25,6 +25,7 @@ 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' @@ -34,7 +35,7 @@ import { claudeStateFile, readClaudeFolderLogin, removeClaudeAccountFolder, - sameClaudeEmail, + isSameClaudeLogin, type ClaudeFolderLogin } from './claude-account-folder' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' @@ -115,11 +116,9 @@ export class ClaudeProfileRouter { if (this.accountLogin(accountId)) { return false } - const email = this.args - .getSettings() - .claudeManagedAccounts.find((account) => account.id === accountId)?.email - const systemDefault = this.systemDefaultLogin()?.email - return Boolean(email && systemDefault && sameClaudeEmail(email, systemDefault)) + 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. */ @@ -338,11 +337,11 @@ export class ClaudeProfileRouter { if (!profile) { return null } - const email = - this.accountLogin(profile.accountId)?.email ?? - this.args.getSettings().claudeManagedAccounts.find((a) => a.id === profile.accountId)?.email - const systemDefault = this.systemDefaultLogin(5_000)?.email - return !email || !systemDefault || !sameClaudeEmail(email, systemDefault) + 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. */ 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 ddbbfa31d201..955df05a6783 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -290,8 +290,19 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { }) expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - // Another email in the guest's ~/.claude never stands in for the account. + // 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 diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 41bfb927a1b4..986317511c9b 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -16,10 +16,18 @@ 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 { claudeStateLogin, sameClaudeEmail } from './claude-account-folder' +import { + claudeStateLogin, + isSameClaudeLogin, + 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 @@ -75,11 +83,9 @@ export class ClaudeWslProfileRouter { if (!selected || (await guestLogin(distro, posix.join(selected.home, '.claude.json')))) { return selected } - const email = this.args - .getSettings() - .claudeManagedAccounts.find((account) => account.id === selected.accountId)?.email + const saved = findClaudeAccount(this.args.getSettings(), selected.accountId) const systemDefault = await guestLogin(distro, posix.join(home, '.claude.json')) - return email && systemDefault && sameClaudeEmail(email, systemDefault) ? null : selected + return saved && systemDefault && isSameClaudeLogin(saved, systemDefault) ? null : selected } private selectedProfile(home: string, distro: string): ClaudeProfileDescriptor | null { @@ -214,11 +220,11 @@ export class ClaudeWslProfileRouter { } /** The login a guest state file names, read over the share like the marker. */ -async function guestLogin(distro: string, stateFile: string): Promise { +async function guestLogin(distro: string, stateFile: string): Promise { try { const state: unknown = JSON.parse(await readFile(toWindowsWslPath(stateFile, distro), 'utf8')) return state && typeof state === 'object' && !Array.isArray(state) - ? (claudeStateLogin(Object.fromEntries(Object.entries(state)))?.email ?? null) + ? claudeStateLogin(Object.fromEntries(Object.entries(state))) : null } catch { return null 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) +} From 3a858d288b9c6cf6ad1b6cc723fc2069aaffe558 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:24:35 -0400 Subject: [PATCH 09/22] Treat v42 terminals as having the claude account function v42 daemons shipped the same claude function and pointer path as v44, so claude in those terminals already follows the selected account. The old-terminal banner now shows only for daemons before v42 and for v43, the revert, which dropped the function. --- src/main/daemon/daemon-protocol-version.ts | 3 ++- src/main/daemon/daemon-provider-state.ts | 8 ++++---- .../ipc/pty/ipc/claude-old-terminal.test.ts | 20 ++++++++++++------- src/main/ipc/pty/ipc/claude-old-terminal.ts | 20 +++++++++++++------ 4 files changed, 33 insertions(+), 18 deletions(-) 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 7599ee9cd03c..4ef1c313c486 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -88,14 +88,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/pty/ipc/claude-old-terminal.test.ts b/src/main/ipc/pty/ipc/claude-old-terminal.test.ts index 962095fa8ebc..a8030f058898 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,10 @@ import { beforeEach, expect, it, vi } from 'vitest' -import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION } from '../../../daemon/daemon-protocol-version' type Handler = (event: unknown, args: { id: unknown }) => Promise type Mocks = { handlers: Map - olderPtyIds: Set + legacyDaemons: Map> startup: ReturnType Promise | undefined>> runsAnotherAccount: boolean | null router: boolean @@ -13,7 +12,7 @@ type Mocks = { const mocks = vi.hoisted((): Mocks => ({ handlers: new Map(), - olderPtyIds: new Set(), + legacyDaemons: new Map(), startup: vi.fn<() => Promise | undefined>(), runsAnotherAccount: true, router: true @@ -24,8 +23,8 @@ vi.mock('../../pty-host-bindings', () => ({ }) })) vi.mock('../../../daemon/daemon-provider-state', () => ({ - isTerminalFromBeforeDaemonProtocol: (id: string, protocolVersion: number) => - protocolVersion === CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION && 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: () => @@ -36,15 +35,22 @@ 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.router = true installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) }) -it('answers true only for a pane owned by a daemon from before per-account Claude folders', async () => { +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: 'v43-1' })).toBe(true) + expect(await ask({}, { id: 'v42-1' })).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) diff --git a/src/main/ipc/pty/ipc/claude-old-terminal.ts b/src/main/ipc/pty/ipc/claude-old-terminal.ts index 91168c425d29..ae02516d9926 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -1,7 +1,18 @@ import { getClaudeProfileRouter } 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 } @@ -18,10 +29,7 @@ export function installPtyClaudeOldTerminalIpcHandler(deps: Deps): void { // Why also the account: when System default is signed in to the selected one, this // terminal's claude already runs it. return ( - isTerminalFromBeforeDaemonProtocol( - args.id, - CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION - ) && + isTerminalOnLegacyDaemon(args.id, lacksClaudeFunction) && (getClaudeProfileRouter()?.systemDefaultRunsAnotherAccount() ?? true) ) } From d17034cbd087e542da08529137f688566c34daa0 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:25:58 -0400 Subject: [PATCH 10/22] Fail the routing test when a new module resolves the claude binary The census only caught code that read the Claude selection, not a new launcher that runs claude without asking the router. A second check now lists every module that resolves the claude binary or loads the Agent SDK, each with why it is routed, and fails on any new one, or on a listed one that no longer matches. --- .../claude-launch-routing.test.ts | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index e56c9acb04a2..524ddb0d3626 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -87,6 +87,27 @@ async function launchHomes(f: ReturnType) { } } +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() @@ -142,28 +163,13 @@ describe('one router decision for every Claude launch', () => { // 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 mainRoot = join(__dirname, '..') const routing = /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|isHostManagedClaudeAccount)\b|\.accountHome\(/ // Maps settings keys to the provider whose model catalog they expire; it routes nothing. const allowed = new Set([ 'native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts' ]) - 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) + 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( @@ -177,4 +183,28 @@ describe('one router decision for every Claude launch', () => { ) 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-wiring.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([]) + }) }) From 60cafb3dc64c35c93cc76ad1c8bba9fef7d1c989 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:27:29 -0400 Subject: [PATCH 11/22] Skip the account refresh on launches that run on System default A launch that falls back to System default never uses the account folder, so starting a setup for it on every launch was wasted work. Account switches still set the folder up for a later sign-in. --- src/main/claude-accounts/claude-profile-router.test.ts | 4 ++-- src/main/claude-accounts/claude-profile-router.ts | 3 --- src/main/claude-accounts/claude-profile-wsl-router.ts | 6 +++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 5af437310f99..0c3cbe6e66dd 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -187,8 +187,8 @@ describe('ClaudeProfileRouter', () => { 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 need not exist yet; it is set up in the background. - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + // 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 () => { diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 1fedcee05457..1bfb72797b0d 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -181,9 +181,6 @@ export class ClaudeProfileRouter { this.writePointer() const profile = this.routedProfile() if (!profile) { - if (selected) { - this.setUpInBackground(selected) - } return this.preparation() } // Why the marker: setup writes it last, so a missing folder is set up too. diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 986317511c9b..152006358917 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -126,7 +126,7 @@ export class ClaudeWslProfileRouter { /** Waits for a first setup that never finished, running or not; otherwise refreshes alongside. */ async prepareLaunch(distro: string): Promise { - const { home, selected, profile } = await this.resolve(distro) + const { home, profile } = await this.resolve(distro) // Why the marker: setup writes it last, so a missing folder is set up too. A re-run of a // set-up folder never blocks. if (profile && !(await hasMarker(distro, profile))) { @@ -134,9 +134,9 @@ export class ClaudeWslProfileRouter { console.warn('[claude-profile] WSL account setup failed:', error) throw claudeProfileSetupFailed() }) - } else if (selected) { + } else if (profile) { // Why in the background: a guest refresh starts a guest Node, too slow for every launch. - this.setUpInBackground(distro, home, selected.accountId) + 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. From d82f7f2481bfd4ea27bea68558877f9c88c310fa Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:30:12 -0400 Subject: [PATCH 12/22] Give a terminal opened before the login shell's env only the account pointer At startup, a pane could be routed before the login shell reported the user's own CLAUDE_CONFIG_DIR, so the fallback compared against ~/.claude.json instead. Until that env arrives a pane gets only the pointer, and the pointer is rewritten once it does, so claude typed there reads the right folder. The recent claude --version answer moves beside the version probe to keep the router under the line limit. --- .../claude-profile-router.test.ts | 15 +++++++++ .../claude-accounts/claude-profile-router.ts | 33 +++++++++++-------- src/main/claude/claude-hook-event-versions.ts | 14 ++++++++ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 0c3cbe6e66dd..a7eb62e1c8bb 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -429,6 +429,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 1bfb72797b0d..e839f89d050f 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -10,7 +10,7 @@ import { } 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 { @@ -41,7 +41,6 @@ import { import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' const REFRESH_WAIT_MS = 5_000 -const CLAUDE_VERSION_REUSE_MS = 5 * 60_000 export type ClaudeProfileRouterSettings = Pick< GlobalSettings, @@ -59,9 +58,9 @@ export type ClaudeProfileRouterSettings = Pick< export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() - private version: { at: number; value: Promise } | undefined private env: NodeJS.ProcessEnv private readonly envReady: Promise + private envResolved: boolean constructor( private readonly args: { getSettings: () => ClaudeProfileRouterSettings @@ -78,9 +77,19 @@ export class ClaudeProfileRouter { 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 { @@ -249,19 +258,10 @@ export class ClaudeProfileRouter { return run } - /** Why remembered: every launch refreshes, and the hook plan needs only a recent version. */ - private claudeVersion(): Promise { - const now = Date.now() - if (!this.version || now - this.version.at > CLAUDE_VERSION_REUSE_MS) { - this.version = { at: now, value: probeClaudeCliVersion(resolveClaudeCommand()) } - } - return this.version.value - } - private async runSetup(profile: ClaudeProfileDescriptor): Promise { await this.envReady const hooks = isAgentStatusHooksEnabledForAgent(this.args.getSettings(), 'claude') - const claudeVersion = hooks ? await this.claudeVersion() : null + const claudeVersion = hooks ? await probeRecentClaudeCliVersion(resolveClaudeCommand()) : null const report = await (this.args.runSetup ?? runClaudeProfileSetupInWorker)({ dataRoot: this.args.dataRoot, profile, @@ -292,6 +292,11 @@ 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. 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 +} From 678e719576983e4fe38a49b094a4eebd870b21b4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:35:41 -0400 Subject: [PATCH 13/22] Drop the chat-only Anthropic auth stripping and override refusal Chats no longer drop the shell's Anthropic auth on any account, so the chat launch's override refusal and the policy's stripAuthEnv could only be reached from test fixtures. The structured auth policy is now just which login a failed sign-in names. Terminals keep their refusal, which still guards WSL launches; the failure reason stays in the shared vocabulary for older peers. --- .../claude-launch-routing.test.ts | 18 ++-- .../claude-structured-auth-policy.test.ts | 9 +- .../claude-structured-auth-policy.ts | 11 +-- .../claude-agent-sdk-contract-pins.test.ts | 2 +- .../claude/claude-model-catalog-probe.test.ts | 6 +- .../claude-structured-auth-parity.test.ts | 84 ++++++------------- ...-structured-launch-account-routing.test.ts | 4 +- ...laude-structured-launch-resolution.test.ts | 79 ++++++----------- .../claude-structured-launch-resolution.ts | 25 ++---- .../claude-structured-launch-visuals.test.ts | 2 +- ...claude-structured-real-cli-visuals.test.ts | 2 +- ...laude-structured-turn-resume-point.test.ts | 2 +- ...native-chat-inline-visuals-setting.test.ts | 2 +- .../agent-session-record-store-slot.test.ts | 2 +- ...ude-structured-session-integration.test.ts | 39 +-------- ...tructured-agent-launch-args-wiring.test.ts | 2 +- ...gent-session-codex-interrupt-order.test.ts | 2 +- ...nt-session-codex-opening-send-hold.test.ts | 2 +- ...ent-session-codex-settlement-order.test.ts | 2 +- ...t-session-codex-stopped-send-order.test.ts | 2 +- ...-session-codex-turn-end-settlement.test.ts | 2 +- ...ured-agent-session-failure-logging.test.ts | 4 +- ...ructured-agent-session-held-signal.test.ts | 2 +- ...uctured-agent-session-host-refusal.test.ts | 2 +- ...ured-agent-session-install-failure.test.ts | 2 +- ...ructured-agent-session-integration.test.ts | 2 +- ...uctured-agent-session-runtime-exit.test.ts | 10 +-- .../structured-agent-session-runtime.test.ts | 4 +- ...nt-session-spawn-token-descendants.test.ts | 2 +- ...ed-agent-session-startup-reconcile.test.ts | 2 +- ...-agent-session-startup-tab-restore.test.ts | 2 +- ...-chat-coordinator-mail-rig.test-fixture.ts | 2 +- .../structured-child-records-switch.test.ts | 2 +- ...structured-claude-attachment-grant.test.ts | 2 +- ...ructured-claude-auth-policy-wiring.test.ts | 7 +- .../structured-claude-pending-rewind.test.ts | 2 +- ...ed-claude-scripted-runtime-test-support.ts | 2 +- ...tructured-codex-child-work-runtime.test.ts | 2 +- ...ructured-codex-session-rpc-test-harness.ts | 2 +- 39 files changed, 120 insertions(+), 232 deletions(-) diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index 524ddb0d3626..632fc506fa3f 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -3,7 +3,7 @@ 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 { claudeChildEnv } from '../claude/claude-structured-child-env' +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 { @@ -117,7 +117,7 @@ describe('one router decision for every Claude launch', () => { chat: f.systemHome, chatEnv: undefined, chatRecord: f.systemHome, - chatAuth: { stripAuthEnv: false, account: 'system' }, + chatAuth: { account: 'system' }, terminalStripsAuth: false, usage: f.systemHome, inactiveUsage: f.systemHome @@ -130,7 +130,7 @@ describe('one router decision for every Claude launch', () => { chatEnv: f.accountHome, chatRecord: f.accountHome, // A shell proxy's key stays with its address on the account too. - chatAuth: { stripAuthEnv: false, account: 'managed' }, + chatAuth: { account: 'managed' }, terminalStripsAuth: false, usage: f.accountHome, inactiveUsage: f.accountHome @@ -152,12 +152,12 @@ describe('one router decision for every Claude launch', () => { stripAuthEnv: prepared.stripAuthEnv }) expect(env).toMatchObject({ ...shell, CLAUDE_CONFIG_DIR: f.accountHome }) - expect( - claudeChildEnv( - { command: 'claude', inheritedEnv: { ...shell }, overlay: undefined }, - claudeStructuredAuthPolicyForSettings(f.settings).stripAuthEnv - ) - ).toMatchObject(shell) + const chat = await resolveClaudeStructuredInvocation({ + resolveCommand: () => 'claude', + resolveInheritedEnv: async () => ({ ...shell }), + resolveAuthPolicy: () => claudeStructuredAuthPolicyForSettings(f.settings) + }) + expect(chat).toMatchObject({ env: shell, account: 'managed' }) }) // Terminals, chats, AI commit messages and automations launch through 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 a893483f4dd5..cb40eb200b8e 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.test.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts @@ -68,20 +68,19 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } }) ) - ).toEqual({ stripAuthEnv: false, account: 'managed' }) + ).toEqual({ account: 'managed' }) }) - it('names a host account pinned by runtime selection, keeping its shell auth', () => { + it('names a host account pinned by runtime selection', () => { expect( claudeStructuredAuthPolicyForSettings( settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } }) ) - ).toEqual({ stripAuthEnv: false, account: 'managed' }) + ).toEqual({ account: 'managed' }) }) it('names System default when no account is selected', () => { expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ - stripAuthEnv: false, account: 'system' }) }) @@ -93,7 +92,7 @@ describe('claudeStructuredAuthPolicyForSettings', () => { activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } } }) ) - ).toEqual({ stripAuthEnv: false, account: 'system' }) + ).toEqual({ account: 'system' }) }) }) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts index 766cbbb2abe8..38ed700bc1fa 100644 --- a/src/main/claude-accounts/claude-structured-auth-policy.ts +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -4,12 +4,10 @@ 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 - /** Which login a failed sign-in names; absent, it follows `stripAuthEnv`. */ - account?: AgentSessionAccountKind + account: AgentSessionAccountKind } /** @@ -40,6 +38,5 @@ export function claudeStructuredAuthPolicyForSettings( settings.claudeManagedAccounts, getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) ) - // Why never strip: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, on every account. - return { stripAuthEnv: false, account: managed ? 'managed' : 'system' } + return { account: managed ? 'managed' : 'system' } } 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-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index b03ce52f3b81..30b95b855181 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 { type DiscoverInput = Parameters[0] type DiscoverResult = Awaited> -const AUTH_POLICY = { stripAuthEnv: false } as const +const AUTH_POLICY = { account: 'system' } as const function probeDeps(command = `"${process.execPath}"`): { resolveCommand: () => string @@ -228,7 +228,9 @@ describe('Claude catalog fake-child contract', () => { const probe = createClaudeModelCatalogProbe({ ...probeDeps(), resolveEnv: () => ({}), - resolveAuthPolicy: () => ({ stripAuthEnv: managed }), + resolveAuthPolicy: () => ({ + account: managed ? ('managed' as const) : ('system' as const) + }), spawnAgent }) const result = await probe('/homes/a') 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-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 19067d65a4c5..40c9fde13a12 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 ?? []), @@ -215,7 +215,7 @@ describe('claude structured launch resolution', () => { ] as AgentSessionRecord['providerHandleChain'] }), undefined, - false, + 'system', { claude: '' }, hasTranscript )({ identity: identityAt(null) }) @@ -242,7 +242,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, @@ -256,7 +258,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, @@ -268,7 +270,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 }) @@ -306,7 +308,7 @@ describe('claude structured launch resolution', () => { const resolve = resolverFor( record({ ...RESUMABLE, launchArgs: ['--model', 'stale'] }), undefined, - false, + 'system', { claude: '' }, async () => true, () => args @@ -362,45 +364,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, @@ -415,7 +383,7 @@ describe('claude structured launch resolution', () => { const launch = await resolverFor( record(), undefined, - false, + 'system', { claude: '' }, async () => true, () => ['--add-dir', '/extra'], @@ -434,7 +402,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 }) @@ -447,7 +415,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', @@ -466,7 +434,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 }) @@ -474,17 +442,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 () => { @@ -518,7 +493,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' @@ -563,7 +538,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 840c2063b146..a2f6add038f9 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -12,12 +12,7 @@ import { 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, @@ -122,12 +117,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 @@ -166,17 +157,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.account ?? (auth.stripAuthEnv ? 'managed' : 'system') + env: claudeChildEnv(sources, false, 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/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/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 7dc6629a566e..61c428ca0f0c 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/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-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 9825a2b7aee6..dd0d4fe48736 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -308,7 +308,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) @@ -325,7 +325,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 92510aa3241a..04da087d0767 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.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 0b3b8d9b307e..21b59ad75ba3 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 6a9ac88504d7..229090cb1f26 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 @@ -256,7 +256,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 9e9eddb7f855..0f9937377b22 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 4362c12fec76..2f46dce5245d 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 77e66d31d639..21ed5bbeff7a 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -236,7 +236,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', From dcce31c5b1b4fcb41283c41c0449ce94cdbe98a9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:37:21 -0400 Subject: [PATCH 14/22] Publish a new account state file whole The first .claude.json write went straight to the target, so a crash mid-write could leave Claude a truncated state file. It is now written to a staged file and linked into place without replacing one Claude created meanwhile. --- .../claude-credential-persistence-ratchet.test.ts | 6 +++++- .../claude-profile-provisioning.test.ts | 2 ++ .../claude-profile-provisioning.ts | 15 +++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) 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 1c8211b6d846..f462c710c5df 100644 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -52,7 +52,11 @@ const fsAllowances: Record = { 'writeFileSync', 'writeFileAtomically' ], - 'claude-accounts/claude-profile-provisioning.ts': ['rmSync', 'writeFileSync'], + '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-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 9022ea83f331..7ce70d3534aa 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -199,6 +199,8 @@ describe('Claude profile refresh from the default home', () => { // 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') diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 78dce976866d..f53a785990d1 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' @@ -5,6 +6,7 @@ import { resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' +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' @@ -191,17 +193,18 @@ async function copyState(source: string, target: string): Promise !isAccountBoundState(key)) if (!lstatIfPresent(target)) { + const staged = `${target}.${process.pid}.${randomUUID()}.tmp` try { - // Why exclusive: Claude may create its own state file meanwhile; that one wins. - writeFileSync(target, `${JSON.stringify(Object.fromEntries(shared), null, 2)}\n`, { + writeFileSync(staged, `${JSON.stringify(Object.fromEntries(shared), null, 2)}\n`, { flag: 'wx', mode: 0o600 }) - return 'synced' - } catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) { - throw error + // 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. From 64c87fcca6b5e8931bd8629f49ee7fce442c0a66 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 20:39:44 -0400 Subject: [PATCH 15/22] Read an unselected WSL account's usage where the WSL router would run it Usage for an unselected WSL account read its saved folder directly, so an account the guest's ~/.claude covers showed no usage, and the read bypassed the router. It now asks the WSL router, after the same check that the distro is already running. --- .../claude-profile-installed-router.ts | 12 ++++ .../claude-profile-wsl-router.ts | 10 +++ .../runtime-auth-service.test.ts | 5 +- .../claude-accounts/runtime-auth-service.ts | 6 +- .../claude-managed-account-usage.ts | 67 +++++++++---------- .../rate-limits/claude-profile-usage.test.ts | 41 +++++++----- 6 files changed, 88 insertions(+), 53 deletions(-) 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-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 152006358917..fea948caed59 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -147,6 +147,16 @@ export class ClaudeWslProfileRouter { return this.preparationFor(distro, home, profile) } + /** 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) 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..8fb6b18120c7 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) }) diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index 5b2008f90cf3..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,47 +21,33 @@ export async function fetchInactiveClaudeAccountUsage( account: InactiveClaudeAccount, options: ClaudeManagedAccountUsageOptions = {} ): Promise { - // Why the router: a covered account's usage is System default's, as its launches are. - const hostUsage = - account.managedAuthRuntime === 'wsl' - ? undefined - : getClaudeProfileRouter()?.accountUsagePreparation(account.id) - const configDir = - account.managedAuthRuntime === 'wsl' - ? savedWslClaudeAccountHome(account) - : (hostUsage?.configDir ?? 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: hostUsage ?? { - 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) => @@ -68,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..104c7bdc1d00 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(() => ({ @@ -241,31 +243,40 @@ 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: {}, + stripAuthEnv: true, + 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() }) From 3ecd16b35622942792bbf178fa197899c8bfc697 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 21:16:10 -0400 Subject: [PATCH 16/22] Keep the shell's Anthropic auth when the claude function runs an account The claude shell function (POSIX, fish and PowerShell) unset ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK and auth-like ANTHROPIC_CUSTOM_HEADERS before running Claude in an account folder, so a proxy set in the shell lost its key while keeping its address. It now only sets CLAUDE_CONFIG_DIR and Orca's marker, as on System default. Protocol 44 is unreleased, so no new bump; the shell-wrapper snapshots are regenerated. --- .../daemon-bash-rcfile.txt | 2 +- .../daemon-fish-init.txt | 6 +-- .../daemon-zsh-zshenv.txt | 2 +- .../local-bash-rcfile.txt | 2 +- .../local-fish-init.txt | 6 +-- .../local-zsh-zshenv.txt | 2 +- .../relay-zsh-zshenv.txt | 2 +- src/shared/claude-shell-function.test.ts | 40 +++++++++++++++---- src/shared/claude-shell-function.ts | 21 ++-------- 9 files changed, 43 insertions(+), 40 deletions(-) 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/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 } From ff11abc1826a503cada10fdbd5c956790039b9db Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 01:22:52 -0400 Subject: [PATCH 17/22] Never drop the user's Anthropic auth from a Claude launch Orca's launches stripped ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK and auth-like ANTHROPIC_CUSTOM_HEADERS for WSL terminals, terminal splits and through the preparation's stripAuthEnv flag, and refused a launch whose agent env set them. If the user's shell overrides the login, that is their setup: every launch now keeps it, on accounts, System default and WSL alike. The flag, the strip paths and the override refusal are gone; the refusal's reason stays in the shared vocabulary so older peers still decode it. Orca's own process env and the claude --version probe still drop these variables: they are Orca's, not the user's launch. --- src/main/agent-workspace-trust.test.ts | 1 - .../claude-launch-routing.test.ts | 72 ++++++++++++------- .../claude-profile-router.test.ts | 6 +- .../claude-accounts/claude-profile-router.ts | 4 -- .../claude-profile-wsl-router.test.ts | 13 ++++ .../claude-profile-wsl-router.ts | 1 - .../claude-structured-auth-policy.test.ts | 70 +----------------- src/main/claude-accounts/environment.ts | 38 ---------- .../claude-accounts/runtime-auth-service.ts | 1 - .../runtime-auth/runtime-auth-types.ts | 1 - .../claude/claude-folder-trust-file.test.ts | 1 - .../claude/claude-structured-child-env.ts | 14 ++-- .../claude-structured-launch-resolution.ts | 2 +- ...lesystem-commit-message-generation.test.ts | 6 +- ...tem-commit-message-model-discovery.test.ts | 1 - .../ipc/pty-pane-materialization-race.test.ts | 3 - src/main/ipc/pty/ipc/spawn-env.ts | 7 -- src/main/ipc/pty/ipc/spawn-options.ts | 5 -- src/main/ipc/pty/runtime/spawn-options.ts | 5 -- src/main/ipc/pty/runtime/spawn-preflight.ts | 7 -- .../rate-limits/claude-profile-usage.test.ts | 6 +- .../service-account-target-selection.test.ts | 7 +- .../service-inactive-account-previews.test.ts | 2 - .../service-live-claude-usage.test.ts | 3 - ...resolve-terminal-split-source-authority.ts | 8 +-- .../commit-message-agent-environment.ts | 4 +- 26 files changed, 78 insertions(+), 210 deletions(-) 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-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index 632fc506fa3f..45a737894f83 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -11,6 +11,7 @@ import { 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' @@ -81,7 +82,6 @@ async function launchHomes(f: ReturnType) { getClaudeConfigDirectory: () => f.router.systemDefaultHome() }), chatAuth: claudeStructuredAuthPolicyForSettings(f.settings), - terminalStripsAuth: f.router.preparation().stripAuthEnv, usage: f.router.preparation().configDir, inactiveUsage: f.router.accountUsagePreparation('a').configDir } @@ -118,7 +118,6 @@ describe('one router decision for every Claude launch', () => { chatEnv: undefined, chatRecord: f.systemHome, chatAuth: { account: 'system' }, - terminalStripsAuth: false, usage: f.systemHome, inactiveUsage: f.systemHome }) @@ -131,34 +130,59 @@ describe('one router decision for every Claude launch', () => { chatRecord: f.accountHome, // A shell proxy's key stays with its address on the account too. chatAuth: { account: 'managed' }, - terminalStripsAuth: false, usage: f.accountHome, inactiveUsage: f.accountHome }) }) - it("keeps a shell proxy's key with its address on a signed-in account's launches", async () => { - const f = coveredAccount() - signIn(f.accountHome, 'a@example.test') - const shell = { - PATH: '/usr/bin', - ANTHROPIC_BASE_URL: 'https://proxy.example.test', - ANTHROPIC_API_KEY: 'proxy-key', - ANTHROPIC_AUTH_TOKEN: 'proxy-token' + // 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 + } + } + } } - const prepared = await f.router.prepareLaunch() - // The env patch every launcher (terminal, commit message, split) applies to its own env. - const env = applyClaudeEnvPatch({ ...shell }, prepared.envPatch, { - stripAuthEnv: prepared.stripAuthEnv - }) - expect(env).toMatchObject({ ...shell, CLAUDE_CONFIG_DIR: f.accountHome }) - const chat = await resolveClaudeStructuredInvocation({ - resolveCommand: () => 'claude', - resolveInheritedEnv: async () => ({ ...shell }), - resolveAuthPolicy: () => claudeStructuredAuthPolicyForSettings(f.settings) - }) - expect(chat).toMatchObject({ env: shell, account: 'managed' }) - }) + ) // Terminals, chats, AI commit messages and automations launch through // ClaudeRuntimeAuthService.prepareForClaudeLaunch; usage through prepareForRateLimitFetch. diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index a7eb62e1c8bb..f65069ebe7a6 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -120,7 +120,6 @@ describe('ClaudeProfileRouter', () => { signIn(f.userHome, 'A@Example.test') await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: join(f.userHome, '.claude'), - stripAuthEnv: false, provenance: 'system' }) expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') @@ -133,8 +132,7 @@ describe('ClaudeProfileRouter', () => { // 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'), - stripAuthEnv: false + configDir: f.home('a') }) expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('a')) expect(f.router.coveredBySystemDefault('a')).toBe(false) @@ -353,7 +351,6 @@ describe('ClaudeProfileRouter', () => { mkdirSync(f.home('a'), { recursive: true }) expect(f.router.preparation()).toMatchObject({ configDir: f.home('a'), - stripAuthEnv: false, envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, CLAUDE_CONFIG_DIR: f.home('a'), @@ -363,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') diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index e839f89d050f..3a7d3f81f42d 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -324,8 +324,6 @@ export class ClaudeProfileRouter { wslDistro: null, wslLinuxConfigDir: null, envPatch: this.launchEnv(), - // Why never: a shell proxy's key must travel with its ANTHROPIC_BASE_URL, as on System default. - stripAuthEnv: false, provenance: home ? `profile:${profile?.accountId}` : 'system' } } @@ -358,7 +356,6 @@ export class ClaudeProfileRouter { return { configDir: this.systemDefaultHome(), envPatch: {}, - stripAuthEnv: false, provenance: 'system' } } @@ -366,7 +363,6 @@ export class ClaudeProfileRouter { return { configDir: home, envPatch: { CLAUDE_CONFIG_DIR: home }, - stripAuthEnv: false, provenance: `profile:${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 955df05a6783..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. @@ -309,6 +310,18 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { }) }) + 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 fea948caed59..8acb23c004cb 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -210,7 +210,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` } } 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 cb40eb200b8e..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, - isHostManagedClaudeAccount -} 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 @@ -96,10 +88,9 @@ describe('claudeStructuredAuthPolicyForSettings', () => { }) }) -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', @@ -108,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/environment.ts b/src/main/claude-accounts/environment.ts index 4a43e04f8eef..11059a0f7a32 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -61,11 +61,6 @@ 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 the selected account is a host-managed one; an id no account explains counts as one. */ export function isHostManagedClaudeAccount( accounts: readonly ClaudeManagedAccount[] | undefined, @@ -79,39 +74,6 @@ export function isHostManagedClaudeAccount( ) } -/** - * 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.ts b/src/main/claude-accounts/runtime-auth-service.ts index 8fb6b18120c7..66659ddb53fc 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -54,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/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-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-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2f6add038f9..18c695185a86 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -160,7 +160,7 @@ export async function resolveClaudeStructuredInvocation( // 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, false, decorateEnv), + env: claudeChildEnv(sources, decorateEnv), account: auth.account } } 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/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/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts index 104c7bdc1d00..a87ddf4c9299 100644 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -53,7 +53,6 @@ function profile() { authPreparation: { configDir: home, envPatch: { CLAUDE_CONFIG_DIR: home }, - stripAuthEnv: true, provenance: 'profile:fake' } } @@ -66,7 +65,7 @@ function systemDefault() { return { home, options: { - authPreparation: { configDir: home, envPatch: {}, stripAuthEnv: false, provenance: 'system' } + authPreparation: { configDir: home, envPatch: {}, provenance: 'system' } } } } @@ -200,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' }) ) @@ -223,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') @@ -250,7 +247,6 @@ it('reads an inactive WSL account where the WSL router says its launches would r runtime: 'wsl' as const, wslDistro: 'Ubuntu', envPatch: {}, - stripAuthEnv: true, provenance: 'wsl:Ubuntu:system' } wslRouter.accountUsagePreparation.mockResolvedValueOnce(authPreparation) 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/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/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) { From fcc23163b36ef12e1dc01c3e7903e2b2e6fa6a44 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 02:03:53 -0400 Subject: [PATCH 18/22] Leave out of an account's state everything Claude resets when it signs out The .claude.json copy left out the login and keys matching cache patterns, but still carried account state such as additionalModelOptionsAnsweredAt, artifactRosterDenied, lastSeenOrgDefaultUpdatedAt and the subscription notices. The copy now leaves out the exact keys Claude resets on logout, beside the install ids, Console API key and first-token date; the cache patterns stay for account caches logout does not reset. Onboarding is still copied. --- .../claude-profile-provisioning.test.ts | 7 ++- .../claude-profile-provisioning.ts | 49 ++++++++++++++----- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 7ce70d3534aa..30da554f29cc 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -194,7 +194,12 @@ describe('Claude profile refresh from the default home', () => { passesEligibilityCache: {}, passesLastSeen: 1, claudeCodeFirstTokenDate: 'source-date', - startupPrefetchedAt: 1 + startupPrefetchedAt: 1, + additionalModelOptionsAnsweredAt: 1, + artifactRosterDenied: true, + lastSeenOrgDefaultUpdatedAt: 1, + hasAvailableSubscription: true, + subscriptionNoticeCount: 2 }) // A folder with no state file yet gets one without a login, so Claude asks for its own. expect((await provision(f)).surfaces['.claude.json']).toBe('synced') diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index f53a785990d1..63137310a054 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -111,27 +111,52 @@ function isSharedEntry(name: string): boolean { ) } -/** Install ids Claude writes before any sign-in; each folder keeps its own. */ -const INSTALL_STATE_KEYS: ReadonlySet = new Set([ +/** + * 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' + 'firstStartVersion', + // Why: a Console API key login; copied, it would outrank the account's own login. + 'primaryApiKey', + // Why: Claude never refetches it. + 'claudeCodeFirstTokenDate' ]) -/** State that belongs to the folder's login or install, never copied from the default home. */ function isAccountBoundState(key: string): boolean { + // Why the patterns too: account caches logout leaves behind (groveConfigCache, passes*). return ( - key === 'oauthAccount' || - // Why: a Console API key login; copied, it would outrank the account's own login. - key === 'primaryApiKey' || + ACCOUNT_BOUND_STATE_KEYS.has(key) || key.includes('Cache') || key.startsWith('cached') || - key.startsWith('passes') || - // Why: Claude never refetches it; a copied value can also skip startup fetches. - key === 'claudeCodeFirstTokenDate' || - key === 'startupPrefetchedAt' || - INSTALL_STATE_KEYS.has(key) + key.startsWith('passes') ) } From 4e8ff77292f9a73b2dcac5b7d92c3ca7b698afe1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 02:09:03 -0400 Subject: [PATCH 19/22] Judge an old terminal's Claude banner by the pane's own runtime The banner compared every old pane with the host selection and showed whenever only a WSL account was selected, so old host panes showed it with nothing to say and old WSL panes were judged by the host's account. The pane now names its runtime: a host pane is compared with the host selection, a WSL pane with its distro's selection through the WSL router, and a pane whose runtime is unknown, or with no account selected for it, stays hidden. --- .../claude-profile-wsl-router.ts | 13 +++++ .../ipc/pty/ipc/claude-old-terminal.test.ts | 48 ++++++++++++++----- src/main/ipc/pty/ipc/claude-old-terminal.ts | 41 ++++++++++++---- src/preload/api/pty-api.ts | 8 +++- .../pty-bridge-stream-and-serialization.ts | 6 ++- .../terminal-pane/ClaudeOldTerminalBanner.tsx | 39 +++++++++++++-- .../claude-old-terminal-banner.test.tsx | 32 +++++++++++-- 7 files changed, 153 insertions(+), 34 deletions(-) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index 8acb23c004cb..f5b0e0ade9fe 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -147,6 +147,19 @@ export class ClaudeWslProfileRouter { 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, 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 a8030f058898..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,8 +1,9 @@ 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 type Mocks = { + wslRunsAnotherAccount: boolean | null handlers: Map legacyDaemons: Map> startup: ReturnType Promise | undefined>> @@ -15,6 +16,7 @@ const mocks = vi.hoisted((): Mocks => ({ legacyDaemons: new Map(), startup: vi.fn<() => Promise | undefined>(), runsAnotherAccount: true, + wslRunsAnotherAccount: true, router: true })) vi.mock('../../pty-host-bindings', () => ({ @@ -28,7 +30,14 @@ vi.mock('../../../daemon/daemon-provider-state', () => ({ })) vi.mock('../../../claude-accounts/claude-profile-installed-router', () => ({ getClaudeProfileRouter: () => - mocks.router ? { systemDefaultRunsAnotherAccount: () => mocks.runsAnotherAccount } : undefined + 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' @@ -42,29 +51,42 @@ beforeEach(() => { [43, new Set(['v43-1'])] ]) mocks.runsAnotherAccount = true + mocks.wslRunsAnotherAccount = true mocks.router = true installPtyClaudeOldTerminalIpcHandler({ getLocalPtyProviderStartupPromise: mocks.startup }) }) +const HOST = { runtime: 'host' } + it('answers true only for a pane whose daemon lacks the claude function', async () => { const ask = mocks.handlers.get('pty:openedBeforeClaudeAccounts')! - expect(await ask({}, { id: 'old-1' })).toBe(true) - expect(await ask({}, { id: 'v43-1' })).toBe(true) - expect(await ask({}, { id: 'v42-1' })).toBe(false) + 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("answers false when that pane's claude already runs the selected account", async () => { +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' })).toBe(false) - // With no host account selected (a WSL one), the router cannot tell; the pane still asks. + 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' })).toBe(true) + 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 - expect(await ask({}, { id: 'old-1' })).toBe(true) + 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 ae02516d9926..04ad8c58a022 100644 --- a/src/main/ipc/pty/ipc/claude-old-terminal.ts +++ b/src/main/ipc/pty/ipc/claude-old-terminal.ts @@ -1,4 +1,7 @@ -import { getClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router' +import { + getClaudeProfileRouter, + getClaudeWslProfileRouter +} from '../../../claude-accounts/claude-profile-installed-router' import { getPtyIpc } from '../../pty-host-bindings' import { CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION, @@ -20,18 +23,38 @@ type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined 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() - // Why also the account: when System default is signed in to the selected one, this - // terminal's claude already runs it. - return ( - isTerminalOnLegacyDaemon(args.id, lacksClaudeFunction) && - (getClaudeProfileRouter()?.systemDefaultRunsAnotherAccount() ?? true) - ) + 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/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/components/terminal-pane/ClaudeOldTerminalBanner.tsx b/src/renderer/src/components/terminal-pane/ClaudeOldTerminalBanner.tsx index 4b1471378583..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' @@ -25,15 +27,43 @@ function selectedClaudeAccountKey(state: AppState): string | null { return host || wsl.length > 0 ? JSON.stringify([host, wsl]) : null } -function useOpenedBeforeClaudeAccounts(ptyId: string, selectionKey: string | null): boolean { +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, + tabId: string, + selectionKey: string | null +): boolean { const [openedBefore, setOpenedBefore] = useState(false) useEffect(() => { - if (!selectionKey) { + 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) { @@ -44,7 +74,7 @@ function useOpenedBeforeClaudeAccounts(ptyId: string, selectionKey: string | nul cancelled = true setOpenedBefore(false) } - }, [selectionKey, ptyId]) + }, [selectionKey, ptyId, tabId]) return openedBefore } @@ -69,6 +99,7 @@ export function ClaudeOldTerminalBanner({ ) const openedBefore = useOpenedBeforeClaudeAccounts( ptyId, + tabId, claudeInPane && !dismissed ? selectionKey : null ) const [dialogOpen, setDialogOpen] = useState(false) 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('') }) From 6cbf056032a44230b0d8d5d78f54713c8a6a07ef Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 02:13:24 -0400 Subject: [PATCH 20/22] Leave an account's settings file alone when only Orca's hooks differ from the default home's --- .../claude-profile-provisioning.ts | 9 ++++++++- src/main/claude/claude-profile-hooks.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts index 63137310a054..f637c3e74143 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -6,6 +6,7 @@ import { resolveClaudeGlobalConfigFile, updateClaudeGlobalConfig } from '../claude/claude-folder-trust-file' +import { getManagedScriptFileName, removeManagedHooks } from '../claude/hook-settings' import { publishFileWithoutOverwrite } from '../codex-accounts/fs-utils' import { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' @@ -160,6 +161,12 @@ function isAccountBoundState(key: string): boolean { ) } +// 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) @@ -169,7 +176,7 @@ function copySettings(source: string, target: string): ClaudeProfileSurfaceOutco input.kind === 'present' && current.kind === 'present' && !lstatIfPresent(target)?.isSymbolicLink() && - JSON.stringify(input.value) === JSON.stringify(current.value) + settingsWithoutOrcaHooks(input.value) === settingsWithoutOrcaHooks(current.value) ) { return 'unchanged' } diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts index 50e64753af5e..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' @@ -118,6 +119,24 @@ describe('Claude hooks at an explicit profile', () => { 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) From bc3fbc2d08089b79c770e2566351a6ed5050e127 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 02:15:34 -0400 Subject: [PATCH 21/22] Reread a WSL account's Claude state only when the file changes --- .../claude-account-folder.test.ts | 35 +++++++++++++++++-- .../claude-accounts/claude-account-folder.ts | 32 +++++++++++++++++ .../claude-profile-wsl-router.ts | 13 ++----- 3 files changed, 68 insertions(+), 12 deletions(-) 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 e636b5aa97cc..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' @@ -101,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-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index f5b0e0ade9fe..c71a8c76d2fa 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -17,8 +17,8 @@ import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude- import type { ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' import { - claudeStateLogin, isSameClaudeLogin, + readClaudeFolderLoginAsync, type ClaudeFolderLogin } from './claude-account-folder' import { wslClaudeProfile, wslClaudeProfilePointer } from './claude-profile-wsl-paths' @@ -242,15 +242,8 @@ export class ClaudeWslProfileRouter { } /** The login a guest state file names, read over the share like the marker. */ -async function guestLogin(distro: string, stateFile: string): Promise { - try { - const state: unknown = JSON.parse(await readFile(toWindowsWslPath(stateFile, distro), 'utf8')) - return state && typeof state === 'object' && !Array.isArray(state) - ? claudeStateLogin(Object.fromEntries(Object.entries(state))) - : null - } catch { - return null - } +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. From 37414b07d1effc3e643c08a00cf30e3993057e5f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 04:12:22 -0400 Subject: [PATCH 22/22] Move tests merged from main onto the account-only Claude auth policy --- src/main/claude/claude-structured-launch-resolution.test.ts | 6 +++--- .../runtime/structured-foreground-command-strip.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 309a97eb2ed4..963df6ce7699 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -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 }) 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,