From 0d060e1d21c2a7ab95a8a0ed0bf186757c7c65d9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 29 May 2026 19:06:33 -0400 Subject: [PATCH] Make WSL first-class for local agents and provider accounts (#2649) --- .../runtime-auth-service.test.ts | 98 ++- .../claude-accounts/runtime-auth-service.ts | 224 +++++- .../claude-accounts/runtime-selection.test.ts | 107 +++ src/main/claude-accounts/runtime-selection.ts | 147 ++++ src/main/claude-accounts/service.test.ts | 228 +++++- src/main/claude-accounts/service.ts | 436 ++++++++-- .../runtime-home-service.test.ts | 663 +++++++++++++++- .../codex-accounts/runtime-home-service.ts | 327 +++++++- .../codex-accounts/runtime-selection.test.ts | 107 +++ src/main/codex-accounts/runtime-selection.ts | 146 ++++ src/main/codex-accounts/service.test.ts | 390 +++++++++ src/main/codex-accounts/service.ts | 410 ++++++++-- src/main/daemon/daemon-pty-adapter.ts | 1 + src/main/daemon/daemon-server.ts | 4 + src/main/daemon/pty-subprocess.test.ts | 130 ++- src/main/daemon/pty-subprocess.ts | 71 +- src/main/daemon/terminal-host.ts | 3 + src/main/daemon/types.ts | 2 + src/main/daemon/wsl-session-context.ts | 3 +- src/main/index.ts | 49 +- src/main/ipc/app.ts | 3 +- src/main/ipc/claude-accounts.ts | 17 +- src/main/ipc/codex-accounts.ts | 20 +- src/main/ipc/preflight.test.ts | 32 +- src/main/ipc/preflight.ts | 68 +- src/main/ipc/pty.ts | 117 ++- src/main/ipc/rate-limits.ts | 7 + src/main/providers/local-pty-provider.test.ts | 101 +++ src/main/providers/local-pty-provider.ts | 84 +- src/main/providers/types.ts | 3 + src/main/providers/windows-shell-args.test.ts | 2 +- src/main/providers/windows-shell-args.ts | 5 +- src/main/pty/codex-home-wsl-env.test.ts | 8 +- src/main/pty/codex-home-wsl-env.ts | 8 + src/main/rate-limits/claude-fetcher.test.ts | 29 +- src/main/rate-limits/claude-fetcher.ts | 54 ++ src/main/rate-limits/claude-pty.ts | 30 +- .../claude-rate-limit-target.test.ts | 102 +++ .../rate-limits/claude-rate-limit-target.ts | 71 ++ src/main/rate-limits/codex-fetcher.test.ts | 151 ++++ src/main/rate-limits/codex-fetcher.ts | 83 +- .../codex-rate-limit-target.test.ts | 102 +++ .../rate-limits/codex-rate-limit-target.ts | 73 ++ src/main/rate-limits/service.test.ts | 195 +++++ src/main/rate-limits/service.ts | 205 ++++- src/main/runtime/orca-runtime-files.test.ts | 4 +- .../commit-message-agent-environment.test.ts | 34 +- .../commit-message-agent-environment.ts | 6 + .../window/attach-main-window-services.ts | 8 +- src/main/wsl-bash-command.test.ts | 23 + src/main/wsl-bash-command.ts | 10 + src/main/wsl-env.ts | 17 + src/preload/api-types.ts | 40 +- src/preload/index.ts | 37 +- .../src/components/settings/AccountsPane.tsx | 429 +++++++--- .../settings/AgentLocationSetting.tsx | 126 +++ .../components/settings/AgentsPane.test.tsx | 47 +- .../src/components/settings/AgentsPane.tsx | 38 +- .../src/components/settings/Settings.tsx | 24 +- .../settings/SettingsFormControls.tsx | 12 +- .../settings/TerminalPane.pwsh.test.ts | 28 + .../src/components/settings/TerminalPane.tsx | 52 ++ .../components/settings/accounts-search.ts | 10 + .../src/components/settings/agents-search.ts | 5 + .../settings/terminal-windows-search.ts | 19 + .../src/components/status-bar/StatusBar.tsx | 746 +++++++++++++++--- .../status-bar-runtime-groups.test.ts | 107 +++ .../TabBar.windows-shell-launch.test.ts | 10 +- .../src/lib/local-preflight-context.test.ts | 48 ++ .../src/lib/local-preflight-context.ts | 45 +- .../lib/windows-terminal-capabilities.test.ts | 34 +- .../src/lib/windows-terminal-capabilities.ts | 23 +- .../src/store/slices/detected-agents.test.ts | 65 ++ .../src/store/slices/detected-agents.ts | 9 +- src/renderer/src/store/slices/preflight.ts | 2 +- src/renderer/src/store/slices/rate-limits.ts | 68 +- src/renderer/src/web/web-preload-api.ts | 12 +- src/shared/constants.ts | 4 + src/shared/rate-limit-types.ts | 7 + src/shared/types.ts | 38 + 80 files changed, 6757 insertions(+), 546 deletions(-) create mode 100644 src/main/claude-accounts/runtime-selection.test.ts create mode 100644 src/main/claude-accounts/runtime-selection.ts create mode 100644 src/main/codex-accounts/runtime-selection.test.ts create mode 100644 src/main/codex-accounts/runtime-selection.ts create mode 100644 src/main/rate-limits/claude-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/claude-rate-limit-target.ts create mode 100644 src/main/rate-limits/codex-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/codex-rate-limit-target.ts create mode 100644 src/main/wsl-bash-command.test.ts create mode 100644 src/main/wsl-bash-command.ts create mode 100644 src/main/wsl-env.ts create mode 100644 src/renderer/src/components/settings/AgentLocationSetting.tsx create mode 100644 src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 233db78d2..962e4795a 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3132,7 +3132,10 @@ describe('ClaudeRuntimeAuthService', () => { const service = new ClaudeRuntimeAuthService(store as never) const preparation = await service.prepareForClaudeLaunch() - expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) expect(preparation.stripAuthEnv).toBe(false) expect(preparation.provenance).toBe('system') @@ -3337,6 +3340,99 @@ describe('ClaudeRuntimeAuthService', () => { expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) }) + it('clears a selected WSL managed account when its credentials are missing', async () => { + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(preparation.runtime).toBe('wsl') + expect(preparation.provenance).toBe('wsl:Ubuntu:system') + expect(preparation.stripAuthEnv).toBe(true) + }) + + it('uses the default distro selection for WSL-default Claude preparation', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => join(testState.userDataDir, 'wsl-home') + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const debianAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'debian-account', + createClaudeCredentialsJson('debian@example.com', 'debian-token') + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }), + createClaudeAccount('debian-account', debianAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + const store = createStore(settings) + + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: null + }) + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', + provenance: 'managed:ubuntu-account:wsl:Ubuntu', + stripAuthEnv: true + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index f41a0fbc6..2a2081bb6 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: Claude account switching has one safety boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and env-patch semantics together prevents PTY launch and quota fetch paths drifting. */ +import { execFileSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' @@ -13,6 +14,9 @@ import { resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from './managed-auth-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' import { hasLiveClaudePtys } from './live-pty-gate' import { ClaudeRuntimePathResolver } from './runtime-paths' import { @@ -24,9 +28,19 @@ import { writeActiveClaudeKeychainCredentialsForRuntime, writeManagedClaudeKeychainCredentials } from './keychain' +import { + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' export type ClaudeRuntimeAuthPreparation = { configDir: string + runtime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean provenance: string @@ -64,6 +78,10 @@ type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() private mutationQueue: Promise = Promise.resolve() @@ -83,18 +101,22 @@ export class ClaudeRuntimeAuthService { void this.safeSyncForCurrentSelection() } - async prepareForClaudeLaunch(): Promise { - await this.syncForCurrentSelection() - return this.getPreparation() + async prepareForClaudeLaunch( + target?: ClaudeAccountSelectionTarget + ): Promise { + await this.syncForCurrentSelection(target) + return this.getPreparation(target) } - async prepareForRateLimitFetch(): Promise { - await this.syncForCurrentSelection() - return this.getPreparation() + async prepareForRateLimitFetch( + target?: ClaudeAccountSelectionTarget + ): Promise { + await this.syncForCurrentSelection(target) + return this.getPreparation(target) } - async syncForCurrentSelection(): Promise { - await this.serializeMutation(() => this.doSyncForCurrentSelection()) + async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { + await this.serializeMutation(() => this.doSyncForCurrentSelection(target)) } async forceMaterializeCurrentSelectionForRollback(): Promise { @@ -122,7 +144,7 @@ export class ClaudeRuntimeAuthService { private initializeLastSyncedState(): void { const settings = this.store.getSettings() - this.lastSyncedAccountId = settings.activeClaudeManagedAccountId + this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) } private async safeSyncForCurrentSelection(): Promise { @@ -139,12 +161,12 @@ export class ClaudeRuntimeAuthService { return next } - private async doSyncForCurrentSelection(): Promise { + private async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - settings.activeClaudeManagedAccountId - ) + const effectiveTarget = this.resolveWslDefaultTarget(target) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) const previousAccount = this.getActiveAccount( settings.claudeManagedAccounts, this.lastSyncedAccountId @@ -163,8 +185,20 @@ export class ClaudeRuntimeAuthService { } } if (!activeAccount) { - if (settings.activeClaudeManagedAccountId) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if (activeAccountId) { + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + } + if (normalizedTarget.runtime === 'wsl') { + return } if (this.lastSyncedAccountId !== null) { await (previousAccount @@ -178,6 +212,47 @@ export class ClaudeRuntimeAuthService { return } + if (activeAccount.managedAuthRuntime === 'wsl') { + if (!this.getOwnedManagedAuthPath(activeAccount)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + const credentialsJson = await this.readManagedCredentials(activeAccount) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + // Why: WSL managed Claude accounts are already isolated by their Linux + // CLAUDE_CONFIG_DIR. Materializing them into Windows ~/.claude would mix + // two runtime auth stores and break the Terminal-default runtime contract. + this.clearLastWrittenRuntimeState() + return + } + if (!this.getOwnedManagedAuthPath(activeAccount)) { console.warn( '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' @@ -446,15 +521,74 @@ export class ClaudeRuntimeAuthService { return candidates } - private getPreparation(): ClaudeRuntimeAuthPreparation { + private getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() - const activeAccountId = settings.activeClaudeManagedAccountId + const normalizedTarget = this.resolveWslDefaultTarget( + target ?? + (process.platform === 'win32' && settings.terminalWindowsShell === 'wsl.exe' + ? ({ + runtime: 'wsl', + wslDistro: settings.terminalWindowsWslDistro ?? null + } satisfies ClaudeAccountSelectionTarget) + : ({ runtime: 'host' } satisfies ClaudeAccountSelectionTarget)) + ) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + if ( + normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && + activeAccount?.managedAuthRuntime === 'wsl' && + activeAccount.wslLinuxAuthPath + ) { + return { + configDir: activeAccount.managedAuthPath, + runtime: 'wsl', + wslDistro: activeAccount.wslDistro ?? null, + wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` + } + } + if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { + const distro = + normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() + const wslHome = distro ? getWslHome(distro) : null + const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null + if (distro && wslHome && wslHomeInfo) { + const windowsConfigDir = join(wslHome, '.claude') + const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` + return { + configDir: windowsConfigDir, + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: linuxConfigDir, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${distro}:system` + } + } + return { + configDir: paths.configDir, + runtime: 'wsl', + wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` + } + } return { configDir: paths.configDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, envPatch: paths.envPatch, - stripAuthEnv: Boolean(activeAccountId), - provenance: activeAccountId ? `managed:${activeAccountId}` : 'system' + stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + provenance: + activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' + ? `managed:${activeAccountId}` + : 'system' } } @@ -468,6 +602,16 @@ export class ClaudeRuntimeAuthService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private resolveWslDefaultTarget( + target?: ClaudeAccountSelectionTarget + ): ClaudeAccountSelectionTarget { + if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target ?? { runtime: 'host' } + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + private async findManagedAccountForRuntimeCredentials( runtimeCredentialsJson: string ): Promise { @@ -773,6 +917,46 @@ export class ClaudeRuntimeAuthService { } private getOwnedManagedAuthPath(account: ClaudeManagedAccount): string | null { + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + return null + } + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null + } catch { + return null + } + } + return existsSync(account.managedAuthPath) ? account.managedAuthPath : null + } return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { adoptLegacyMarker: true }) diff --git a/src/main/claude-accounts/runtime-selection.test.ts b/src/main/claude-accounts/runtime-selection.test.ts new file mode 100644 index 000000000..997da720d --- /dev/null +++ b/src/main/claude-accounts/runtime-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' +import { + getSelectedClaudeAccountIdForTarget, + pruneInvalidClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget +} from './runtime-selection' + +function createSettings( + overrides: Partial< + Pick + > = {} +): Pick { + return { + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }, + ...overrides + } +} + +function createAccount( + overrides: Partial & Pick +): ClaudeManagedAccount { + const { id, ...rest } = overrides + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/tmp/${id}`, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...rest + } +} + +describe('Claude runtime account selection', () => { + it('selects host and WSL accounts independently', () => { + const first = setSelectedClaudeAccountIdForTarget({ host: null, wsl: {} }, 'host-account', { + runtime: 'host' + }) + const next = setSelectedClaudeAccountIdForTarget(first, 'wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + + it('resolves a WSL default target when exactly one WSL distro has a selection', () => { + const settings = createSettings({ + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + + expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'wsl' })).toBe('wsl-account') + expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })).toBe('host-account') + }) + + it('clears WSL selections without clearing the host selection for a WSL default target', () => { + const next = setSelectedClaudeAccountIdForTarget( + { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account', Debian: 'other-wsl-account' } + }, + null, + { runtime: 'wsl' } + ) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null, Debian: null } + }) + }) + + it('drops selections whose account belongs to another runtime', () => { + const selection = pruneInvalidClaudeRuntimeSelection( + { + host: 'wsl-account', + wsl: { Ubuntu: 'host-account', Debian: 'missing-account' } + }, + [ + createAccount({ id: 'host-account' }), + createAccount({ + id: 'wsl-account', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + ] + ) + + expect(selection).toEqual({ + host: null, + wsl: { Ubuntu: null, Debian: null } + }) + }) +}) diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts new file mode 100644 index 000000000..8f3a3a439 --- /dev/null +++ b/src/main/claude-accounts/runtime-selection.ts @@ -0,0 +1,147 @@ +import type { + ClaudeManagedAccount, + ClaudeManagedAccountRuntimeSelection, + GlobalSettings +} from '../../shared/types' + +export type ClaudeAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedClaudeAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeClaudeAccountSelectionTarget( + target?: ClaudeAccountSelectionTarget | null +): NormalizedClaudeAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +export function normalizeClaudeRuntimeSelection( + settings: Pick< + GlobalSettings, + 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime' + > +): ClaudeManagedAccountRuntimeSelection { + return { + host: + settings.activeClaudeManagedAccountIdsByRuntime?.host ?? + settings.activeClaudeManagedAccountId ?? + null, + wsl: { ...settings.activeClaudeManagedAccountIdsByRuntime?.wsl } + } +} + +export function getSelectedClaudeAccountIdForTarget( + settings: Pick< + GlobalSettings, + 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime' + >, + target?: ClaudeAccountSelectionTarget | null +): string | null { + const selection = normalizeClaudeRuntimeSelection(settings) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return selection.host + } + if (normalizedTarget.wslDistro) { + return selection.wsl[getClaudeWslSelectionKey(normalizedTarget.wslDistro)] ?? null + } + const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean))) + return ( + selection.wsl[getClaudeWslSelectionKey(null)] ?? + (selectedIds.length === 1 ? selectedIds[0] : null) + ) +} + +export function setSelectedClaudeAccountIdForTarget( + selection: ClaudeManagedAccountRuntimeSelection, + accountId: string | null, + target?: ClaudeAccountSelectionTarget | null +): ClaudeManagedAccountRuntimeSelection { + const normalizedTarget = normalizeClaudeAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return { host: accountId, wsl: { ...selection.wsl } } + } + if (accountId === null && normalizedTarget.wslDistro === null) { + return { + host: selection.host, + wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null])) + } + } + return { + host: selection.host, + wsl: { + ...selection.wsl, + [getClaudeWslSelectionKey(normalizedTarget.wslDistro)]: accountId + } + } +} + +export function removeClaudeAccountIdFromSelection( + selection: ClaudeManagedAccountRuntimeSelection, + accountId: string +): ClaudeManagedAccountRuntimeSelection { + const nextWsl: Record = {} + for (const [distro, selectedId] of Object.entries(selection.wsl)) { + nextWsl[distro] = selectedId === accountId ? null : selectedId + } + return { + host: selection.host === accountId ? null : selection.host, + wsl: nextWsl + } +} + +export function pruneInvalidClaudeRuntimeSelection( + selection: ClaudeManagedAccountRuntimeSelection, + accounts: ClaudeManagedAccount[] +): ClaudeManagedAccountRuntimeSelection { + const hostAccount = selection.host + ? accounts.find((account) => account.id === selection.host) + : null + const nextWsl: Record = {} + for (const [distroKey, accountId] of Object.entries(selection.wsl)) { + if (!accountId) { + nextWsl[distroKey] = null + continue + } + const account = accounts.find((entry) => entry.id === accountId) + nextWsl[distroKey] = + account && + account.managedAuthRuntime === 'wsl' && + getClaudeWslSelectionKey(account.wslDistro) === distroKey + ? accountId + : null + } + return { + host: hostAccount && hostAccount.managedAuthRuntime !== 'wsl' ? selection.host : null, + wsl: nextWsl + } +} + +export function getClaudeSelectionTargetForAccount( + account: ClaudeManagedAccount +): ClaudeAccountSelectionTarget { + if (account.managedAuthRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getClaudeWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index aa4496778..423b6aa99 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -505,7 +505,9 @@ describe('ClaudeAccountService credential capture', () => { await service.removeAccount('account-1') expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { + runtime: 'host' + }) expect(settings).toMatchObject({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null @@ -576,7 +578,229 @@ describe('ClaudeAccountService credential capture', () => { await service.reauthenticateAccount('account-1') expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'host' + }) expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') }) + + it('selects a WSL account without changing the Windows active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const snapshot = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(snapshot.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) + + it('rejects selecting a WSL account for the Windows target', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(wslAuthPath, { recursive: true }) + const settings = { + claudeManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await expect( + service.selectAccountForTarget('wsl-account', { runtime: 'host' }) + ).rejects.toThrow('different runtime') + expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() + expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() + }) + + it('removes a WSL account without clearing the Windows active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.removeAccount('wsl-account') + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null } + }) + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) }) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 688af804f..c09183c94 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ import { randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' @@ -30,6 +30,19 @@ import { writeManagedClaudeKeychainCredentials } from './keychain' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + getClaudeSelectionTargetForAccount, + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + pruneInvalidClaudeRuntimeSelection, + removeClaudeAccountIdFromSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' const LOGIN_TIMEOUT_MS = 180_000 const STATUS_TIMEOUT_MS = 20_000 @@ -52,6 +65,22 @@ type ManagedClaudeAuthSnapshot = { oauthAccountJson: string | null } +export type ClaudeAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +type ManagedClaudeAuthLocation = { + managedAuthPath: string + managedAuthRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxAuthPath: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() @@ -66,8 +95,8 @@ export class ClaudeAccountService { return this.getSnapshot() } - async addAccount(): Promise { - return this.serializeMutation(() => this.doAddAccount()) + async addAccount(target?: ClaudeAccountAddTarget): Promise { + return this.serializeMutation(() => this.doAddAccount(target)) } async reauthenticateAccount(accountId: string): Promise { @@ -82,19 +111,29 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId)) } + async selectAccountForTarget( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + } + private serializeMutation(fn: () => Promise): Promise { const next = this.mutationQueue.then(fn, fn) this.mutationQueue = next.catch(() => {}) return next } - private async doAddAccount(): Promise { + private async doAddAccount( + target?: ClaudeAccountAddTarget + ): Promise { const accountId = randomUUID() - const managedAuthPath = this.createManagedAuthDir(accountId) + const managedAuth = this.createManagedAuthDir(accountId, target) + const { managedAuthPath } = managedAuth const previousSettings = this.store.getSettings() try { - const captured = await this.runClaudeLoginAndCapture() + const captured = await this.runClaudeLoginAndCapture(managedAuth) if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } @@ -105,6 +144,9 @@ export class ClaudeAccountService { id: accountId, email: captured.identity.email, managedAuthPath, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro, + wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, authMethod: 'subscription-oauth', organizationUuid: captured.identity.organizationUuid, organizationName: captured.identity.organizationName, @@ -113,14 +155,25 @@ export class ClaudeAccountService { lastAuthenticatedAt: now } - const outgoingAccountId = previousSettings.activeClaudeManagedAccountId + const selection = normalizeClaudeRuntimeSelection(previousSettings) + const targetSelection = getClaudeSelectionTargetForAccount(account) + const outgoingAccountId = getSelectedClaudeAccountIdForTarget( + previousSettings, + targetSelection + ) this.store.updateSettings({ claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: account.id + activeClaudeManagedAccountId: + targetSelection.runtime === 'host' ? account.id : selection.host, + activeClaudeManagedAccountIdsByRuntime: setSelectedClaudeAccountIdForTarget( + selection, + account.id, + targetSelection + ) }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) + await this.syncRuntimeAuthWithLivePtyGate(targetSelection) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -135,7 +188,12 @@ export class ClaudeAccountService { const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId) const previousSettings = this.store.getSettings() const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath) - const captured = await this.runClaudeLoginAndCapture() + const captured = await this.runClaudeLoginAndCapture({ + managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + wslLinuxAuthPath: account.wslLinuxAuthPath ?? null + }) if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } @@ -162,8 +220,11 @@ export class ClaudeAccountService { this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) + await this.rateLimits.refreshForClaudeAccountChange( + undefined, + getClaudeSelectionTargetForAccount(account) + ) return this.getSnapshot() } catch (error) { let restoredManagedCredentials = false @@ -206,26 +267,45 @@ export class ClaudeAccountService { const account = this.requireAccount(accountId) const settings = this.store.getSettings() const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeClaudeAccountIdFromSelection( + normalizeClaudeRuntimeSelection(settings), + accountId + ) const nextActiveId = - settings.activeClaudeManagedAccountId === accountId - ? null - : settings.activeClaudeManagedAccountId + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host try { - if (settings.activeClaudeManagedAccountId === accountId) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - await this.syncRuntimeAuthWithLivePtyGate() + if ( + getSelectedClaudeAccountIdForTarget( + settings, + getClaudeSelectionTargetForAccount(account) + ) === accountId + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) } else { this.store.updateSettings({ claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection }) - await this.syncRuntimeAuthWithLivePtyGate() + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) } await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange( + getSelectedClaudeAccountIdForTarget( + settings, + getClaudeSelectionTargetForAccount(account) + ) === accountId + ? accountId + : undefined, + getClaudeSelectionTargetForAccount(account) + ) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(settings) @@ -234,16 +314,37 @@ export class ClaudeAccountService { } } - private async doSelectAccount(accountId: string | null): Promise { + private async doSelectAccount( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + let effectiveTarget = target if (accountId !== null) { - this.requireAccount(accountId) + const account = this.requireAccount(accountId) + const accountTarget = getClaudeSelectionTargetForAccount(account) + const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Claude account belongs to a different runtime.') + } + effectiveTarget = accountTarget } const previousSettings = this.store.getSettings() - const outgoingAccountId = previousSettings.activeClaudeManagedAccountId - this.store.updateSettings({ activeClaudeManagedAccountId: accountId }) + const selection = normalizeClaudeRuntimeSelection(previousSettings) + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedClaudeAccountIdForTarget(selection, accountId, effectiveTarget) + this.store.updateSettings({ + activeClaudeManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) try { - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) + await this.syncRuntimeAuthWithLivePtyGate(effectiveTarget) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -258,7 +359,8 @@ export class ClaudeAccountService { accounts: settings.claudeManagedAccounts .map((account) => this.toSummary(account)) .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: settings.activeClaudeManagedAccountId + activeAccountId: normalizeClaudeRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) } } @@ -266,6 +368,8 @@ export class ClaudeAccountService { return { id: account.id, email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, authMethod: account.authMethod ?? 'unknown', organizationUuid: account.organizationUuid ?? null, organizationName: account.organizationName ?? null, @@ -287,54 +391,73 @@ export class ClaudeAccountService { private normalizeActiveSelection(): void { const settings = this.store.getSettings() - if (!settings.activeClaudeManagedAccountId) { - return - } - const hasActiveAccount = settings.claudeManagedAccounts.some( - (entry) => entry.id === settings.activeClaudeManagedAccountId + const nextSelection = pruneInvalidClaudeRuntimeSelection( + normalizeClaudeRuntimeSelection(settings), + settings.claudeManagedAccounts ) - if (!hasActiveAccount) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if ( + nextSelection.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(nextSelection) !== JSON.stringify(normalizeClaudeRuntimeSelection(settings)) + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) } } private restoreClaudeSettings(settings: ReturnType): void { this.store.updateSettings({ claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime }) } - private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise): Promise { + private async syncRuntimeAuthWithLivePtyGate( + target?: ClaudeAccountSelectionTarget, + operation?: () => Promise + ): Promise { beginClaudeAuthSwitch() try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection()) + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) } finally { endClaudeAuthSwitch() } } - private async runClaudeLoginAndCapture(): Promise { - const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) + private async runClaudeLoginAndCapture( + location: ManagedClaudeAuthLocation = { + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + ): Promise { + const tempConfig = this.createTemporaryClaudeConfigDir(location) const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() let captured: CapturedClaudeAuth | null = null let captureError: unknown = null let cleanupError: unknown = null try { - await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS) + await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS) const status = await this.runClaudeCommand( ['auth', 'status', '--json'], - tempConfigDir, + tempConfig, STATUS_TIMEOUT_MS, { allowFailure: true } ) - captured = await this.captureAuthFromConfigDir(tempConfigDir, status, previousLegacyKeychain) + captured = await this.captureAuthFromConfigDir( + tempConfig.windowsPath, + status, + previousLegacyKeychain + ) } catch (error) { captureError = error } finally { if (process.platform === 'darwin') { try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfigDir) + await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) } catch (error) { console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) } @@ -350,7 +473,7 @@ export class ClaudeAccountService { cleanupError = error } } - rmSync(tempConfigDir, { recursive: true, force: true }) + this.removeTemporaryClaudeConfigDir(tempConfig) } if (captureError) { throw captureError @@ -361,6 +484,72 @@ export class ClaudeAccountService { return captured! } + private createTemporaryClaudeConfigDir(location: ManagedClaudeAuthLocation): { + windowsPath: string + linuxPath: string | null + wslDistro: string | null + } { + if (location.managedAuthRuntime !== 'wsl') { + return { + windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')), + linuxPath: null, + wslDistro: null + } + } + if (!location.wslDistro) { + throw new Error('Could not resolve the active WSL distribution for Claude login.') + } + const linuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + location.wslDistro, + '--', + 'bash', + '-lc', + 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"' + ], + { encoding: 'utf-8', timeout: 5000 } + ) + .replaceAll(String.fromCharCode(0), '') + .trim() + if (!linuxPath.startsWith('/')) { + throw new Error('Could not create a temporary WSL Claude login directory.') + } + return { + windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), + linuxPath, + wslDistro: location.wslDistro + } + } + + private removeTemporaryClaudeConfigDir(tempConfig: { + windowsPath: string + linuxPath: string | null + wslDistro: string | null + }): void { + if (tempConfig.linuxPath && tempConfig.wslDistro) { + try { + execFileSync( + 'wsl.exe', + [ + '-d', + tempConfig.wslDistro, + '--', + 'bash', + '-lc', + `rm -rf -- ${shellQuote(tempConfig.linuxPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + } catch { + // Best-effort cleanup. + } + return + } + rmSync(tempConfig.windowsPath, { recursive: true, force: true }) + } + private async captureAuthFromConfigDir( configDir: string, statusOutput: string, @@ -520,11 +709,72 @@ export class ClaudeAccountService { } } - private createManagedAuthDir(accountId: string): string { + private createManagedAuthDir( + accountId: string, + target?: ClaudeAccountAddTarget + ): ManagedClaudeAuthLocation { + const wslAuth = this.tryCreateWslManagedAuthDir(accountId, target) + if (wslAuth) { + return wslAuth + } + const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth') mkdirSync(managedAuthPath, { recursive: true }) writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - return this.assertManagedAuthPath(managedAuthPath, accountId) + return { + managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId), + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + } + + private tryCreateWslManagedAuthDir( + accountId: string, + target?: ClaudeAccountAddTarget + ): ManagedClaudeAuthLocation | null { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + + const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] + const infoOutput = execFileSync( + 'wsl.exe', + [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + { encoding: 'utf-8', timeout: 5000 } + ) + const [rawDistro, rawHome] = infoOutput + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = target.wslDistro?.trim() || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Claude login.') + } + + const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` + const markerPath = `${wslLinuxAuthPath}/.orca-managed-claude-auth` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + `mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + + const managedAuthPath = toWindowsWslPath(wslLinuxAuthPath, distro) + return { + managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId), + managedAuthRuntime: 'wsl', + wslDistro: distro, + wslLinuxAuthPath + } } private getManagedAccountsRoot(): string { @@ -534,6 +784,60 @@ export class ClaudeAccountService { } private assertManagedAuthPath(candidatePath: string, expectedAccountId?: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') + } + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + expectedAccountId + ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` + : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"', + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { + cause: error + }) + } + } + if ( + !existsSync(candidatePath) || + !existsSync(join(candidatePath, '.orca-managed-claude-auth')) + ) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return candidatePath + } + this.getManagedAccountsRoot() const accountId = expectedAccountId ?? this.readManagedAuthAccountIdFromPath(candidatePath) if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { @@ -567,19 +871,39 @@ export class ClaudeAccountService { private runClaudeCommand( args: string[], - configDir: string, + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, timeoutMs: number, options?: { allowFailure?: boolean } ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const claudeCommand = resolveClaudeCommand() - const child = spawn(claudeCommand, args, { + const spawnConfig = + configDir.linuxPath && configDir.wslDistro + ? { + command: 'wsl.exe', + args: [ + '-d', + configDir.wslDistro, + '--', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` + ], + env: process.env, + shell: false + } + : { + command: resolveClaudeCommand(), + args, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }, + shell: process.platform === 'win32' + } + const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', - env: { - ...process.env, - CLAUDE_CONFIG_DIR: configDir - } + shell: spawnConfig.shell, + env: spawnConfig.env }) let settled = false diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c59b6f162..cb0d00847 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -74,6 +74,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', terminalScrollbackBytes: 10_000_000, + localAccountRuntime: 'host', + localAccountWslDistro: null, openLinksInApp: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', @@ -479,7 +481,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) @@ -517,7 +521,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) expect(warnSpy).toHaveBeenCalled() }) @@ -533,7 +539,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) }) @@ -673,6 +681,651 @@ describe('CodexRuntimeHomeService', () => { ) }) + it('does not touch host auth on startup when the active account is WSL-backed', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"host-system"}\n', 'utf-8') + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"wsl"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"wsl"}\n' + ) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('clears a selected WSL managed account when auth.json is missing', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token') + ) + rmSync(join(managedHomePath, 'auth.json'), { force: true }) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const firstAuth = createCodexAuthJson('first@example.com', 'acct-first', 'first-token') + const secondAuth = createCodexAuthJson('second@example.com', 'acct-second', 'second-token') + const firstManagedHomePath = createManagedAuth(testState.userDataDir, 'account-1', firstAuth) + const secondManagedHomePath = createManagedAuth(testState.userDataDir, 'account-2', secondAuth) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'first@example.com', + managedHomePath: firstManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-first', + workspaceLabel: null, + workspaceAccountId: 'acct-first', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'second@example.com', + managedHomePath: secondManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-2/home', + providerAccountId: 'acct-second', + workspaceLabel: null, + workspaceAccountId: 'acct-second', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + + store.updateSettings({ + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } + }) + service.syncForCurrentSelection(target) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') + const wslManagedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'managed-newer', + 2_000 + ) + const staleWslRuntimeAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-stale', + 1_000 + ) + const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'wsl-account', + wslManagedAuth + ) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + mkdirSync(wslRuntimeHomePath, { recursive: true }) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), staleWslRuntimeAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + providerAccountId: 'acct-host', + workspaceLabel: null, + workspaceAccountId: 'acct-host', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'host-account', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) + const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) + const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') + + service.clearLastWrittenAuthJson('account-1') + service.syncForCurrentSelection(target) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + ) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('uses the default distro selection for WSL-default rate-limit fetches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const ubuntuAuth = createCodexAuthJson('ubuntu@example.com', 'acct-ubuntu', 'ubuntu-token') + const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') + const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) + const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) + const runtimeAuthPath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home', + 'auth.json' + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'ubuntu-account', + email: 'ubuntu@example.com', + managedHomePath: ubuntuHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/ubuntu/home', + providerAccountId: 'acct-ubuntu', + workspaceLabel: null, + workspaceAccountId: 'acct-ubuntu', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'debian-account', + email: 'debian@example.com', + managedHomePath: debianHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/debian/home', + providerAccountId: 'acct-debian', + workspaceLabel: null, + workspaceAccountId: 'acct-debian', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe( + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + ) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not write WSL system-default auth into managed accounts', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-old', 1_000) + const systemDefaultAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'system-newer', + 2_000 + ) + const managedHomePath = createManagedAuth(testState.userDataDir, 'wsl-account', managedAuth) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemDefaultAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('reads WSL system-default token refreshes back to WSL system auth', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'system-old', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('preserves WSL system-default token refreshes after app restart', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'system-old', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const systemCodexHomePath = join(wslHome, '.codex') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + mkdirSync(systemCodexHomePath, { recursive: true }) + mkdirSync(wslRuntimeHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('does not overwrite auth.json when no managed account was ever active', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8') @@ -998,7 +1651,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 981e7aec7..0be2ebef6 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -12,7 +12,7 @@ import { rmSync, statSync } from 'node:fs' -import { dirname, extname, join, parse, relative } from 'node:path' +import { dirname, extname, join, parse, relative, win32 as pathWin32 } from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' @@ -24,6 +24,14 @@ import { } from '../codex/codex-home-paths' import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + getSelectedCodexAccountIdForTarget, + normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' type CodexAuthIdentity = { email: string | null @@ -67,6 +75,11 @@ export class CodexRuntimeHomeService { // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as // the new system default instead of restoring a stale snapshot. private lastWrittenAuthJson: string | null = null + // Why: WSL terminals have their own stable runtime homes per distro. They + // cannot share the host baseline or host sync can make stale WSL auth look + // newer than managed storage. + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { @@ -77,10 +90,26 @@ export class CodexRuntimeHomeService { private initializeLastSyncedState(): void { const settings = this.store.getSettings() - this.lastSyncedAccountId = settings.activeCodexManagedAccountId + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes are never materialized into host ~/.codex. + // Treating one as "last synced" makes cold start look like a host-account + // transition and can restore/delete host auth that Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host } - prepareForCodexLaunch(): string { + prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return ( + this.syncWslRuntimeForCurrentSelection(wslTarget) ?? + this.getWslSystemCodexHomePath(wslTarget) + ) + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -88,14 +117,38 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - prepareForRateLimitFetch(): string { + private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + return home ? this.joinWslPath(home, '.codex') : null + } + + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return ( + this.syncWslRuntimeForCurrentSelection(wslTarget) ?? + this.getWslSystemCodexHomePath(wslTarget) + ) + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() return this.getRuntimeHomePath() } - syncForCurrentSelection(): void { + syncForCurrentSelection(target?: CodexAccountSelectionTarget): void { + if (target?.runtime === 'wsl') { + this.syncWslRuntimeForCurrentSelection(target) + return + } + const settings = this.store.getSettings() const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { @@ -103,12 +156,29 @@ export class CodexRuntimeHomeService { } const activeAccount = this.getActiveAccount( settings.codexManagedAccounts, - settings.activeCodexManagedAccountId + normalizeCodexRuntimeSelection(settings).host ) const previousAccount = this.getActiveAccount( settings.codexManagedAccounts, this.lastSyncedAccountId ) + if (this.getWslManagedHomePath(activeAccount)) { + const previousWasHostManaged = previousAccount && !this.getWslManagedHomePath(previousAccount) + const outgoingReadBackResult = previousWasHostManaged + ? this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenAuthJson: false + }) + : 'unchanged' + if (previousWasHostManaged) { + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) + } + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + this.skipNextReadBackForAccountId = null + return + } let outgoingReadBackResult: CodexReadBackResult = 'unchanged' if (previousAccount && previousAccount.id !== activeAccount?.id) { outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { @@ -116,8 +186,14 @@ export class CodexRuntimeHomeService { }) } if (!activeAccount) { - if (settings.activeCodexManagedAccountId) { - this.store.updateSettings({ activeCodexManagedAccountId: null }) + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) } // Why: only restore the system-default mirror when transitioning FROM a // managed account. When no managed account was ever active, later syncs @@ -164,7 +240,13 @@ export class CodexRuntimeHomeService { console.warn( '[codex-runtime-home] Active managed account is missing auth.json, restoring system default' ) - this.store.updateSettings({ activeCodexManagedAccountId: null }) + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null @@ -201,8 +283,10 @@ export class CodexRuntimeHomeService { // re-auth or add-account. Those flows write fresh tokens to managed storage, // so the read-back must be skipped to avoid overwriting them with stale // runtime tokens. - clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void { - if (accountId === this.store.getSettings().activeCodexManagedAccountId) { + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } this.skipNextReadBackForAccountId = accountId @@ -211,18 +295,36 @@ export class CodexRuntimeHomeService { private readBackRefreshedTokens(options: { updateLastWrittenAuthJson: boolean }): CodexReadBackResult { + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) + } + + private readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult { try { - const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { return 'unchanged' } + const lastWrittenAuthJson = + options.lastWrittenAuthJson === undefined + ? this.lastWrittenAuthJson + : options.lastWrittenAuthJson const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) { + if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { return 'unchanged' } - const match = this.findManagedAccountForRuntimeAuth(runtimeContents) + const match = this.findManagedAccountForRuntimeAuth( + runtimeContents, + options.expectedAccountId + ) if (match.kind !== 'matched') { if (match.kind === 'ambiguous') { console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') @@ -232,7 +334,7 @@ export class CodexRuntimeHomeService { // Why: after app restart, Orca has no last-written baseline. Identity // alone cannot prove runtime auth is newer than managed storage. if ( - this.lastWrittenAuthJson === null && + lastWrittenAuthJson === null && !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) ) { return 'rejected' @@ -240,7 +342,11 @@ export class CodexRuntimeHomeService { writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) if (options.updateLastWrittenAuthJson) { - this.lastWrittenAuthJson = runtimeContents + if (options.setLastWrittenAuthJson) { + options.setLastWrittenAuthJson(runtimeContents) + } else { + this.lastWrittenAuthJson = runtimeContents + } } return 'persisted' } catch (error) { @@ -253,10 +359,13 @@ export class CodexRuntimeHomeService { } private readBackRefreshedTokensForAccount( - _account: CodexManagedAccount, + account: CodexManagedAccount, options: { updateLastWrittenAuthJson: boolean } ): CodexReadBackResult { - return this.readBackRefreshedTokens(options) + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { + ...options, + expectedAccountId: account.id + }) } private safeSyncForCurrentSelection(): void { @@ -277,13 +386,184 @@ export class CodexRuntimeHomeService { return accounts.find((account) => account.id === activeAccountId) ?? null } - private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch { + private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + if (!account) { + return null + } + if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) { + return account.managedHomePath + } + return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null + } + + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() + if (!distro) { + return null + } + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null + } + + mkdirSync(runtimeHomePath, { recursive: true }) + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) + + const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { + this.skipNextReadBackForAccountId = null + } else { + const previousWslAccount = this.getActiveAccount( + settings.codexManagedAccounts, + previousWslAccountId + ) + if (previousWslAccount) { + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) + } + } + } + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null + if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { + const activeAuth = readFileSync(activeAuthPath, 'utf-8') + this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return runtimeHomePath + } + if (activeAccount && activeAuthPath) { + console.warn( + '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' + ) + this.store.updateSettings({ + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + null, + wslTarget + ) + }) + } + + const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) + if (systemAuthPath && existsSync(systemAuthPath)) { + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null + const runtimeAuth = existsSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') + : null + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL runtime homes are per-distro and their in-memory baseline is + // lost on app restart. A same-identity fresher runtime auth is a Codex + // token refresh and should be copied back before we mirror ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + private getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home + ? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + : null + } + + private joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + private resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + + private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { + const home = this.getWslSystemCodexHomePath(target) + return home ? this.joinWslPath(home, 'auth.json') : null + } + + private seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (existsSync(runtimeConfigPath)) { + return + } + + const candidateHomes = [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + for (const homePath of candidateHomes) { + const configPath = join(homePath, 'config.toml') + if (existsSync(configPath)) { + copyFileSync(configPath, runtimeConfigPath) + return + } + } + } + + private findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string + ): CodexReadBackMatch { const matches: { account: CodexManagedAccount managedAuthPath: string managedAuthContents: string }[] = [] for (const account of this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } const managedAuthPath = join(account.managedHomePath, 'auth.json') if (!existsSync(managedAuthPath)) { continue @@ -875,6 +1155,15 @@ export class CodexRuntimeHomeService { this.lastWrittenAuthJson = contents } + private writeRuntimeAuthAtPath(authPath: string, contents: string): void { + if (this.fileContentsEqual(authPath, contents)) { + this.ensureOwnerOnlyMode(authPath) + return + } + mkdirSync(dirname(authPath), { recursive: true }) + writeFileAtomically(authPath, contents, { mode: 0o600 }) + } + private fileContentsEqual(targetPath: string, contents: string): boolean { try { return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents diff --git a/src/main/codex-accounts/runtime-selection.test.ts b/src/main/codex-accounts/runtime-selection.test.ts new file mode 100644 index 000000000..df98d2024 --- /dev/null +++ b/src/main/codex-accounts/runtime-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import { + getSelectedCodexAccountIdForTarget, + pruneInvalidCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget +} from './runtime-selection' + +function createSettings( + overrides: Partial< + Pick + > = {} +): Pick { + return { + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, + ...overrides + } +} + +function createAccount( + overrides: Partial & Pick +): CodexManagedAccount { + const { id, ...rest } = overrides + return { + id, + email: `${id}@example.com`, + managedHomePath: `/tmp/${id}`, + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...rest + } +} + +describe('Codex runtime account selection', () => { + it('selects host and WSL accounts independently', () => { + const first = setSelectedCodexAccountIdForTarget({ host: null, wsl: {} }, 'host-account', { + runtime: 'host' + }) + const next = setSelectedCodexAccountIdForTarget(first, 'wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + + it('resolves a WSL default target when exactly one WSL distro has a selection', () => { + const settings = createSettings({ + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + + expect(getSelectedCodexAccountIdForTarget(settings, { runtime: 'wsl' })).toBe('wsl-account') + expect(getSelectedCodexAccountIdForTarget(settings, { runtime: 'host' })).toBe('host-account') + }) + + it('clears WSL selections without clearing the host selection for a WSL default target', () => { + const next = setSelectedCodexAccountIdForTarget( + { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account', Debian: 'other-wsl-account' } + }, + null, + { runtime: 'wsl' } + ) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null, Debian: null } + }) + }) + + it('drops selections whose account belongs to another runtime', () => { + const selection = pruneInvalidCodexRuntimeSelection( + { + host: 'wsl-account', + wsl: { Ubuntu: 'host-account', Debian: 'missing-account' } + }, + [ + createAccount({ id: 'host-account' }), + createAccount({ + id: 'wsl-account', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + ] + ) + + expect(selection).toEqual({ + host: null, + wsl: { Ubuntu: null, Debian: null } + }) + }) +}) diff --git a/src/main/codex-accounts/runtime-selection.ts b/src/main/codex-accounts/runtime-selection.ts new file mode 100644 index 000000000..4b6ff3a51 --- /dev/null +++ b/src/main/codex-accounts/runtime-selection.ts @@ -0,0 +1,146 @@ +import type { + CodexManagedAccount, + CodexManagedAccountRuntimeSelection, + GlobalSettings +} from '../../shared/types' + +export type CodexAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedCodexAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeCodexAccountSelectionTarget( + target?: CodexAccountSelectionTarget | null +): NormalizedCodexAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +export function normalizeCodexRuntimeSelection( + settings: Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' + > +): CodexManagedAccountRuntimeSelection { + return { + host: + settings.activeCodexManagedAccountIdsByRuntime?.host ?? + settings.activeCodexManagedAccountId ?? + null, + wsl: { ...settings.activeCodexManagedAccountIdsByRuntime?.wsl } + } +} + +export function getSelectedCodexAccountIdForTarget( + settings: Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' + >, + target?: CodexAccountSelectionTarget | null +): string | null { + const selection = normalizeCodexRuntimeSelection(settings) + const normalizedTarget = normalizeCodexAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return selection.host + } + if (normalizedTarget.wslDistro) { + return selection.wsl[getWslSelectionKey(normalizedTarget.wslDistro)] ?? null + } + const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean))) + return ( + selection.wsl[getWslSelectionKey(null)] ?? (selectedIds.length === 1 ? selectedIds[0] : null) + ) +} + +export function setSelectedCodexAccountIdForTarget( + selection: CodexManagedAccountRuntimeSelection, + accountId: string | null, + target?: CodexAccountSelectionTarget | null +): CodexManagedAccountRuntimeSelection { + const normalizedTarget = normalizeCodexAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return { host: accountId, wsl: { ...selection.wsl } } + } + if (accountId === null && normalizedTarget.wslDistro === null) { + return { + host: selection.host, + wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null])) + } + } + return { + host: selection.host, + wsl: { + ...selection.wsl, + [getWslSelectionKey(normalizedTarget.wslDistro)]: accountId + } + } +} + +export function removeCodexAccountIdFromSelection( + selection: CodexManagedAccountRuntimeSelection, + accountId: string +): CodexManagedAccountRuntimeSelection { + const nextWsl: Record = {} + for (const [distro, selectedId] of Object.entries(selection.wsl)) { + nextWsl[distro] = selectedId === accountId ? null : selectedId + } + return { + host: selection.host === accountId ? null : selection.host, + wsl: nextWsl + } +} + +export function pruneInvalidCodexRuntimeSelection( + selection: CodexManagedAccountRuntimeSelection, + accounts: CodexManagedAccount[] +): CodexManagedAccountRuntimeSelection { + const hostAccount = selection.host + ? accounts.find((account) => account.id === selection.host) + : null + const nextWsl: Record = {} + for (const [distroKey, accountId] of Object.entries(selection.wsl)) { + if (!accountId) { + nextWsl[distroKey] = null + continue + } + const account = accounts.find((entry) => entry.id === accountId) + nextWsl[distroKey] = + account && + account.managedHomeRuntime === 'wsl' && + getWslSelectionKey(account.wslDistro) === distroKey + ? accountId + : null + } + return { + host: hostAccount && hostAccount.managedHomeRuntime !== 'wsl' ? selection.host : null, + wsl: nextWsl + } +} + +export function getCodexSelectionTargetForAccount( + account: CodexManagedAccount +): CodexAccountSelectionTarget { + if (account.managedHomeRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 52ef01a22..34b712c01 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -23,6 +23,11 @@ vi.mock('node:os', async () => { } }) +function decodeEncodedWslBashCommand(command: string): string { + const encoded = command.match(/^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command +} + function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true @@ -61,6 +66,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', terminalScrollbackBytes: 10_000_000, + localAccountRuntime: 'host', + localAccountWslDistro: null, openLinksInApp: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', @@ -395,6 +402,7 @@ describe('CodexAccountService config sync', () => { ) vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), spawn: spawnMock })) vi.doMock('../codex-cli/command', () => ({ @@ -419,6 +427,314 @@ describe('CodexAccountService config sync', () => { expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) + it('adds a managed Codex account inside WSL when the account context is WSL', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') + const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' + writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) + if (script.includes('WSL_DISTRO_NAME')) { + return 'Debian\n/home/alice\n' + } + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual([ + '-d', + 'Debian', + '--', + 'bash', + '-lc', + `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` + ]) + expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\n' + ) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + + const payload = Buffer.from(JSON.stringify({ email: 'wsl@example.com' })).toString( + 'base64url' + ) + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ tokens: { id_token: `header.${payload}.signature` } }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:crypto', () => ({ + randomUUID: () => 'account-id-for-test' + })) + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: (linuxPath: string) => + linuxPath.endsWith('/.codex/config.toml') ? wslConfigPath : wslManagedHomePath + })) + + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.addAccount({ runtime: 'wsl', wslDistro: 'Debian' }) + + expect(result.accounts[0]).toMatchObject({ + email: 'wsl@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Debian' + }) + expect(store.getSettings().codexManagedAccounts[0]).toMatchObject({ + managedHomePath: wslManagedHomePath, + wslLinuxHomePath, + managedHomeRuntime: 'wsl' + }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('reauthenticates a WSL managed Codex account inside its distro', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'old@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual([ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` + ]) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'new@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-1') + + expect(result.accounts[0]).toMatchObject({ + email: 'new@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('removes a WSL managed account only after canonical path validation', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + return '' + }), + spawn: vi.fn() + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.removeAccount('account-1') + + expect(result.accounts).toHaveLength(0) + expect(existsSync(wslManagedHomePath)).toBe(false) + expect(existsSync(join(testState.userDataDir, 'wsl-account'))).toBe(false) + expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('deselects active account via selectAccount(null)', async () => { const managedHomePath = createManagedHome( testState.userDataDir, @@ -460,6 +776,80 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled() }) + it('keeps Windows and WSL active Codex account selections separate', async () => { + const hostManagedHomePath = createManagedHome( + testState.userDataDir, + 'host-account', + '', + '{"account":"host"}\n' + ) + const wslManagedHomePath = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-accounts\\wsl-account\\home' + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'host-account', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: {} + } + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(result.activeAccountId).toBe('host-account') + expect(result.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(store.getSettings().activeCodexManagedAccountId).toBe('host-account') + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + it('removes an account and cleans up managed home', async () => { const managedHomePath = createManagedHome( testState.userDataDir, diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 8b6306e24..ce6414ddf 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -2,9 +2,9 @@ account lifecycle, path safety, login, and identity parsing in one audited main-process module so the managed-account boundary stays explicit. */ import { randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' @@ -18,6 +18,19 @@ import { writeFileAtomically } from './fs-utils' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + normalizeCodexAccountSelectionTarget, + normalizeCodexRuntimeSelection, + pruneInvalidCodexRuntimeSelection, + removeCodexAccountIdFromSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -34,6 +47,22 @@ type ResolvedCodexIdentity = { workspaceAccountId: string | null } +export type CodexAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +type ManagedHomeLocation = { + managedHomePath: string + managedHomeRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxHomePath: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class CodexAccountService { // Why: account mutations read settings, do async work (login, rate-limit // refresh), then write settings. Without serialization, overlapping calls @@ -59,8 +88,8 @@ export class CodexAccountService { return this.getSnapshot() } - async addAccount(): Promise { - return this.serializeMutation(() => this.doAddAccount()) + async addAccount(target?: CodexAccountAddTarget): Promise { + return this.serializeMutation(() => this.doAddAccount(target)) } async reauthenticateAccount(accountId: string): Promise { @@ -75,9 +104,17 @@ export class CodexAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId)) } - private async doAddAccount(): Promise { + async selectAccountForTarget( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + } + + private async doAddAccount(target?: CodexAccountAddTarget): Promise { const accountId = randomUUID() - const managedHomePath = this.createManagedHome(accountId) + const managedHome = this.createManagedHome(accountId, target) + const { managedHomePath } = managedHome try { this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath) @@ -92,6 +129,9 @@ export class CodexAccountService { id: accountId, email: identity.email, managedHomePath, + managedHomeRuntime: managedHome.managedHomeRuntime, + wslDistro: managedHome.wslDistro, + wslLinuxHomePath: managedHome.wslLinuxHomePath, providerAccountId: identity.providerAccountId, workspaceLabel: identity.workspaceLabel, workspaceAccountId: identity.workspaceAccountId, @@ -101,9 +141,17 @@ export class CodexAccountService { } const settings = this.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const targetSelection = getCodexSelectionTargetForAccount(account) this.store.updateSettings({ codexManagedAccounts: [...settings.codexManagedAccounts, account], - activeCodexManagedAccountId: account.id + activeCodexManagedAccountId: + targetSelection.runtime === 'host' ? account.id : selection.host, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + selection, + account.id, + targetSelection + ) }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(account.id) @@ -111,8 +159,8 @@ export class CodexAccountService { // Why: the new account becomes active, so the previous active account is // now inactive and its last-known usage should be cached for the switcher. - const outgoingAccountId = settings.activeCodexManagedAccountId - await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) + const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() } catch (error) { this.safeRemoveManagedHome(managedHomePath) @@ -151,12 +199,15 @@ export class CodexAccountService { }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(accountId) - this.runtimeHome.syncForCurrentSelection() + this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) // Why: re-auth can change which actual Codex identity the managed home // points at. Force a fresh read immediately so the status bar cannot keep // showing the previous account's quota under the updated label. - await this.rateLimits.refreshForCodexAccountChange() + await this.rateLimits.refreshForCodexAccountChange( + undefined, + getCodexSelectionTargetForAccount(account) + ) return this.getSnapshot() } @@ -164,14 +215,17 @@ export class CodexAccountService { const account = this.requireAccount(accountId) const settings = this.store.getSettings() const nextAccounts = settings.codexManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeCodexAccountIdFromSelection( + normalizeCodexRuntimeSelection(settings), + accountId + ) const nextActiveId = - settings.activeCodexManagedAccountId === accountId - ? null - : settings.activeCodexManagedAccountId + settings.activeCodexManagedAccountId === accountId ? null : nextSelection.host this.store.updateSettings({ codexManagedAccounts: nextAccounts, - activeCodexManagedAccountId: nextActiveId + activeCodexManagedAccountId: nextActiveId, + activeCodexManagedAccountIdsByRuntime: nextSelection }) this.runtimeHome.syncForCurrentSelection() @@ -180,28 +234,49 @@ export class CodexAccountService { // so purge its cached usage to avoid stale entries. this.rateLimits.evictInactiveCodexCache(accountId) await this.rateLimits.refreshForCodexAccountChange( - settings.activeCodexManagedAccountId === accountId - ? settings.activeCodexManagedAccountId - : undefined + getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) === + accountId + ? accountId + : undefined, + getCodexSelectionTargetForAccount(account) ) return this.getSnapshot() } - private async doSelectAccount(accountId: string | null): Promise { + private async doSelectAccount( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + let effectiveTarget = target if (accountId !== null) { - this.requireAccount(accountId) + const account = this.requireAccount(accountId) + const accountTarget = getCodexSelectionTargetForAccount(account) + const requestedTarget = normalizeCodexAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeCodexAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Codex account belongs to a different runtime.') + } + effectiveTarget = accountTarget } const previousSettings = this.store.getSettings() - const outgoingAccountId = previousSettings.activeCodexManagedAccountId + const selection = normalizeCodexRuntimeSelection(previousSettings) + const outgoingAccountId = getSelectedCodexAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedCodexAccountIdForTarget(selection, accountId, effectiveTarget) this.store.updateSettings({ - activeCodexManagedAccountId: accountId + activeCodexManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeCodexManagedAccountIdsByRuntime: nextSelection }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.syncForCurrentSelection() + this.runtimeHome.syncForCurrentSelection(effectiveTarget) - await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() } @@ -211,7 +286,8 @@ export class CodexAccountService { accounts: settings.codexManagedAccounts .map((account) => this.toSummary(account)) .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: settings.activeCodexManagedAccountId + activeAccountId: normalizeCodexRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings) } } @@ -219,6 +295,8 @@ export class CodexAccountService { return { id: account.id, email: account.email, + managedHomeRuntime: account.managedHomeRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, providerAccountId: account.providerAccountId ?? null, workspaceLabel: account.workspaceLabel ?? null, workspaceAccountId: account.workspaceAccountId ?? null, @@ -239,25 +317,99 @@ export class CodexAccountService { private normalizeActiveSelection(): void { const settings = this.store.getSettings() - if (!settings.activeCodexManagedAccountId) { - return - } - const hasActiveAccount = settings.codexManagedAccounts.some( - (entry) => entry.id === settings.activeCodexManagedAccountId + const selection = normalizeCodexRuntimeSelection(settings) + const nextSelection = pruneInvalidCodexRuntimeSelection( + selection, + settings.codexManagedAccounts ) - if (!hasActiveAccount) { - this.store.updateSettings({ activeCodexManagedAccountId: null }) + const changed = + nextSelection.host !== selection.host || + JSON.stringify(nextSelection.wsl) !== JSON.stringify(selection.wsl) + if (changed) { + this.store.updateSettings({ + activeCodexManagedAccountId: nextSelection.host, + activeCodexManagedAccountIdsByRuntime: nextSelection + }) } } - private createManagedHome(accountId: string): string { + private createManagedHome( + accountId: string, + target?: CodexAccountAddTarget + ): ManagedHomeLocation { + const wslHome = this.tryCreateWslManagedHome(accountId, target) + if (wslHome) { + return wslHome + } + const managedHomePath = join(this.getManagedAccountsRoot(), accountId, 'home') mkdirSync(managedHomePath, { recursive: true }) // Why: Codex expects CODEX_HOME to be a concrete directory it can own. We // pre-create the directory and leave a marker so future cleanup code can // prove the path belongs to Orca before deleting anything. writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8') - return this.assertManagedHomePath(managedHomePath) + return { + managedHomePath: this.assertManagedHomePath(managedHomePath), + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null + } + } + + private tryCreateWslManagedHome( + accountId: string, + target?: CodexAccountAddTarget + ): ManagedHomeLocation | null { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + + const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] + const infoOutput = execFileSync( + 'wsl.exe', + [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + { encoding: 'utf-8', timeout: 5000 } + ) + const [rawDistro, rawHome] = infoOutput + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = target.wslDistro?.trim() || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Codex login.') + } + + const wslLinuxHomePath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home` + const markerPath = `${wslLinuxHomePath}/.orca-managed-home` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + + const managedHomePath = toWindowsWslPath(wslLinuxHomePath, distro) + let trustedManagedHomePath: string + try { + trustedManagedHomePath = this.assertManagedHomePath(managedHomePath) + } catch (error) { + this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId) + throw error + } + + return { + managedHomePath: trustedManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: distro, + wslLinuxHomePath + } } private safeSyncCanonicalConfigToManagedHomes(): void { @@ -277,15 +429,10 @@ export class CodexAccountService { } private syncCanonicalConfigToManagedHomes(): void { - const canonicalConfig = this.readCanonicalConfig() - if (canonicalConfig === null) { - return - } - const settings = this.store.getSettings() for (const account of settings.codexManagedAccounts) { try { - this.syncCanonicalConfigIntoManagedHome(account.managedHomePath, canonicalConfig) + this.syncCanonicalConfigIntoManagedHome(account.managedHomePath) } catch (error) { console.warn('[codex-accounts] Failed to sync managed config:', error) } @@ -294,7 +441,7 @@ export class CodexAccountService { private syncCanonicalConfigIntoManagedHome( managedHomePath: string, - canonicalConfig = this.readCanonicalConfig() + canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) ): void { if (canonicalConfig === null) { return @@ -322,6 +469,31 @@ export class CodexAccountService { } } + private readCanonicalConfigForManagedHome(managedHomePath: string): string | null { + const wslInfo = parseWslUncPath(managedHomePath) + if (!wslInfo) { + return this.readCanonicalConfig() + } + + const managedRootMarker = '/.local/share/orca/codex-accounts/' + const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) + if (markerIndex < 0) { + return null + } + const wslHome = wslInfo.linuxPath.slice(0, markerIndex) + const configPath = toWindowsWslPath(`${wslHome}/.codex/config.toml`, wslInfo.distro) + if (!existsSync(configPath)) { + return null + } + + try { + return readFileSync(configPath, 'utf-8') + } catch (error) { + console.warn('[codex-accounts] Failed to read WSL canonical config:', error) + return null + } + } + private writeManagedConfig(managedHomePath: string, contents: string): void { writeFileAtomically(join(managedHomePath, 'config.toml'), contents) } @@ -333,6 +505,62 @@ export class CodexAccountService { } private assertManagedHomePath(candidatePath: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/codex-accounts/') || + !wslInfo.linuxPath.endsWith('/home') + ) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-home"', + 'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Codex home is outside Orca account storage.', { + cause: error + }) + } + } + + if (wslInfo.linuxPath.split('/').includes('..')) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + if (!existsSync(candidatePath)) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + if (!existsSync(join(candidatePath, '.orca-managed-home'))) { + throw new Error('Managed Codex home is missing Orca ownership marker.') + } + return candidatePath + } + const rootPath = this.getManagedAccountsRoot() const resolvedCandidate = resolve(candidatePath) const resolvedRoot = resolve(rootPath) @@ -376,6 +604,48 @@ export class CodexAccountService { return canonicalCandidate } + private safeRemoveWslManagedHomeCandidate( + distro: string, + linuxHomePath: string, + expectedAccountId: string + ): void { + // Why: WSL home creation can fail after mkdir/marker write but before the + // path is trusted. Cleanup must prove the marker/account ID inside WSL. + try { + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(linuxHomePath)}`, + `expected_marker=${shellQuote(expectedAccountId)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)', + 'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)', + 'test -n "$candidate_real"', + 'test -n "$managed_root_real"', + 'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac', + 'test -f "$candidate_real/.orca-managed-home"', + 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', + 'rm -rf -- "$candidate_real"', + 'parent_dir=$(dirname -- "$candidate_real")', + 'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ) + } catch (error) { + console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error) + } + } + private safeRemoveManagedHome(candidatePath: string): void { let managedHomePath: string try { @@ -387,6 +657,15 @@ export class CodexAccountService { rmSync(managedHomePath, { recursive: true, force: true }) + if (parseWslUncPath(managedHomePath)) { + try { + rmSync(dirname(managedHomePath), { recursive: true, force: true }) + } catch { + // Best-effort cleanup + } + return + } + // Why: managed homes live at //home. Removing // just the home/ leaf leaves an empty / directory behind. try { @@ -405,22 +684,45 @@ export class CodexAccountService { private async runCodexLogin(managedHomePath: string): Promise { await new Promise((resolvePromise, rejectPromise) => { - const codexCommand = resolveCodexCommand() - // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file - // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute - // batch scripts directly without shell:true, but shell:true with an args - // array causes DEP0190 because args are concatenated, not escaped. - // Fix: detect batch scripts and invoke cmd.exe /c explicitly. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) - const child = spawn(spawnCmd, spawnArgs, { + const wslInfo = parseWslUncPath(managedHomePath) + const spawnConfig = wslInfo + ? { + command: 'wsl.exe', + args: [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` + ], + env: process.env, + codexCommand: 'codex' + } + : (() => { + const codexCommand = resolveCodexCommand() + // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file + // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute + // batch scripts directly without shell:true, but shell:true with an args + // array causes DEP0190 because args are concatenated, not escaped. + // Fix: detect batch scripts and invoke cmd.exe /c explicitly. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) + return { + command: spawnCmd, + args: spawnArgs, + env: { + ...process.env, + CODEX_HOME: managedHomePath + }, + codexCommand + } + })() + const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], // Why: route through cmd.exe for .cmd/.bat entrypoints would otherwise // flash a console window in the packaged GUI app on Windows. windowsHide: true, - env: { - ...process.env, - CODEX_HOME: managedHomePath - } + env: spawnConfig.env }) let settled = false @@ -457,7 +759,7 @@ export class CodexAccountService { // Why: ENOENT can mean either the codex binary doesn't exist OR the // script's shebang interpreter (node) isn't in PATH. When we resolved // codex to a full path, ENOENT almost certainly means node is missing. - const isBareCommand = codexCommand === 'codex' + const isBareCommand = spawnConfig.codexCommand === 'codex' const message = isEnoent ? isBareCommand ? 'Codex CLI not found.' diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c956c3a63..c22585e7c 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -134,6 +134,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // the override makes the daemon path behave the same as the in-process // LocalPtyProvider. shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, shellReadySupported: opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 42c385c94..c5cff0e99 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this class owns the daemon socket protocol, + request routing, stream fanout, and session lifecycle in one place so + renderer/daemon request semantics stay auditable across platform branches. */ import { createServer, type Server, type Socket } from 'net' import { randomUUID } from 'crypto' import { performance } from 'perf_hooks' @@ -220,6 +223,7 @@ export class DaemonServer { envToDelete: p.envToDelete, command: p.command, shellOverride: p.shellOverride, + terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, streamClient: { diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index daed3cced..1f3416b1c 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -871,6 +871,43 @@ describe('createPtySubprocess', () => { ) }) + it('uses the preferred WSL distro for daemon WSL terminals with Windows cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-distro-test-')) + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(cwd, { recursive: true, force: true }) + } + + const normalizedCwd = cwd.replace(/\\/g, '/') + const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) + const expectedLinuxCwd = driveMatch + ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` + : '/mnt/c' + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Debian', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + expect.objectContaining({ cwd: expect.any(String) }) + ) + }) + it('launches WSL for WSL worktree cwd even when a stale Windows shell override is present', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -912,7 +949,7 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', - env: { CODEX_HOME: 'C:\\Users\\jin\\.codex' } + env: { CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } }) } finally { if (platform) { @@ -924,7 +961,96 @@ describe('createPtySubprocess', () => { 'wsl.exe', ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"], expect.objectContaining({ - env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + env: expect.not.objectContaining({ + CODEX_HOME: expect.anything(), + ORCA_CODEX_HOME: expect.anything() + }) + }) + ) + }) + + it('does not pass a WSL managed Codex home into daemon Windows terminals', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + env: { + CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home', + ORCA_CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + env: expect.not.objectContaining({ + CODEX_HOME: expect.anything(), + ORCA_CODEX_HOME: expect.anything() + }) + }) + ) + }) + + it('routes daemon default WSL terminals to the Codex home distro without losing cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-codex-home-cwd-')) + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe', + env: { + CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home', + ORCA_CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(cwd, { recursive: true, force: true }) + } + + const normalizedCwd = cwd.replace(/\\/g, '/') + const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) + const expectedLinuxCwd = driveMatch + ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` + : '/mnt/c' + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + expect.objectContaining({ + env: expect.objectContaining({ + CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', + ORCA_CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', + WSLENV: expect.stringContaining('CODEX_HOME') + }) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index c84f003f2..dafe2ca5b 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -19,9 +19,10 @@ import { import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell' import { isPwshAvailable } from '../pwsh' -import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { parseWslPath } from '../wsl' +import { addWslEnvKeys } from '../wsl-env' import { getWslContextFromSessionId } from './wsl-session-context' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -38,6 +39,7 @@ export type PtySubprocessOptions = { * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } @@ -81,6 +83,13 @@ function removeInheritedDevAgentHookEndpoint( } } +function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed } : undefined +} + function removeInheritedElectronRunAsNode(env: Record): void { // Why: the daemon needs ELECTRON_RUN_AS_NODE=1 internally, but user shells // must not inherit it or nested Electron commands run as plain Node. @@ -205,13 +214,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null const sessionWslContext = process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(opts.terminalWindowsWslDistro) + : undefined // Why: WSL worktree cwd is the repo's execution environment. Older persisted // tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and // daemon-backed terminals enter the WSL distro just like LocalPtyProvider. let shellPath = cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] - let spawnCwd = opts.cwd || getDefaultCwd() + const requestedCwd = opts.cwd || getDefaultCwd() + let spawnCwd = requestedCwd let validationCwd = spawnCwd if (process.platform === 'win32') { @@ -245,18 +259,57 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext + sessionWslContext ?? preferredWslContext ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd - if ( - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && - isHostCodexHomeForWsl(env.CODEX_HOME) - ) { - // Why: Orca's selected Codex runtime home is host-local. WSL Codex must - // use its Linux-side ~/.codex instead of inheriting a Windows path. + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + const launchWslDistro = + cwdWslInfo?.distro ?? sessionWslContext?.distro ?? preferredWslContext?.distro + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + requestedCwd, + getDefaultCwd(), + { + distro: codexHomeWslInfo.distro + } + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + // Why: Orca's selected Codex runtime home is host-local. WSL Codex + // must use its Linux-side ~/.codex instead of a Windows path. + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR + // through Windows wsl.exe; non-default env vars need WSLENV import. + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use + // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore + // CODEX_HOME from it after user profiles run. delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME } } else { // Why: any Orca-injected overlay env that user rc files can clobber diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 672eddd48..90114e45b 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -19,6 +19,7 @@ export type CreateOrAttachOptions = { * daemon path honors per-tab shell selection the same way LocalPtyProvider * does. */ shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean streamClient: { onData: (data: string) => void; onExit: (code: number) => void } @@ -42,6 +43,7 @@ export type TerminalHostOptions = { envToDelete?: string[] command?: string shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' }) => SubprocessHandle // Why: on graceful shutdown, the host writes final checkpoints for all live @@ -106,6 +108,7 @@ export class TerminalHost { envToDelete: opts.envToDelete, command: opts.command, shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a1b5501db..f697d54e0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -71,6 +71,8 @@ export type CreateOrAttachRequest = { * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) * or the hard-coded powershell.exe fallback. */ shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. */ + terminalWindowsWslDistro?: string | null /** Why: the UI keeps PowerShell as one shell family, but the runtime may * need to substitute pwsh.exe for powershell.exe when the user selected * PowerShell 7+. Forward the persisted implementation choice so the daemon diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index 372407f9a..f6d568a70 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -4,6 +4,7 @@ import { parsePtySessionId } from './pty-session-id' export type WslSessionContext = { distro: string + treatPosixCwdAsWsl: true } export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined { @@ -12,5 +13,5 @@ export function getWslContextFromSessionId(sessionId: string): WslSessionContext ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro } : undefined + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } diff --git a/src/main/index.ts b/src/main/index.ts index 1a8b89080..8ba2c81c7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -57,10 +57,17 @@ import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock } from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' +import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -307,8 +314,8 @@ if (hasSingleInstanceLock) { enableMainProcessGpuFeatures() } -function prepareCodexRuntimeHomeForLaunch(): string { - const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch() +function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { + const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor @@ -487,7 +494,7 @@ function openMainWindow(): BrowserWindow { store, runtime, prepareCodexRuntimeHomeForLaunch, - () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { if (window.webContents.id === webContentsId) { @@ -1048,8 +1055,14 @@ app.whenReady().then(async () => { codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch()) - rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch()) + rateLimits.setCodexHomePathResolver((target) => + codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + rateLimits.setClaudeAuthPreparationResolver((target) => + claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) rateLimits.setSettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ homePath: app.getPath('home'), @@ -1058,14 +1071,32 @@ app.whenReady().then(async () => { browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) rateLimits.setInactiveClaudeAccountsResolver(() => { const settings = store!.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) return settings.claudeManagedAccounts - .filter((account) => account.id !== settings.activeClaudeManagedAccountId) - .map((account) => ({ id: account.id, managedAuthPath: account.managedAuthPath })) + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) }) rateLimits.setInactiveCodexAccountsResolver(() => { const settings = store!.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) return settings.codexManagedAccounts - .filter((account) => account.id !== settings.activeCodexManagedAccountId) + .filter((account) => !activeIds.has(account.id)) .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) }) const runtimeService = new OrcaRuntimeService(store, stats, { @@ -1249,7 +1280,7 @@ app.whenReady().then(async () => { runtime, prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), - () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store ) // Why: headless servers have no renderer graph publisher. Publish an diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 2fb289e3e..d24e737d6 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -10,7 +10,7 @@ import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/ import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailable } from '../pwsh' -import { isWslAvailable } from '../wsl' +import { isWslAvailable, listWslDistros } from '../wsl' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { authorizeExternalPath } from './filesystem-auth' import { @@ -117,6 +117,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp }) ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) + ipcMain.handle('wsl:listDistros', (): string[] => listWslDistros()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index 8d62c8c06..2fbaff81e 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,16 +1,25 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', () => claudeAccounts.addAccount()) + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => + claudeAccounts.addAccount(args) + ) ipcMain.handle('claudeAccounts:reauthenticate', (_event, args: { accountId: string }) => claudeAccounts.reauthenticateAccount(args.accountId) ) ipcMain.handle('claudeAccounts:remove', (_event, args: { accountId: string }) => claudeAccounts.removeAccount(args.accountId) ) - ipcMain.handle('claudeAccounts:select', (_event, args: { accountId: string | null }) => - claudeAccounts.selectAccount(args.accountId) + ipcMain.handle( + 'claudeAccounts:select', + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { + if (!args.runtime) { + return claudeAccounts.selectAccount(args.accountId) + } + return claudeAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 12b132eca..4e5f195e9 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -1,16 +1,28 @@ import { ipcMain } from 'electron' -import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' export function registerCodexAccountHandlers(codexAccounts: CodexAccountService): void { ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts()) - ipcMain.handle('codexAccounts:add', () => codexAccounts.addAccount()) + ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => + codexAccounts.addAccount(args) + ) ipcMain.handle('codexAccounts:reauthenticate', (_event, args: { accountId: string }) => codexAccounts.reauthenticateAccount(args.accountId) ) ipcMain.handle('codexAccounts:remove', (_event, args: { accountId: string }) => codexAccounts.removeAccount(args.accountId) ) - ipcMain.handle('codexAccounts:select', (_event, args: { accountId: string | null }) => - codexAccounts.selectAccount(args.accountId) + ipcMain.handle( + 'codexAccounts:select', + (_event, args: { accountId: string | null } & CodexAccountSelectionTarget) => { + if (!args.runtime) { + // Why: older renderer surfaces selected by account id only. Let the + // service infer the account's runtime instead of treating missing + // runtime as Windows/host and rejecting valid WSL accounts. + return codexAccounts.selectAccount(args.accountId) + } + return codexAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 6f7a492cc..05b6c92bd 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -62,7 +62,13 @@ import { runPreflightCheck } from './preflight' -type HandlerMap = Record Promise> +type HandlerMap = Record< + string, + ( + _event?: unknown, + args?: { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } + ) => Promise +> describe('preflight', () => { const originalPlatform = process.platform @@ -381,6 +387,30 @@ describe('preflight', () => { await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) }) + it('detects agents from the default WSL distro when requested', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[3]) + if (script === "command -v 'codex'") { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDefault: true })).resolves.toEqual(['codex']) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--', 'bash', '-lc', "command -v 'codex'"], + { encoding: 'utf-8', timeout: 5000 } + ) + }) + it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { // Why: the Agents settings Refresh button calls this path. It must (1) ask // the shell hydrator for a fresh PATH, (2) merge any new segments, then diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index c38939a22..8821a65ff 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -14,6 +14,7 @@ const execFileAsync = promisify(execFile) type PreflightRuntimeContext = { wslDistro?: string | null + wslDefault?: boolean } export type PreflightStatus = { @@ -50,24 +51,32 @@ export function _resetPreflightCache(): void { cached = null } +type WslPreflightTarget = { + distro?: string +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } async function execCommandInWsl( - distro: string, + target: WslPreflightTarget, command: string ): Promise<{ stdout: string; stderr: string }> { - return execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], { + const distroArgs = target.distro ? ['-d', target.distro] : [] + return execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { encoding: 'utf-8', timeout: 5000 }) as Promise<{ stdout: string; stderr: string }> } -async function isCommandAvailable(command: string, wslDistro?: string): Promise { +async function isCommandAvailable( + command: string, + wslTarget?: WslPreflightTarget +): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote(command)} --version`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) : execFileAsync(command, ['--version'])) return true } catch { @@ -78,11 +87,11 @@ async function isCommandAvailable(command: string, wslDistro?: string): Promise< // Why: `which`/`where` is faster than spawning the agent binary itself and avoids // triggering any agent-specific startup side-effects. This gives a reliable // PATH-based check without requiring `--version` support from each agent. -async function isCommandOnPath(command: string, wslDistro?: string): Promise { +async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - const { stdout } = wslDistro - ? await execCommandInWsl(wslDistro, `command -v ${shellQuote(command)}`) + const { stdout } = wslTarget + ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) : await execFileAsync(finder, [command], { encoding: 'utf-8' }) return stdout .split(/\r?\n/) @@ -98,18 +107,26 @@ const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).map(([id, config]) cmd: config.detectCmd })) -function getPreflightWslDistro(context?: PreflightRuntimeContext): string | null { +function getPreflightWslTarget(context?: PreflightRuntimeContext): WslPreflightTarget | null { + if (process.platform !== 'win32') { + return null + } const distro = context?.wslDistro?.trim() - return process.platform === 'win32' && distro ? distro : null + if (distro) { + return { distro } + } + return context?.wslDefault ? {} : null } async function detectCommandRuntime( command: string, context?: PreflightRuntimeContext -): Promise<{ installed: boolean; wslDistro?: string }> { - const wslDistro = getPreflightWslDistro(context) - if (wslDistro && (await isCommandAvailable(command, wslDistro))) { - return { installed: true, wslDistro } +): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return (await isCommandAvailable(command, wslTarget)) + ? { installed: true, wslTarget } + : { installed: false } } if (await isCommandAvailable(command)) { return { installed: true } @@ -118,12 +135,11 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslDistro = getPreflightWslDistro(context) + const wslTarget = getPreflightWslTarget(context) const checks = await Promise.all( KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ id, - installed: - (wslDistro ? await isCommandOnPath(cmd, wslDistro) : false) || (await isCommandOnPath(cmd)) + installed: await isCommandOnPath(cmd, wslTarget ?? undefined) })) ) return checks.filter((c) => c.installed).map((c) => c.id) @@ -178,10 +194,10 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return result.agents } -async function isGhAuthenticated(wslDistro?: string): Promise { +async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote('gh')} auth status`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) : execFileAsync('gh', ['auth', 'status'], { encoding: 'utf-8' })) @@ -201,10 +217,10 @@ async function isGhAuthenticated(wslDistro?: string): Promise { // Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth // status to stderr in some versions and stdout in others; check both. -async function isGlabAuthenticated(wslDistro?: string): Promise { +async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote('glab')} auth status`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) : execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' })) return true } catch (error) { @@ -219,7 +235,7 @@ export async function runPreflightCheck( force = false, context?: PreflightRuntimeContext ): Promise { - const cacheable = !getPreflightWslDistro(context) + const cacheable = !getPreflightWslTarget(context) if (cacheable && cached && !force) { return cached } @@ -241,8 +257,8 @@ export async function runPreflightCheck( ]) const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghProbe.installed ? isGhAuthenticated(ghProbe.wslDistro) : Promise.resolve(false), - glabProbe.installed ? isGlabAuthenticated(glabProbe.wslDistro) : Promise.resolve(false), + ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), getBitbucketAuthStatus(), getAzureDevOpsAuthStatus(), getGiteaAuthStatus() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c48a5ff8e..4ffb18fac 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -24,6 +24,7 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import { isClaudeAuthSwitchInProgress, @@ -56,6 +57,8 @@ import { } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -286,6 +289,38 @@ function shouldSkipCodexHomeEnvForWindowsShell( } const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const +type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null +type PrepareClaudeAuth = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +function getCodexSelectionTargetForPty( + shellPath: string | undefined, + cwd: string | undefined, + wslDistro?: string | null +): CodexAccountSelectionTarget { + const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null + if (isWslShellName(shellPath) || wslPath) { + return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } + } + return { runtime: 'host' } +} + +function getCompatibleSelectedCodexHomePath( + target: CodexAccountSelectionTarget, + selectedCodexHomePath: string | null +): string | null { + if (!selectedCodexHomePath) { + return null + } + const wslInfo = parseWslPath(selectedCodexHomePath) + if (target.runtime === 'wsl') { + return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null + } + return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) + ? null + : selectedCodexHomePath +} function readEnvWithProcessFallback( baseEnv: Record, @@ -795,9 +830,9 @@ export function unbindLocalProviderListeners(): void { export function registerPtyHandlers( mainWindow: BrowserWindow, runtime?: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, store?: Store ): void { // Remove any previously registered handlers so we can re-register them @@ -832,13 +867,19 @@ export function registerPtyHandlers( : undefined, pwshAvailable: () => isPwshAvailable(), buildSpawnEnv: (id, baseEnv, ctx) => { + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - // Why: WSL's inner shell cannot use a Windows userData CODEX_HOME. - // Leave Linux Codex on its native ~/.codex until we own a WSL home. - skipCodexHomeEnv: ctx?.isWsl === true, + selectedCodexHomePath, + skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: ctx?.command, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) @@ -1177,7 +1218,17 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + const daemonShellOverride = + process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined + const codexSelectionTarget = getCodexSelectionTargetForPty( + daemonShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -1195,12 +1246,16 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - const daemonShellOverride = - process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null const skipCodexHomeEnv = - isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) && + !selectedCodexHomePath if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -1208,7 +1263,7 @@ export function registerPtyHandlers( env = buildPtyHostEnv(sessionId, env ?? {}, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + selectedCodexHomePath, skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, @@ -1247,6 +1302,7 @@ export function registerPtyHandlers( } if (process.platform === 'win32' && !args.connectionId) { spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell + spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -1474,7 +1530,18 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + const initialShellOverride = + args.shellOverride ?? + (process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined) + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -1602,8 +1669,21 @@ export function registerPtyHandlers( (process.platform === 'win32' && !args.connectionId ? getSettings?.()?.terminalWindowsShell : undefined) + const codexSelectionTarget = getCodexSelectionTargetForPty( + effectiveShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null const skipCodexHomeEnv = - isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) && + !selectedCodexHomePath if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -1627,7 +1707,7 @@ export function registerPtyHandlers( buildPtyHostEnv(sessionIdForEnv, env, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + selectedCodexHomePath, skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, @@ -1704,6 +1784,7 @@ export function registerPtyHandlers( // the persisted implementation choice through spawnOptions so both the // in-process and daemon-backed PTY paths can resolve the same effective // executable without inventing a fourth top-level shell. + spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -2217,9 +2298,9 @@ export function registerPtyHandlers( export function registerHeadlessPtyRuntime( runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, store?: Store ): void { // Why: headless `orca serve` has no renderer window, but the runtime still diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 703e97617..63a506494 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -1,9 +1,16 @@ import { ipcMain } from 'electron' import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:get', () => rateLimits.getState()) ipcMain.handle('rateLimits:refresh', () => rateLimits.refresh()) + ipcMain.handle('rateLimits:refreshCodexForTarget', (_event, target: RateLimitRuntimeTarget) => + rateLimits.refreshCodexForTarget(target) + ) + ipcMain.handle('rateLimits:refreshClaudeForTarget', (_event, target: RateLimitRuntimeTarget) => + rateLimits.refreshClaudeForTarget(target) + ) ipcMain.handle('rateLimits:setPollingInterval', (_event, ms: number) => rateLimits.setPollingInterval(ms) ) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e06841f40..09b2dc3e2 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -164,6 +164,7 @@ describe('LocalPtyProvider', () => { provider.configure({ buildSpawnEnv: (_id, env) => { env.CODEX_HOME = 'C:\\Users\\jin\\.codex' + env.ORCA_CODEX_HOME = 'C:\\Users\\jin\\.codex' return env } }) @@ -177,6 +178,30 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('does not pass a WSL managed Codex home into Windows terminals', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() }) it('preserves an explicit Linux Codex home for WSL terminals', async () => { @@ -197,6 +222,82 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.codex-alt') + expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') + }) + + it('translates a WSL managed Codex home before launching a WSL terminal', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.local/share/orca/codex-accounts/a/home') + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBe( + '/home/jin/.local/share/orca/codex-accounts/a/home' + ) + expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') + expect(spawnCall[2].env.WSLENV).toContain('ORCA_CODEX_HOME') + }) + + it('does not pass a WSL managed Codex home into a different WSL distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('uses the preferred WSL distro for Windows cwd WSL terminals', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'bash', + '-c', + "cd '/mnt/c/Users/jin/repo' && exec bash -l" + ]) }) it('does not inherit parent Orca pane identity when caller omits pane env', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 95bd66b6f..7e6772b97 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -31,7 +31,8 @@ import { STARTUP_COMMAND_READY_MAX_WAIT_MS } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' +import { addWslEnvKeys } from '../wsl-env' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -93,10 +94,17 @@ function disposePtyListeners(id: string): void { function getWslContextFromWorktreeId( worktreeId: string | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro } : undefined + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined +} + +function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed } : undefined } function clearPtyState(id: string): void { @@ -146,7 +154,7 @@ export type LocalPtyProviderOptions = { buildSpawnEnv?: ( id: string, baseEnv: Record, - ctx?: { command?: string; isWsl?: boolean } + ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null } ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ @@ -183,6 +191,10 @@ export class LocalPtyProvider implements IPtyProvider { const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null const worktreeWslContext = process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) + : undefined let shellPath: string let shellArgs: string[] @@ -236,7 +248,12 @@ export class LocalPtyProvider implements IPtyProvider { // same shellArgs for the same (shell, cwd) pair. The helper keeps CJK // UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the // wsl.exe /mnt/ cwd translation in one place. - const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, worktreeWslContext) + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + cwd, + defaultCwd, + worktreeWslContext ?? preferredWslContext + ) shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd @@ -290,17 +307,56 @@ export class LocalPtyProvider implements IPtyProvider { } const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const launchWslDistro = + wslInfo?.distro ?? worktreeWslContext?.distro ?? preferredWslContext?.distro ?? null const finalEnv = this.opts.buildSpawnEnv - ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, isWsl: isWslShell }) + ? this.opts.buildSpawnEnv(id, spawnEnv, { + command: args.command, + isWsl: isWslShell, + wslDistro: launchWslDistro + }) : spawnEnv - if ( - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && - isHostCodexHomeForWsl(finalEnv.CODEX_HOME) - ) { - // Why: Orca's selected Codex runtime home is host-local. WSL Codex must - // use its Linux-side ~/.codex instead of inheriting a Windows path. - delete finalEnv.CODEX_HOME + if (process.platform === 'win32') { + const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } else { + finalEnv.CODEX_HOME = codexHomeWslInfo.linuxPath + finalEnv.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, { + distro: codexHomeWslInfo.distro + }) + shellArgs = resolved.shellArgs + effectiveCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + } + } + } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { + // Why: Orca's selected Codex runtime home is host-local. WSL Codex + // must use its Linux-side ~/.codex instead of a Windows path. + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } else if (finalEnv.CODEX_HOME) { + addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (finalEnv.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR + // through Windows wsl.exe; non-default env vars need WSLENV import. + addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { + // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use + // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore + // CODEX_HOME from it after user profiles run. + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } } if (!wslInfo && process.platform !== 'win32') { // Why: any Orca-injected overlay env that user rc files can clobber diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1288b8b07..7844b3269 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -41,6 +41,9 @@ export type PtySpawnOptions = { * changing the user's persistent default shell setting. Only consulted on * Windows; ignored on macOS/Linux where shell selection is not exposed. */ shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. Worktree/session + * distro still wins when the cwd already identifies a WSL distro. */ + terminalWindowsWslDistro?: string | null /** Why: PowerShell is the top-level shell family in product terms, but on * Windows we may need to choose between inbox Windows PowerShell 5.1 and * pwsh.exe at spawn time. Threading the persisted implementation choice diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 7f2e13d59..b702efaff 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -131,7 +131,7 @@ describe('resolveWindowsShellLaunchArgs', () => { 'wsl.exe', '/home/alice/repo/subdir', 'C:\\Users\\alice', - { distro: 'Ubuntu' } + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } ) expect(result.shellArgs).toEqual([ diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 5ff2ed265..c032aa151 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -27,6 +27,7 @@ export type WindowsShellLaunchArgs = { export type WindowsShellWslContext = { distro: string + treatPosixCwdAsWsl?: boolean } function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { @@ -84,7 +85,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, @@ -94,7 +95,7 @@ export function resolveWindowsShellLaunchArgs( const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c' return { - shellArgs: buildWslShellArgs(linuxCwd), + shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro), effectiveCwd: defaultCwd, validationCwd: cwd } diff --git a/src/main/pty/codex-home-wsl-env.test.ts b/src/main/pty/codex-home-wsl-env.test.ts index c98958cbd..1077ea985 100644 --- a/src/main/pty/codex-home-wsl-env.test.ts +++ b/src/main/pty/codex-home-wsl-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isHostCodexHomeForWsl } from './codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from './codex-home-wsl-env' describe('isHostCodexHomeForWsl', () => { it('matches Windows paths that WSL Codex cannot use as CODEX_HOME', () => { @@ -14,4 +14,10 @@ describe('isHostCodexHomeForWsl', () => { expect(isHostCodexHomeForWsl('')).toBe(false) expect(isHostCodexHomeForWsl(undefined)).toBe(false) }) + + it('matches Linux paths that host Codex cannot use on Windows', () => { + expect(isWslCodexHomeForHost('/home/jin/.local/share/orca/codex-accounts/a/home')).toBe(true) + expect(isWslCodexHomeForHost('C:\\Users\\jin\\.codex')).toBe(false) + expect(isWslCodexHomeForHost(undefined)).toBe(false) + }) }) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index 13efe2ad8..b642e7661 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -5,3 +5,11 @@ export function isHostCodexHomeForWsl(value: string | undefined): boolean { } return /^[A-Za-z]:(?:[\\/]|$)/.test(trimmed) || trimmed.startsWith('\\\\') } + +export function isWslCodexHomeForHost(value: string | undefined): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + return trimmed.startsWith('/') +} diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 6bca4c914..b68ce2cd6 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -99,6 +99,30 @@ describe('fetchClaudeRateLimits', () => { } }) + it('does not read host credentials when WSL config resolution fails', async () => { + await expect( + fetchClaudeRateLimits({ + authPreparation: { + configDir: '/Users/test/.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'WSL Claude config unavailable for Ubuntu' + }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('reads scoped default-config Keychain credentials for OAuth usage fetches', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { @@ -168,7 +192,10 @@ describe('fetchClaudeRateLimits', () => { status: 'ok' }) - expect(readFileMock).toHaveBeenCalledWith('/Users/test/.claude/.credentials.json', 'utf-8') + expect(readFileMock).toHaveBeenCalledWith( + join('/Users/test/.claude', '.credentials.json'), + 'utf-8' + ) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 0d56e06fb..22d2aed30 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- Why: this module keeps Claude credential source ordering, OAuth usage fetch semantics, and PTY fallback behavior together so subscription usage state cannot drift across code paths. */ +import { existsSync, lstatSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { net, session } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { parseWslUncPath } from '../../shared/wsl-paths' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { @@ -307,6 +309,17 @@ async function fetchViaOAuth(token: string): Promise { export async function fetchClaudeRateLimits(options?: { authPreparation?: ClaudeRuntimeAuthPreparation }): Promise { + if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + // Path A: try OAuth API if we have a genuine OAuth token const oauthCredentials = await readOAuthCredentials(options?.authPreparation?.configDir) if (oauthCredentials.token) { @@ -368,6 +381,9 @@ export async function fetchClaudeRateLimits(options?: { export type InactiveClaudeAccountInfo = { id: string managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null } // Why: reads an inactive account's OAuth token directly from its managed @@ -375,6 +391,14 @@ export type InactiveClaudeAccountInfo = { // Using ClaudeRuntimeAuthService would overwrite the active account's auth. async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise { try { + if (account.managedAuthRuntime === 'wsl') { + const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) + if (!managedAuthPath) { + return null + } + const raw = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + return raw ? parseOAuthCredentialsJson(raw).token : null + } const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { adoptLegacyMarker: true }) @@ -395,6 +419,36 @@ async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promis } } +function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo): string | null { + if (process.platform !== 'win32') { + return null + } + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { + return null + } + const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath + if ( + !linuxPath.includes('/.local/share/orca/claude-accounts/') || + !linuxPath.endsWith(`/${account.id}/auth`) + ) { + return null + } + try { + const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') + if ( + !existsSync(markerPath) || + lstatSync(markerPath).isSymbolicLink() || + readFileSync(markerPath, 'utf-8').trim() !== account.id + ) { + return null + } + return account.managedAuthPath + } catch { + return null + } +} + export async function fetchManagedAccountUsage( account: InactiveClaudeAccountInfo ): Promise { diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 8f0dc78bc..1e31e26af 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -129,6 +129,10 @@ const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + function describeClaudeUsageFailure(output: string): string { if (RATE_LIMITED_RE.test(output)) { return 'Claude usage is rate limited right now.' @@ -167,14 +171,34 @@ export async function fetchViaPty(options?: { // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 // and ensure the command path is properly quoted if it contains spaces. const isWin32 = process.platform === 'win32' - const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = isWin32 ? ['/c', `"${claudeCommand}"`] : [] - const spawnEnv = applyClaudeEnvPatch( { ...process.env, TERM: 'xterm-256color' } as Record, options?.authPreparation?.envPatch ?? {}, { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } ) + const authPreparation = options?.authPreparation + const wslConfig = + authPreparation?.runtime === 'wsl' && + authPreparation.wslDistro && + authPreparation.wslLinuxConfigDir + ? { + distro: authPreparation.wslDistro, + linuxConfigDir: authPreparation.wslLinuxConfigDir + } + : null + const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand + const spawnArgs = wslConfig + ? [ + '-d', + wslConfig.distro, + '--', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}; exec claude` + ] + : isWin32 + ? ['/c', `"${claudeCommand}"`] + : [] const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts new file mode 100644 index 000000000..8ae7722f6 --- /dev/null +++ b/src/main/rate-limits/claude-rate-limit-target.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' + +function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { + const next = { ...settings } as Partial + delete next.localAccountRuntime + return next as GlobalSettings +} + +describe('getInitialClaudeRateLimitTarget', () => { + it('uses the configured WSL account runtime before agent detection runtime', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Fedora', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) + }) + + it('uses the single selected WSL account distro when account runtime is WSL default', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the configured WSL agent runtime and distro', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsWslDistro: 'Debian' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses a single WSL-only active account after restart', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }) + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('keeps explicit host runtime on host', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host', + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts new file mode 100644 index 000000000..68b33cdd9 --- /dev/null +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -0,0 +1,71 @@ +import type { GlobalSettings } from '../../shared/types' +import { + getClaudeWslSelectionKey, + normalizeClaudeRuntimeSelection, + type ClaudeAccountSelectionTarget +} from '../claude-accounts/runtime-selection' + +function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { + const selection = normalizeClaudeRuntimeSelection(settings) + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + const [distroKey] = selectedWslEntries[0] + return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey +} + +export function getInitialClaudeRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): ClaudeAccountSelectionTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + if (settings.localAccountRuntime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAccountWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? + getSingleSelectedWslDistro(settings) + } + } + + if ( + settings.localAgentRuntime === 'wsl' || + (settings.localAgentRuntime == null && + platform === 'win32' && + settings.terminalWindowsShell === 'wsl.exe') + ) { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAgentWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) + } + } + + const selection = normalizeClaudeRuntimeSelection(settings) + if (!selection.host) { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length === 1) { + const [distroKey] = selectedWslEntries[0] + return { + runtime: 'wsl', + wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey + } + } + } + + return { runtime: 'host' } +} diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 9a1272230..77da7f8d3 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -110,6 +110,23 @@ describe('fetchCodexRateLimits', () => { }) }) + it('does not start the PTY fallback when disabled for background account previews', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + rpcChild.emit('close') + await vi.advanceTimersByTimeAsync(0) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error' + }) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) @@ -152,4 +169,138 @@ describe('fetchCodexRateLimits', () => { expect(result.session?.windowMinutes).toBe(300) expect(result.weekly?.windowMinutes).toBe(10080) }) + + it('runs rate-limit RPC through WSL when the Codex home is a WSL managed account', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 11 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + expect(childSpawnMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'" + ], + expect.objectContaining({ + env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { + const originalPlatform = process.platform + const originalCodexHome = process.env.CODEX_HOME + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + process.env.CODEX_HOME = 'C:\\Users\\alice\\.codex' + + const rpcChild = makeRpcChild() + const ptyHandlers: { onData?: (data: string) => void } = {} + childSpawnMock.mockReturnValue(rpcChild) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + try { + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + rpcChild.emit('close') + await vi.advanceTimersByTimeAsync(0) + + expect(ptySpawnMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex " + ], + expect.objectContaining({ + env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + }) + ) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + onPtyData('>') + onPtyData('5h limit: 17%\nWeekly limit: 23%\n') + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: { usedPercent: 17 }, + weekly: { usedPercent: 23 }, + status: 'ok' + }) + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c7f81df4b..feec38d98 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -7,13 +7,16 @@ import { resolveCodexCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { parseWslUncPath } from '../../shared/wsl-paths' const RPC_TIMEOUT_MS = 10_000 +const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null + allowPtyFallback?: boolean } // --------------------------------------------------------------------------- @@ -43,6 +46,37 @@ type RpcRateLimitsResponse = { rateLimits?: RpcRateLimitsResult } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +function buildWslCodexCommand( + codexHomePath: string, + args: string[] +): { + command: string + args: string[] +} | null { + const wslInfo = parseWslUncPath(codexHomePath) + if (process.platform !== 'win32' || !wslInfo) { + return null + } + const script = [ + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, + `exec codex ${args.map(shellQuote).join(' ')}` + ].join('; ') + return { + command: 'wsl.exe', + args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + } +} + +function cloneProcessEnvWithoutCodexHome(): NodeJS.ProcessEnv { + const env = { ...process.env } + delete env.CODEX_HOME + return env +} + function buildRpcMessage(id: number, method: string, params?: unknown): string { return `${JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })}\n` } @@ -95,18 +129,21 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { const pty = await import('node-pty') - const codexCommand = resolveCodexCommand() + const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null + const codexCommand = wslCodex ? 'codex' : resolveCodexCommand() // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — // those need cmd.exe as an interpreter. resolveCodexCommand() may also fall @@ -328,8 +366,8 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise((resolve) => { let output = '' @@ -341,9 +379,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise void }[] = [] @@ -450,9 +488,22 @@ export async function fetchCodexRateLimits( if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') { return rpcResult } + if (options?.allowPtyFallback === false) { + return rpcResult + } // Why: app-server can fail independently of the interactive CLI. Keep the // status-bar useful by trying the older /status PTY reader on RPC errors. } catch { + if (options?.allowPtyFallback === false) { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'RPC failed', + status: 'error' + } + } // RPC failed — fall through to PTY } diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts new file mode 100644 index 000000000..739adc17d --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-target.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { + const next = { ...settings } as Partial + delete next.localAccountRuntime + return next as GlobalSettings +} + +describe('getInitialCodexRateLimitTarget', () => { + it('uses the configured WSL account runtime before agent detection runtime', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Fedora', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) + }) + + it('uses the single selected WSL account distro when account runtime is WSL default', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the configured WSL agent runtime and distro', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsWslDistro: 'Debian' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses a single WSL-only active account after restart', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }) + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('keeps explicit host runtime on host', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host', + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts new file mode 100644 index 000000000..0afb9d19e --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -0,0 +1,73 @@ +import type { GlobalSettings } from '../../shared/types' +import { + getWslSelectionKey, + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' + +function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { + const selection = normalizeCodexRuntimeSelection(settings) + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + const [distroKey] = selectedWslEntries[0] + return distroKey === getWslSelectionKey(null) ? null : distroKey +} + +export function getInitialCodexRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): CodexAccountSelectionTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + if (settings.localAccountRuntime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAccountWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? + getSingleSelectedWslDistro(settings) + } + } + + if ( + settings.localAgentRuntime === 'wsl' || + (settings.localAgentRuntime == null && + platform === 'win32' && + settings.terminalWindowsShell === 'wsl.exe') + ) { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAgentWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) + } + } + + const selection = normalizeCodexRuntimeSelection(settings) + if (!selection.host) { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length === 1) { + const [distroKey] = selectedWslEntries[0] + // Why: after restart there is no last-clicked switcher target, but a + // single WSL-only active account is the least surprising quota context. + return { + runtime: 'wsl', + wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey + } + } + } + + return { runtime: 'host' } +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 3c0310569..10d5a74c4 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -230,6 +230,201 @@ describe('RateLimitService', () => { expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) + it('passes the selected WSL Codex home into active account rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + service.setCodexHomePathResolver(resolver) + + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refreshForCodexAccountChange(null, { runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ codexHomePath: wslCodexHome }) + ) + }) + + it('uses the initialized WSL target for active Codex rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + service.setCodexHomePathResolver(resolver) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ codexHomePath: wslCodexHome }) + ) + }) + + it('does not fetch host Codex usage when WSL home resolution fails', async () => { + const service = new RateLimitService() + const resolver = vi.fn(() => null) + service.setCodexHomePathResolver(resolver) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + expect(service.getState().codex).toMatchObject({ + provider: 'codex', + status: 'error', + error: 'WSL Codex home unavailable for Ubuntu' + }) + }) + + it('uses the initialized WSL target for active Claude rate-limit fetches', async () => { + const service = new RateLimitService() + const resolver = vi.fn(async (target) => ({ + configDir: + target?.runtime === 'wsl' + ? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude' + : 'C:\\Users\\jin\\.claude', + runtime: target?.runtime ?? 'host', + 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) + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + stripAuthEnv: true + }) + }) + expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('does not cache host Codex usage under an outgoing WSL account', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + service.setCodexHomePathResolver((target) => + target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + ) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('wsl-account-1', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(service.getState().inactiveCodexAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) + ) + }) + + it('does not cache host Claude usage under an outgoing WSL account', async () => { + const service = new RateLimitService() + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) + service.setClaudeAuthPreparationResolver(async (target) => ({ + configDir: + target?.runtime === 'wsl' + ? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude' + : 'C:\\Users\\jin\\.claude', + runtime: target?.runtime ?? 'host', + 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' + })) + + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(okProvider('claude', 20, Date.now())) + .mockResolvedValueOnce(okProvider('claude', 40, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + await service.refreshForClaudeAccountChange('wsl-account-1', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(service.getState().inactiveClaudeAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) + ) + }) + + it('passes WSL Codex managed homes into inactive account rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-1', managedHomePath: wslCodexHome } + ]) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now())) + + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledWith({ + codexHomePath: wslCodexHome, + allowPtyFallback: false + }) + expect(service.getState().inactiveCodexAccounts).toEqual([ + { + accountId: 'account-1', + claude: expect.objectContaining({ + session: expect.objectContaining({ usedPercent: 33 }) + }), + updatedAt: expect.any(Number), + isFetching: false + } + ]) + }) + + it('does not start overlapping inactive Codex preview fetches', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + ]) + vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) + + const firstFetch = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + + accountFetch.resolve(okProvider('codex', 50, Date.now())) + await firstFetch + }) + it('preserves Gemini buckets through getState after fetch', async () => { const service = new RateLimitService() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 1ef3557e9..82ac7753f 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -11,14 +11,29 @@ import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetche import type { InactiveClaudeAccountInfo } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget +} from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget, + type NormalizedCodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' export type InactiveCodexAccountInfo = { id: string managedHomePath: string } +type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null +type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + // Why: Claude's subscription usage endpoint has a tight request budget. Quota // state is informational, so prefer keeping a recent snapshot over polling it // into 429s during long focused Orca sessions. @@ -57,8 +72,16 @@ export class RateLimitService { private claudeFetchGeneration = 0 private opencodeFetchGeneration = 0 private lastOpencodeConfigHash = '' - private codexHomePathResolver: (() => string | null) | null = null - private claudeAuthPreparationResolver: (() => Promise) | null = null + private codexHomePathResolver: CodexHomePathResolver | null = null + private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } private settingsResolver: | (() => { opencodeSessionCookie: string @@ -86,14 +109,22 @@ export class RateLimitService { } } - setCodexHomePathResolver(resolver: () => string | null): void { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { this.codexHomePathResolver = resolver } - setClaudeAuthPreparationResolver(resolver: () => Promise): void { + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { this.claudeAuthPreparationResolver = resolver } + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + setSettingsResolver( resolver: () => { opencodeSessionCookie: string @@ -152,6 +183,8 @@ export class RateLimitService { getState(): RateLimitState { return { ...this.state, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( this.inactiveClaudeCache, this.inactiveClaudeFetching @@ -172,10 +205,19 @@ export class RateLimitService { return this.getState() } - async refreshForCodexAccountChange(outgoingAccountId?: string | null): Promise { - if (outgoingAccountId && this.state.codex?.session) { + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + if ( + outgoingAccountId && + this.state.codex?.session && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) } + this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old @@ -189,12 +231,34 @@ export class RateLimitService { return this.getState() } - async refreshForClaudeAccountChange(outgoingAccountId?: string | null): Promise { + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) // Why: snapshot the outgoing account's usage before clearing it so the // inline usage bars in the switcher can show last-known data immediately. - if (outgoingAccountId && this.state.claude?.session) { + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) } + this.claudeFetchTarget = nextTarget this.inactiveClaudeAccountsGeneration += 1 this.pruneInactiveClaudeState() this.claudeFetchGeneration += 1 @@ -207,6 +271,19 @@ export class RateLimitService { return this.getState() } + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + async fetchInactiveClaudeAccountsOnOpen(): Promise { if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return @@ -273,6 +350,9 @@ export class RateLimitService { if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } + if (this.inactiveCodexFetching.size > 0) { + return + } const accounts = this.inactiveCodexAccountsResolver?.() ?? [] if (accounts.length === 0) { return @@ -288,7 +368,13 @@ export class RateLimitService { // Why: fetchCodexRateLimits already accepts codexHomePath, so we can // point it at the managed account's home directory directly without // materializing credentials into the shared runtime location. - const fresh = await fetchCodexRateLimits({ codexHomePath: account.managedHomePath }) + // Why: opening the account switcher should never start hidden PTYs for + // every inactive account. On Windows that fallback can crash inside + // ConPTY; RPC-only is enough for this non-critical preview surface. + const fresh = await fetchCodexRateLimits({ + codexHomePath: account.managedHomePath, + allowPtyFallback: false + }) const cached = this.inactiveCodexCache.get(account.id) ?? null this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) } catch { @@ -526,6 +612,50 @@ export class RateLimitService { } } + private isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + private isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + private getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + private getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + private shouldAllowCodexPtyFallback(): boolean { + // Why: quota UI refreshes run in the background. On Windows, hidden PTY + // fallback can crash inside ConPTY, so prefer RPC-only degradation there. + return process.platform !== 'win32' + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' @@ -544,11 +674,13 @@ export class RateLimitService { } private async runFetchAllCycle(): Promise { - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeTarget = this.claudeFetchTarget + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration - const codexHomePath = this.codexHomePathResolver?.() ?? null - const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' + const codexTarget = this.codexFetchTarget + const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration const previousState = this.state const settings = this.settingsResolver?.() @@ -579,9 +711,16 @@ export class RateLimitService { : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go') }) + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }), - fetchCodexRateLimits({ codexHomePath }), + missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback() + }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined) ]) @@ -641,14 +780,16 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const latestCodexHomePath = this.codexHomePathResolver?.() ?? null - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' + const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration // Why: account switches can race in-flight Codex fetches. Only apply a @@ -675,8 +816,9 @@ export class RateLimitService { } private async runFetchCodexOnlyCycle(): Promise { - const codexHomePath = this.codexHomePathResolver?.() ?? null - const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' + const codexTarget = this.codexFetchTarget + const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration const previousState = this.state @@ -685,7 +827,17 @@ export class RateLimitService { codex: this.withFetchingStatus(previousState.codex, 'codex') }) - const codex = await fetchCodexRateLimits({ codexHomePath }).catch( + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback() + }) + ).catch( (err): ProviderRateLimits => ({ provider: 'codex', session: null, @@ -696,8 +848,8 @@ export class RateLimitService { }) ) - const latestCodexHomePath = this.codexHomePathResolver?.() ?? null - const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance @@ -710,7 +862,8 @@ export class RateLimitService { } private async runFetchClaudeOnlyCycle(): Promise { - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeTarget = this.claudeFetchTarget + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const previousState = this.state @@ -731,10 +884,12 @@ export class RateLimitService { }) ) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) this.updateState({ ...this.state, diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 1c38564dc..a6207bc02 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -1,4 +1,6 @@ -/* eslint-disable max-lines */ +/* eslint-disable max-lines -- Why: runtime file command tests share mocked fs, + authorization, and watcher lifecycle fixtures; splitting would duplicate the + setup that makes cross-command filesystem behavior comparable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index 96f50bcd0..b7b913351 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -5,9 +5,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' const originalEnv = { ...process.env } +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const tempDirs: string[] = [] afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } for (const key of Object.keys(process.env)) { if (!(key in originalEnv)) { delete process.env[key] @@ -20,6 +24,7 @@ afterEach(() => { }) function makeHome(): string { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-')) tempDirs.push(dir) process.env.HOME = dir @@ -40,7 +45,7 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: expect.objectContaining({ - OPENCODE_CONFIG_DIR: join(home, 'company/opencode') + OPENCODE_CONFIG_DIR: `${home}/company/opencode` }) }) }) @@ -69,7 +74,7 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: expect.objectContaining({ - PI_CODING_AGENT_DIR: join(home, '.config/pi-agent') + PI_CODING_AGENT_DIR: `${home}/.config/pi-agent` }) }) }) @@ -104,4 +109,29 @@ describe('prepareLocalCommitMessageAgentEnv', () => { ok: true }) }) + + it('sets CODEX_HOME for host managed Codex accounts', async () => { + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => + 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home' + }) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + CODEX_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home' + }) + }) + }) + + it('does not pass WSL managed Codex homes to host-local commit generation', async () => { + process.env.CODEX_HOME = 'C:\\Users\\tester\\.codex' + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => + '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.local\\share\\orca\\codex-accounts\\a\\home' + }) + + expect(result).toEqual({ ok: true }) + }) }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 5d4558b5c..25dbdd8cc 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -1,6 +1,7 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { parseWslUncPath } from '../../shared/wsl-paths' export type CommitMessageAgentEnvironmentResolvers = { prepareForCodexLaunch?: () => string | null @@ -74,6 +75,11 @@ export async function prepareLocalCommitMessageAgentEnv( try { if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { const codexHomePath = resolvers.prepareForCodexLaunch() + if (codexHomePath && parseWslUncPath(codexHomePath)) { + // Why: this local generation path spawns the host Codex binary. A WSL + // managed home is only valid when the process is routed through wsl.exe. + return { ok: true } + } return { ok: true, env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index c982d3361..4f8f981d8 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -34,13 +34,17 @@ import type { } from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, - prepareClaudeAuth?: () => Promise, + getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null, + prepareClaudeAuth?: ( + target?: ClaudeAccountSelectionTarget + ) => Promise, options?: { onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void } diff --git a/src/main/wsl-bash-command.test.ts b/src/main/wsl-bash-command.test.ts new file mode 100644 index 000000000..a6440a7fd --- /dev/null +++ b/src/main/wsl-bash-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildEncodedWslBashCommand } from './wsl-bash-command' + +describe('buildEncodedWslBashCommand', () => { + it('wraps Bash scripts without exposing local shell variables to wsl.exe', () => { + const command = [ + 'set -euo pipefail', + "candidate='/home/alice/.local/share/orca/codex-accounts/a/home'", + 'candidate_real=$(readlink -f -- "$candidate")', + 'printf "%s\\n" "$candidate_real"' + ].join('\n') + + const wrapped = buildEncodedWslBashCommand(command) + const encoded = wrapped.match( + /^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/ + )?.[1] + + expect(wrapped).not.toContain('$candidate') + expect(wrapped).not.toContain('\n') + expect(encoded).toBeTruthy() + expect(Buffer.from(encoded as string, 'base64').toString('utf8')).toBe(command) + }) +}) diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts new file mode 100644 index 000000000..ff7426ae6 --- /dev/null +++ b/src/main/wsl-bash-command.ts @@ -0,0 +1,10 @@ +function quoteShell(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export function buildEncodedWslBashCommand(command: string): string { + // Why: wsl.exe preprocesses `$local_shell_vars` in command arguments before + // Bash sees them. Base64 keeps validation scripts intact across that boundary. + const encoded = Buffer.from(command, 'utf8').toString('base64') + return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` +} diff --git a/src/main/wsl-env.ts b/src/main/wsl-env.ts new file mode 100644 index 000000000..71e836e94 --- /dev/null +++ b/src/main/wsl-env.ts @@ -0,0 +1,17 @@ +export function addWslEnvKeys( + env: Record, + keys: readonly string[] +): void { + const existing = env.WSLENV ?? process.env.WSLENV ?? '' + const tokens = existing.split(':').filter(Boolean) + const tokenNames = new Set(tokens.map((token) => token.split('/')[0])) + + for (const key of keys) { + if (!tokenNames.has(key)) { + tokens.push(key) + tokenNames.add(key) + } + } + + env.WSLENV = tokens.join(':') +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8f458b78e..4a3c56dac 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -224,7 +224,7 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } from '../shared/claude-usage-types' -import type { RateLimitState } from '../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -424,9 +424,16 @@ export type RefreshAgentsResult = { } export type PreflightApi = { - check: (args?: { force?: boolean; wslDistro?: string | null }) => Promise - detectAgents: (args?: { wslDistro?: string | null }) => Promise - refreshAgents: (args?: { wslDistro?: string | null }) => Promise + check: (args?: { + force?: boolean + wslDistro?: string | null + wslDefault?: boolean + }) => Promise + detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }) => Promise + refreshAgents: (args?: { + wslDistro?: string | null + wslDefault?: boolean + }) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise } @@ -1329,17 +1336,31 @@ export type PreloadApi = { } codexAccounts: { list: () => Promise - add: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise reauthenticate: (args: { accountId: string }) => Promise remove: (args: { accountId: string }) => Promise - select: (args: { accountId: string | null }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise } claudeAccounts: { list: () => Promise - add: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise reauthenticate: (args: { accountId: string }) => Promise remove: (args: { accountId: string }) => Promise - select: (args: { accountId: string | null }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise } cli: { getInstallStatus: () => Promise @@ -2017,6 +2038,8 @@ export type PreloadApi = { rateLimits: { get: () => Promise refresh: () => Promise + refreshCodexForTarget: (target: RateLimitRuntimeTarget) => Promise + refreshClaudeForTarget: (target: RateLimitRuntimeTarget) => Promise setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise @@ -2101,6 +2124,7 @@ export type PreloadApi = { } wsl: { isAvailable: () => Promise + listDistros: () => Promise } pwsh: { isAvailable: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index f8b7a9f3b..9a0e8a515 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,7 +55,7 @@ import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' -import type { RateLimitState } from '../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' import type { WorkspaceSpaceAnalyzeResult, WorkspaceSpaceScanProgress @@ -461,7 +461,8 @@ const api = { }, wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable') + isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), + listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') }, pwsh: { @@ -1412,24 +1413,32 @@ const api = { codexAccounts: { list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (): Promise => ipcRenderer.invoke('codexAccounts:add'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('codexAccounts:add', args), reauthenticate: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:remove', args), - select: (args: { accountId: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:select', args) + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('codexAccounts:select', args) }, claudeAccounts: { list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (): Promise => ipcRenderer.invoke('claudeAccounts:add'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('claudeAccounts:add', args), reauthenticate: (args: { accountId: string }): Promise => ipcRenderer.invoke('claudeAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('claudeAccounts:remove', args), - select: (args: { accountId: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:select', args) + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) }, cli: { @@ -1495,10 +1504,12 @@ const api = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: { wslDistro?: string | null }): Promise => + detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }): Promise => ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: { wslDistro?: string | null }): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), + refreshAgents: (args?: { + wslDistro?: string | null + wslDefault?: boolean + }): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args) }, @@ -3056,6 +3067,10 @@ const api = { rateLimits: { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), setPollingInterval: (ms: number): Promise => ipcRenderer.invoke('rateLimits:setPollingInterval', ms), fetchInactiveClaudeAccounts: (): Promise => diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 21518e226..cc86de89f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -14,6 +14,7 @@ import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' import { useAppStore } from '../../store' import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' @@ -22,13 +23,14 @@ import { ACCOUNTS_CLAUDE_SEARCH_ENTRIES, ACCOUNTS_CODEX_SEARCH_ENTRIES, ACCOUNTS_GEMINI_SEARCH_ENTRIES, + ACCOUNTS_LOCATION_SEARCH_ENTRIES, ACCOUNTS_OPENCODE_SEARCH_ENTRIES, ACCOUNTS_PANE_SEARCH_ENTRIES } from './accounts-search' import { SearchableSetting } from './SearchableSetting' +import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' -import { getLocalPreflightContext } from '@/lib/local-preflight-context' import { Dialog, DialogContent, @@ -43,6 +45,13 @@ export { ACCOUNTS_PANE_SEARCH_ENTRIES } type AccountsPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + wslAvailable?: boolean + wslDistros?: string[] + wslCapabilitiesLoading?: boolean +} + +function getHostRuntimeLabel(): string { + return navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' } function getCodexAccountLabel( @@ -55,6 +64,46 @@ function getCodexAccountLabel( return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' } +function getActiveCodexAccountIdForRuntime( + state: CodexRateLimitAccountsState, + runtime: LocalAccountRuntime +): string | null { + if (runtime.runtime === 'host') { + return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId + } + if (runtime.wslDistro) { + return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null + } + const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__ + if (defaultSelection) { + return defaultSelection + } + const selectedIds = Array.from( + new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean)) + ) + return selectedIds.length === 1 ? selectedIds[0] : null +} + +function getActiveClaudeAccountIdForRuntime( + state: ClaudeRateLimitAccountsState, + runtime: LocalAccountRuntime +): string | null { + if (runtime.runtime === 'host') { + return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId + } + if (runtime.wslDistro) { + return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null + } + const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__ + if (defaultSelection) { + return defaultSelection + } + const selectedIds = Array.from( + new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean)) + ) + return selectedIds.length === 1 ? selectedIds[0] : null +} + function getClaudeAccountLabel( state: ClaudeRateLimitAccountsState, accountId: string | null | undefined @@ -65,6 +114,24 @@ function getClaudeAccountLabel( return state.accounts.find((account) => account.id === accountId)?.email ?? 'Claude account' } +function getCodexAccountRuntimeLabel( + account: CodexRateLimitAccountsState['accounts'][number] +): string { + if (account.managedHomeRuntime === 'wsl') { + return account.wslDistro ? `WSL ${account.wslDistro}` : 'WSL' + } + return getHostRuntimeLabel() +} + +function getClaudeAccountRuntimeLabel( + account: ClaudeRateLimitAccountsState['accounts'][number] +): string { + if (account.managedAuthRuntime === 'wsl') { + return account.wslDistro ? `WSL ${account.wslDistro}` : 'WSL' + } + return getHostRuntimeLabel() +} + function getCodexAccountErrorDescription(error: unknown): string { const message = String((error as Error)?.message ?? error) .replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '') @@ -109,30 +176,102 @@ function getClaudeAccountErrorDescription(error: unknown): string { ) } -export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): React.JSX.Element { +type LocalAccountRuntime = { + runtime: 'host' | 'wsl' + wslDistro?: string | null + label: string +} + +function accountMatchesRuntime( + account: + | CodexRateLimitAccountsState['accounts'][number] + | ClaudeRateLimitAccountsState['accounts'][number], + runtime: LocalAccountRuntime +): boolean { + const accountRuntime = + 'authMethod' in account + ? (account.managedAuthRuntime ?? 'host') + : (account.managedHomeRuntime ?? 'host') + const accountDistro = account.wslDistro ?? null + if (runtime.runtime === 'host') { + return accountRuntime !== 'wsl' + } + if (accountRuntime !== 'wsl') { + return false + } + return runtime.wslDistro ? accountDistro === runtime.wslDistro : true +} + +function getSelectedAccountRuntime( + settings: GlobalSettings, + wslAvailable: boolean, + wslDistros: string[], + wslCapabilitiesLoading: boolean +): LocalAccountRuntime { + if (settings.localAccountRuntime === 'wsl') { + if (!wslAvailable && !wslCapabilitiesLoading) { + return { runtime: 'wsl', label: 'WSL' } + } + const configuredDistro = settings.localAccountWslDistro?.trim() || null + const selectedDistro = + configuredDistro && (wslCapabilitiesLoading || wslDistros.includes(configuredDistro)) + ? configuredDistro + : null + return { + runtime: 'wsl', + wslDistro: selectedDistro, + label: selectedDistro ? `WSL ${selectedDistro}` : 'WSL default' + } + } + return { runtime: 'host', label: getHostRuntimeLabel() } +} + +export function AccountsPane({ + settings, + updateSettings, + wslAvailable = false, + wslDistros = [], + wslCapabilitiesLoading = false +}: AccountsPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) - const localPreflightContext = useAppStore(getLocalPreflightContext) - const activeWslDistro = localPreflightContext?.wslDistro?.trim() || null const recordedOpenCodeSettingEditsRef = useRef>(new Set()) + const accountRuntime = getSelectedAccountRuntime( + settings, + wslAvailable, + wslDistros, + wslCapabilitiesLoading + ) const [codexAccounts, setCodexAccounts] = useState({ accounts: [], - activeAccountId: null + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [codexAction, setCodexAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') const [claudeAccounts, setClaudeAccounts] = useState({ accounts: [], - activeAccountId: null + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [claudeAction, setClaudeAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') const [removeAccountId, setRemoveAccountId] = useState(null) const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null) + const visibleClaudeAccounts = claudeAccounts.accounts.filter((account) => + accountMatchesRuntime(account, accountRuntime) + ) + const visibleCodexAccounts = codexAccounts.accounts.filter((account) => + accountMatchesRuntime(account, accountRuntime) + ) + const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime) + const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime) + const accountRuntimeUnavailable = + accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading const recordOpenCodeSettingEdit = (field: 'cookie' | 'workspaceId'): void => { if (recordedOpenCodeSettingEditsRef.current.has(field)) { @@ -202,27 +341,90 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R }) } + const accountRuntimeControls = ( + + + updateSettings({ localAccountRuntime: value })} + equalWidth + options={[ + { value: 'host', label: getHostRuntimeLabel() }, + { + value: 'wsl', + label: 'WSL', + disabled: wslCapabilitiesLoading || !wslAvailable + } + ]} + /> + {accountRuntime.runtime === 'wsl' ? ( + + ) : null} + + } + /> + + ) + const runCodexAccountAction = async ( action: typeof codexAction, operation: () => Promise ): Promise => { - const previousActiveAccountId = codexAccounts.activeAccountId + const previousActiveAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime) setCodexAction(action) try { const next = await operation() await syncCodexAccounts(next) recordFeatureInteraction('codex-account-switching') + const nextActiveAccountId = getActiveCodexAccountIdForRuntime(next, accountRuntime) const shouldPromptRestart = action === 'adding' || - (action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) || + (action.startsWith('select:') && previousActiveAccountId !== nextActiveAccountId) || (action.startsWith('reauth:') && - next.activeAccountId !== null && - action === `reauth:${next.activeAccountId}`) || - (action.startsWith('remove:') && previousActiveAccountId !== next.activeAccountId) + nextActiveAccountId !== null && + action === `reauth:${nextActiveAccountId}`) || + (action.startsWith('remove:') && previousActiveAccountId !== nextActiveAccountId) if (shouldPromptRestart) { void markLiveCodexSessionsForRestart({ previousAccountLabel: getCodexAccountLabel(codexAccounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, next.activeAccountId) + nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) }) } } catch (error) { @@ -238,15 +440,25 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R action: typeof claudeAction, operation: () => Promise ): Promise => { - const previousActiveAccountId = claudeAccounts.activeAccountId + const previousActiveAccountId = getActiveClaudeAccountIdForRuntime( + claudeAccounts, + accountRuntime + ) setClaudeAction(action) try { const next = await operation() await syncClaudeAccounts(next) recordFeatureInteraction('claude-account-switching') - if (previousActiveAccountId !== next.activeAccountId || action === 'adding') { + const nextActiveAccountId = getActiveClaudeAccountIdForRuntime(next, accountRuntime) + const shouldPromptRestart = + action === 'adding' || + previousActiveAccountId !== nextActiveAccountId || + (action.startsWith('reauth:') && + nextActiveAccountId !== null && + action === `reauth:${nextActiveAccountId}`) + if (shouldPromptRestart) { toast.info('Claude account updated.', { - description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.` + description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} -> ${getClaudeAccountLabel(next, nextActiveAccountId)}. Restart live Claude terminals before continuing old sessions.` }) } } catch (error) { @@ -259,6 +471,11 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R } const visibleSections = [ + matchesSettingsSearch(searchQuery, ACCOUNTS_LOCATION_SEARCH_ENTRIES) ? ( +
+ {accountRuntimeControls} +
+ ) : null, matchesSettingsSearch(searchQuery, ACCOUNTS_CLAUDE_SEARCH_ENTRIES) ? (
@@ -282,16 +499,23 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R

- Orca swaps Claude auth only; config and chat history stay in the shared Claude root. + Showing {accountRuntime.label} accounts. New accounts are added there.

- {claudeAccounts.accounts.length === 0 ? ( + {visibleClaudeAccounts.length === 0 ? (
- No managed Claude accounts yet. Orca will use your system default Claude login until - you add one here. + No managed Claude accounts for {accountRuntime.label}. Orca will use that + environment's system default Claude login until you add one here.
) : ( - claudeAccounts.accounts.map((account) => { - const isActive = claudeAccounts.activeAccountId === account.id + visibleClaudeAccounts.map((account) => { + const isActive = activeClaudeAccountId === account.id const isReauthing = claudeAction === `reauth:${account.id}` - const isBusy = claudeAction !== 'idle' + const isBusy = claudeAction !== 'idle' || accountRuntimeUnavailable return (
void runClaudeAccountAction(`select:${account.id}`, () => - window.api.claudeAccounts.select({ accountId: account.id }) + window.api.claudeAccounts.select({ + accountId: account.id, + runtime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null + }) ) } disabled={isBusy} @@ -368,6 +600,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R >
{account.email} + + {getClaudeAccountRuntimeLabel(account)} + {isActive ? ( - {activeWslDistro ? ( -

- WSL terminals use the Codex login inside {activeWslDistro}. Managed Codex account - switching applies to host terminals. -

- ) : null}

Each account keeps its own local sign-in context in Orca. Account auth stays on this device. @@ -472,18 +704,23 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R

- {activeWslDistro - ? `Use codex login in ${activeWslDistro} to change the WSL Codex account.` - : 'Add a Codex account to use it in Orca.'} + Showing {accountRuntime.label} accounts. New accounts are added there.

- {codexAccounts.accounts.length === 0 ? ( -
- {activeWslDistro - ? `No managed host Codex accounts yet. WSL terminals will use the Codex login in ${activeWslDistro}.` - : 'No managed Codex accounts yet. Orca will use your system default Codex login until you add one here.'} -
- ) : ( -
- - {codexAccounts.accounts.map((account) => { - const isActive = codexAccounts.activeAccountId === account.id + + Use your current {accountRuntime.label} Codex login. + +
+ + {visibleCodexAccounts.length === 0 ? ( +
+ No managed Codex accounts for {accountRuntime.label}. Orca will use that + environment's system default Codex login until you add one here. +
+ ) : ( + visibleCodexAccounts.map((account) => { + const isActive = activeCodexAccountId === account.id const isReauthing = codexAction === `reauth:${account.id}` const isRemoving = codexAction === `remove:${account.id}` - const isBusy = codexAction !== 'idle' + const isBusy = codexAction !== 'idle' || accountRuntimeUnavailable return (
void runCodexAccountAction(`select:${account.id}`, () => - window.api.codexAccounts.select({ accountId: account.id }) + window.api.codexAccounts.select({ + accountId: account.id, + runtime: account.managedHomeRuntime ?? 'host', + wslDistro: account.wslDistro ?? null + }) ) } disabled={isBusy} @@ -562,6 +806,12 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R >
{account.email} + + {getCodexAccountRuntimeLabel(account)} + {isActive ? (
) - })} -
- )} + }) + )} +
) : null, @@ -662,8 +912,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R

Extracts OAuth credentials from your local Gemini CLI installation to authenticate - with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break - if Google updates the CLI. Use at your own risk. + with Google for {accountRuntime.label}. This uses credentials issued to the Gemini CLI + app, not Orca. May break if Google updates the CLI. Use at your own risk.

+ ) + })} + + + ) +} + function ClaudeSwitcherMenu({ claude, compact, @@ -122,22 +582,30 @@ function ClaudeSwitcherMenu({ const [accountsExpanded, setAccountsExpanded] = useState(false) const [accounts, setAccounts] = useState({ accounts: [], - activeAccountId: null + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [isSwitching, setIsSwitching] = useState(false) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const refreshClaudeRateLimitsForTarget = useAppStore((s) => s.refreshClaudeRateLimitsForTarget) const fetchInactiveClaudeAccountUsage = useAppStore((s) => s.fetchInactiveClaudeAccountUsage) const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts) + const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget) + const settings = useAppStore((s) => s.settings) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities( + navigator.userAgent.includes('Windows') + ) const claudeAccountSyncKey = useAppStore((s) => { const settings = s.settings if (!settings) { return 'no-settings' } - return `${settings.activeClaudeManagedAccountId ?? 'system'}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) + const accountState = getClaudeStatusAccountsFromSettings(settings) ?? accounts const loadAccounts = useCallback(async () => { const next = await window.api.claudeAccounts.list() @@ -163,13 +631,20 @@ function ClaudeSwitcherMenu({ } }, [accountsExpanded, fetchInactiveClaudeAccountUsage]) - const handleSelectAccount = async (accountId: string | null): Promise => { + const handleSelectAccount = async ( + accountId: string | null, + target: CodexStatusRuntimeTarget + ): Promise => { if (isSwitching) { return } setIsSwitching(true) try { - const next = await window.api.claudeAccounts.select({ accountId }) + const next = await window.api.claudeAccounts.select({ + accountId, + runtime: target.runtime, + wslDistro: target.wslDistro + }) recordFeatureInteraction('claude-account-switching') setAccounts(next) await fetchSettings() @@ -181,19 +656,39 @@ function ClaudeSwitcherMenu({ } } - const activeAccountLabel = - accounts.activeAccountId === null - ? 'System default' - : (accounts.accounts.find((account) => account.id === accounts.activeAccountId)?.email ?? - 'Managed') - const availableSwitchTargets = [ - ...(accounts.activeAccountId === null - ? [] - : [{ id: null as string | null, label: 'System default' }]), - ...accounts.accounts - .filter((account) => account.id !== accounts.activeAccountId) - .map((account) => ({ id: account.id, label: account.email })) - ] + const handleSelectRuntime = async (group: ClaudeStatusSwitchGroup): Promise => { + const currentKey = getCodexStatusRuntimeKey( + normalizeClaudeStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(claudeTarget)) + ) + if (group.key === currentKey) { + return + } + setAccountsExpanded(false) + try { + await refreshClaudeRateLimitsForTarget(group.runtimeTarget) + } catch (error) { + console.error('Failed to switch Claude usage runtime:', error) + } + } + + const selectedRuntimeKey = getCodexStatusRuntimeKey( + normalizeClaudeStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(claudeTarget)) + ) + const fallbackWslDistro = getStatusBarPreferredWslDistro( + settings, + windowsTerminalCapabilities.wslDistros + ) + const switchGroups = buildClaudeStatusSwitchGroups( + accountState, + toCodexStatusRuntimeTarget(claudeTarget), + { + fallbackWslDistro, + includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + } + ) + const selectedGroup = + switchGroups.find((group) => group.key === selectedRuntimeKey) ?? switchGroups[0] + const activeTarget = selectedGroup?.targets.find((target) => target.active) return ( void handleSelectRuntime(group)} + ariaLabel="Claude usage runtime" + /> + } open={open} onOpenChange={handleOpenChange} > @@ -212,7 +715,7 @@ function ClaudeSwitcherMenu({ }} > - {activeAccountLabel} + {activeTarget?.label ?? 'System default'} {accountsExpanded ? ( @@ -226,25 +729,34 @@ function ClaudeSwitcherMenu({ Switch to
- {availableSwitchTargets.length === 0 ? ( + {selectedGroup?.targets.length === 0 ? (
No other accounts
) : null} - {availableSwitchTargets.map((target) => { + {selectedGroup?.targets.map((target) => { const inactiveUsage = target.id ? inactiveClaudeAccounts.find((a) => a.accountId === target.id) : null return ( { event.preventDefault() - void handleSelectAccount(target.id) + if (!target.active) { + void handleSelectAccount(target.id, target.runtimeTarget) + } }} >
- {target.label} +
+ {target.label} + {target.active ? ( + + Active + + ) : null} +
{inactiveUsage?.isFetching && !inactiveUsage.claude ? ( ) : inactiveUsage?.claude ? ( @@ -487,15 +999,22 @@ function CodexSwitcherMenu({ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const refreshCodexRateLimitsForTarget = useAppStore((s) => s.refreshCodexRateLimitsForTarget) const fetchInactiveCodexAccountUsage = useAppStore((s) => s.fetchInactiveCodexAccountUsage) const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts) + const codexTarget = useAppStore((s) => s.rateLimits.codexTarget) + const settings = useAppStore((s) => s.settings) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities( + navigator.userAgent.includes('Windows') + ) const codexAccountSyncKey = useAppStore((s) => { const settings = s.settings if (!settings) { return 'no-settings' } - return `${settings.activeCodexManagedAccountId ?? 'system'}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) + const accountState = getCodexStatusAccountsFromSettings(settings) ?? accounts const loadAccounts = useCallback(async () => { const next = await window.api.codexAccounts.list() @@ -512,21 +1031,29 @@ function CodexSwitcherMenu({ }) }, [loadAccounts, open, codexAccountSyncKey]) - const handleSelectAccount = async (accountId: string | null): Promise => { + const handleSelectAccount = async ( + accountId: string | null, + target: CodexStatusRuntimeTarget + ): Promise => { if (isSwitching) { return } - const previousActiveAccountId = accounts.activeAccountId + const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setIsSwitching(true) try { - const next = await window.api.codexAccounts.select({ accountId }) + const next = await window.api.codexAccounts.select({ + accountId, + runtime: target.runtime, + wslDistro: target.wslDistro + }) recordFeatureInteraction('codex-account-switching') setAccounts(next) await fetchSettings() - if (previousActiveAccountId !== next.activeAccountId) { + const nextActiveAccountId = getCodexStatusActiveId(next, target) + if (previousActiveAccountId !== nextActiveAccountId) { await markLiveCodexSessionsForRestart({ - previousAccountLabel: getCodexAccountLabel(accounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, next.activeAccountId) + previousAccountLabel: getCodexAccountLabel(accountState, previousActiveAccountId), + nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) }) // Why: account switching can require a second explicit recovery step // for live Codex terminals. Keeping the switcher open and collapsing @@ -541,6 +1068,21 @@ function CodexSwitcherMenu({ } } + const handleSelectRuntime = async (group: CodexStatusSwitchGroup): Promise => { + const currentKey = getCodexStatusRuntimeKey( + normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) + ) + if (group.key === currentKey) { + return + } + setAccountsExpanded(false) + try { + await refreshCodexRateLimitsForTarget(group.runtimeTarget) + } catch (error) { + console.error('Failed to switch Codex usage runtime:', error) + } + } + const handleOpenChange = useCallback((nextOpen: boolean): void => { setOpen(nextOpen) if (!nextOpen) { @@ -554,24 +1096,24 @@ function CodexSwitcherMenu({ } }, [accountsExpanded, fetchInactiveCodexAccountUsage]) - const activeAccountLabel = - accounts.activeAccountId === null - ? 'System default' - : (accounts.accounts.find((account) => account.id === accounts.activeAccountId)?.email ?? - 'Managed') - const availableSwitchTargets = [ - ...(accounts.activeAccountId === null - ? [] - : [{ id: null as string | null, label: 'System default' }]), - ...accounts.accounts - .filter((account) => account.id !== accounts.activeAccountId) - .map((account) => ({ - id: account.id, - label: account.workspaceLabel - ? `${account.email} (${account.workspaceLabel})` - : account.email - })) - ] + const selectedRuntimeKey = getCodexStatusRuntimeKey( + normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) + ) + const fallbackWslDistro = getStatusBarPreferredWslDistro( + settings, + windowsTerminalCapabilities.wslDistros + ) + const switchGroups = buildCodexStatusSwitchGroups( + accountState, + toCodexStatusRuntimeTarget(codexTarget), + { + fallbackWslDistro, + includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + } + ) + const selectedGroup = + switchGroups.find((group) => group.key === selectedRuntimeKey) ?? switchGroups[0] + const activeTarget = selectedGroup?.targets.find((target) => target.active) return ( void handleSelectRuntime(group)} + ariaLabel="Codex usage runtime" + /> + } open={open} onOpenChange={handleOpenChange} > @@ -589,9 +1139,13 @@ function CodexSwitcherMenu({ setAccountsExpanded((prev) => !prev) }} > - - {activeAccountLabel} - +
+
+ + {activeTarget?.label ?? 'System default'} + +
+
{accountsExpanded ? ( ) : ( @@ -600,45 +1154,52 @@ function CodexSwitcherMenu({ {accountsExpanded ? (
-
- Switch to -
- {availableSwitchTargets.length === 0 ? ( -
No other accounts
- ) : null} - {availableSwitchTargets.map((target) => { - const inactiveUsage = target.id - ? inactiveCodexAccounts.find((a) => a.accountId === target.id) - : null + {selectedGroup ? ( + <> + {selectedGroup.targets.map((target) => { + const inactiveUsage = target.id + ? inactiveCodexAccounts.find((a) => a.accountId === target.id) + : null - return ( - { - // Why: account switching may need an immediate follow-up - // restart action for live Codex tabs. Prevent the menu from - // auto-closing so that prompt can stay within the same - // account-switcher interaction instead of jumping elsewhere. - event.preventDefault() - void handleSelectAccount(target.id) - }} - disabled={isSwitching} - > -
- {target.label} - {inactiveUsage?.isFetching && !inactiveUsage.claude ? ( - - ) : inactiveUsage?.claude ? ( - - ) : null} -
-
- ) - })} + return ( + { + // Why: account switching may need an immediate follow-up + // restart action for live Codex tabs. Prevent the menu from + // auto-closing so that prompt can stay within the same + // account-switcher interaction instead of jumping elsewhere. + event.preventDefault() + if (!target.active) { + void handleSelectAccount(target.id, target.runtimeTarget) + } + }} + disabled={isSwitching || target.active} + > +
+
+ {target.label} + {target.active ? ( + + Active + + ) : null} +
+ {inactiveUsage?.isFetching && !inactiveUsage.claude ? ( + + ) : inactiveUsage?.claude ? ( + + ) : null} +
+
+ ) + })} + + ) : null}
) : null} @@ -665,6 +1226,7 @@ function ProviderDetailsMenu({ compact, iconOnly, ariaLabel, + topContent, open, onOpenChange, children @@ -673,6 +1235,7 @@ function ProviderDetailsMenu({ compact: boolean iconOnly: boolean ariaLabel: string + topContent?: React.ReactNode open?: boolean onOpenChange?: (open: boolean) => void children?: React.ReactNode @@ -715,6 +1278,7 @@ function ProviderDetailsMenu({ + {topContent}
diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts new file mode 100644 index 000000000..665273236 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../../../shared/types' +import { buildClaudeStatusSwitchGroups, buildCodexStatusSwitchGroups } from './StatusBar' + +const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' + +describe('status bar runtime switch groups', () => { + it('collapses WSL default into the single concrete Codex distro', () => { + const state: CodexRateLimitAccountsState = { + accounts: [ + { + id: 'codex-wsl', + email: 'wsl@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'codex-wsl' } } + } + + expect( + buildCodexStatusSwitchGroups(state, { runtime: 'wsl', wslDistro: null }).map((group) => ({ + key: group.key, + label: group.label + })) + ).toEqual([ + { key: 'host', label: hostLabel }, + { key: 'wsl:Ubuntu', label: 'WSL Ubuntu' } + ]) + }) + + it('keeps the Claude WSL toggle available when Windows is selected', () => { + const state: ClaudeRateLimitAccountsState = { + accounts: [ + { + id: 'claude-host', + email: 'host@example.com', + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'claude-wsl', + email: 'wsl@example.com', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeAccountId: 'claude-host', + activeAccountIdsByRuntime: { host: 'claude-host', wsl: { Ubuntu: 'claude-wsl' } } + } + + expect( + buildClaudeStatusSwitchGroups(state, { runtime: 'host', wslDistro: null }).map((group) => ({ + key: group.key, + label: group.label + })) + ).toEqual([ + { key: 'host', label: hostLabel }, + { key: 'wsl:Ubuntu', label: 'WSL Ubuntu' } + ]) + }) + + it('keeps Claude WSL system-default available without managed Claude accounts', () => { + const state: ClaudeRateLimitAccountsState = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + + expect( + buildClaudeStatusSwitchGroups( + state, + { runtime: 'host', wslDistro: null }, + { includeFallbackWsl: true, fallbackWslDistro: 'Ubuntu' } + ).map((group) => ({ + key: group.key, + label: group.label, + targets: group.targets.map((target) => target.label) + })) + ).toEqual([ + { key: 'host', label: hostLabel, targets: ['System default'] }, + { key: 'wsl:Ubuntu', label: 'WSL Ubuntu', targets: ['System default'] } + ]) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 2e242d50c..401a62fa9 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -234,7 +234,10 @@ describe('TabBar PowerShell launch wiring', () => { it('passes pwsh.exe when the PowerShell menu item uses the PowerShell 7+ implementation', async () => { vi.stubGlobal('window', { api: { - wsl: { isAvailable: vi.fn().mockResolvedValue(false) }, + wsl: { + isAvailable: vi.fn().mockResolvedValue(false), + listDistros: vi.fn().mockResolvedValue([]) + }, pwsh: { isAvailable: vi.fn().mockResolvedValue(true) } } }) @@ -281,7 +284,10 @@ describe('TabBar PowerShell launch wiring', () => { it('shows the WSL terminal row when shared Windows capabilities report WSL', async () => { vi.stubGlobal('window', { api: { - wsl: { isAvailable: vi.fn().mockResolvedValue(true) }, + wsl: { + isAvailable: vi.fn().mockResolvedValue(true), + listDistros: vi.fn().mockResolvedValue(['Ubuntu']) + }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) } } }) diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 1c036f8d5..67ebdbcab 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { + getLocalAgentPreflightContext, getLocalPreflightContext, getWslDistroFromPath, localPreflightContextKey @@ -86,4 +87,51 @@ describe('local preflight context', () => { expect(getLocalPreflightContext(state)).toBeUndefined() expect(localPreflightContextKey(getLocalPreflightContext(state))).toBe('host') }) + + it('uses the selected WSL distro for local agent checks when WSL is the default shell', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + } + } as AppState + + const context = getLocalAgentPreflightContext(state) + + expect(context).toEqual({ wslDistro: 'Debian' }) + expect(localPreflightContextKey(context)).toBe('wsl:Debian') + }) + + it('lets explicit agent location choose Windows even when the terminal shell is WSL', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localAgentRuntime: 'host' + } + } as AppState + + const context = getLocalAgentPreflightContext(state) + + expect(context).toBeUndefined() + expect(localPreflightContextKey(context)).toBe('host') + }) + + it('lets explicit agent location choose a WSL distro independent of the terminal shell', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'powershell.exe', + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } + } as AppState + + const context = getLocalAgentPreflightContext(state) + + expect(context).toEqual({ wslDistro: 'Ubuntu' }) + expect(localPreflightContextKey(context)).toBe('wsl:Ubuntu') + }) }) diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 7f17191e6..ed61a7619 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,9 +1,10 @@ import type { AppState } from '@/store/types' import { parseWslUncPath } from '../../../shared/wsl-paths' -export type LocalPreflightContext = { wslDistro?: string | null } | undefined +export type LocalPreflightContext = { wslDistro?: string | null; wslDefault?: boolean } | undefined const wslPreflightContextsByDistro = new Map>() +const wslDefaultPreflightContext = Object.freeze({ wslDefault: true }) export function getWslDistroFromPath(path?: string | null): string | null { return path ? (parseWslUncPath(path)?.distro ?? null) : null @@ -23,6 +24,40 @@ function getWslPreflightContext(wslDistro: string): NonNullable repo.id === state.activeRepoId)?.path - const wslDistro = getWslDistroFromPath(activePath) - return wslDistro ? getWslPreflightContext(wslDistro) : undefined + return getWslDistroFromPath(activePath) } export function localPreflightContextKey(context: LocalPreflightContext): string { - return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host' + if (context?.wslDistro) { + return `wsl:${context.wslDistro}` + } + return context?.wslDefault ? 'wsl:default' : 'host' } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 411f11f12..b12fc9c48 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -6,21 +6,27 @@ import { resetWindowsTerminalCapabilitiesForTests } from './windows-terminal-capabilities' -function stubTerminalCapabilityApi(args: { wslAvailable: boolean; pwshAvailable: boolean }): { +function stubTerminalCapabilityApi(args: { + wslAvailable: boolean + pwshAvailable: boolean + wslDistros?: string[] +}): { wslIsAvailable: ReturnType + wslListDistros: ReturnType pwshIsAvailable: ReturnType } { const wslIsAvailable = vi.fn().mockResolvedValue(args.wslAvailable) + const wslListDistros = vi.fn().mockResolvedValue(args.wslDistros ?? []) const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros }, pwsh: { isAvailable: pwshIsAvailable } } }) - return { wslIsAvailable, pwshIsAvailable } + return { wslIsAvailable, wslListDistros, pwshIsAvailable } } describe('windows terminal capabilities', () => { @@ -37,16 +43,22 @@ describe('windows terminal capabilities', () => { expect(getCachedWindowsTerminalCapabilities()).toEqual({ wslAvailable: false, - pwshAvailable: false + wslDistros: [], + pwshAvailable: false, + isLoading: false }) await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({ wslAvailable: true, - pwshAvailable: true + wslDistros: [], + pwshAvailable: true, + isLoading: false }) expect(getCachedWindowsTerminalCapabilities()).toEqual({ wslAvailable: true, - pwshAvailable: true + wslDistros: [], + pwshAvailable: true, + isLoading: false }) await loadWindowsTerminalCapabilities() @@ -59,14 +71,16 @@ describe('windows terminal capabilities', () => { const pwshIsAvailable = vi.fn().mockRejectedValue(new Error('pwsh probe failed')) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable } } }) await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({ wslAvailable: true, - pwshAvailable: false + wslDistros: [], + pwshAvailable: false, + isLoading: false }) }) @@ -75,7 +89,7 @@ describe('windows terminal capabilities', () => { const pwshIsAvailable = vi.fn().mockResolvedValue(false) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable } } }) @@ -98,7 +112,7 @@ describe('windows terminal capabilities', () => { const pwshIsAvailable = vi.fn().mockResolvedValue(false) vi.stubGlobal('window', { api: { - wsl: { isAvailable: wslIsAvailable }, + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable } } }) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 57e9ba33d..51819a229 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -2,12 +2,16 @@ import { useEffect, useState } from 'react' export type WindowsTerminalCapabilities = { wslAvailable: boolean + wslDistros: string[] pwshAvailable: boolean + isLoading: boolean } const UNAVAILABLE_CAPABILITIES: WindowsTerminalCapabilities = { wslAvailable: false, - pwshAvailable: false + wslDistros: [], + pwshAvailable: false, + isLoading: false } const CAPABILITY_CACHE_TTL_MS = 30_000 @@ -52,10 +56,11 @@ export function loadWindowsTerminalCapabilities( const requestId = ++latestCapabilityRequestId pendingCapabilities = Promise.all([ window.api.wsl.isAvailable().catch(() => false), + window.api.wsl.listDistros().catch(() => []), window.api.pwsh.isAvailable().catch(() => false) ]) - .then(([wslAvailable, pwshAvailable]) => { - const capabilities = { wslAvailable, pwshAvailable } + .then(([wslAvailable, wslDistros, pwshAvailable]) => { + const capabilities = { wslAvailable, wslDistros, pwshAvailable, isLoading: false } if (requestId === latestCapabilityRequestId) { pendingCapabilities = null publish(capabilities, now) @@ -79,7 +84,10 @@ export function refreshWindowsTerminalCapabilities(): Promise { @@ -88,14 +96,15 @@ export function useWindowsTerminalCapabilities(enabled: boolean): WindowsTermina return } - setCapabilities(getCachedWindowsTerminalCapabilities()) + const cached = getCachedWindowsTerminalCapabilities() + setCapabilities(cachedCapabilities ? cached : { ...cached, isLoading: true }) subscribers.add(setCapabilities) - void loadWindowsTerminalCapabilities().then(setCapabilities) + void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount }).then(setCapabilities) return () => { subscribers.delete(setCapabilities) } - }, [enabled]) + }, [enabled, forceRefreshOnMount]) return enabled ? capabilities : UNAVAILABLE_CAPABILITIES } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 1c89b0113..5a85dbb8d 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -112,6 +112,71 @@ describe('createDetectedAgentsSlice WSL context', () => { expect(refreshAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' }) }) + it('detects local agents in the default WSL distro when the default Windows shell is WSL', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'wsl.exe' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ wslDefault: true }) + }) + + it('detects local agents in the selected WSL distro when the default Windows shell is WSL', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' }) + }) + + it('detects Windows agents when explicit agent location is Windows', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localAgentRuntime: 'host' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith(undefined) + }) + + it('detects WSL agents when explicit agent location is WSL', async () => { + const store = createTestStore({ + settings: { + terminalWindowsShell: 'powershell.exe', + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Fedora' + } as AppState['settings'], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Fedora' }) + }) + it('does not keep previous context agents when detection fails after a context switch', async () => { detectAgents .mockReset() diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index 9fa75afa5..aa641beb9 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -1,7 +1,10 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' -import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { + getLocalAgentPreflightContext, + localPreflightContextKey +} from '@/lib/local-preflight-context' export type DetectedAgentsSlice = { detectedAgentIds: TuiAgent[] | null @@ -48,7 +51,7 @@ export const createDetectedAgentsSlice: StateCreator { - const context = getLocalPreflightContext(get()) + const context = getLocalAgentPreflightContext(get()) const contextKey = localPreflightContextKey(context) const existing = get().detectedAgentIds if (existing && detectedContextKey === contextKey) { @@ -85,7 +88,7 @@ export const createDetectedAgentsSlice: StateCreator { - const context = getLocalPreflightContext(get()) + const context = getLocalAgentPreflightContext(get()) const contextKey = localPreflightContextKey(context) if (refreshPromise?.key === contextKey) { return refreshPromise.promise diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index 6818a32de..15bff1f5b 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -28,7 +28,7 @@ function getErrorMessage(error: unknown): string { function buildPreflightArgs( force: boolean, context: LocalPreflightContext -): { force?: boolean; wslDistro?: string | null } | undefined { +): { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } | undefined { if (!force && !context) { return undefined } diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 07fc408b4..4e0e142e6 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -1,22 +1,26 @@ import type { StateCreator } from 'zustand' -import type { RateLimitState } from '../../../../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../../../../shared/rate-limit-types' import type { AppState } from '../types' export type RateLimitSlice = { rateLimits: RateLimitState fetchRateLimits: () => Promise refreshRateLimits: () => Promise + refreshClaudeRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise + refreshCodexRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise fetchInactiveClaudeAccountUsage: () => Promise fetchInactiveCodexAccountUsage: () => Promise setRateLimitsFromPush: (state: RateLimitState) => void } -export const createRateLimitSlice: StateCreator = (set) => ({ +export const createRateLimitSlice: StateCreator = (set, get) => ({ rateLimits: { claude: null, codex: null, gemini: null, opencodeGo: null, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], inactiveCodexAccounts: [] }, @@ -39,6 +43,66 @@ export const createRateLimitSlice: StateCreator { + const current = get().rateLimits + const targetChanged = + current.claudeTarget.runtime !== target.runtime || + current.claudeTarget.wslDistro !== target.wslDistro + set({ + rateLimits: { + ...current, + claudeTarget: target, + claude: + current.claude && !targetChanged + ? { ...current.claude, status: 'fetching' } + : { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + }) + try { + const state = await window.api.rateLimits.refreshClaudeForTarget(target) + set({ rateLimits: state }) + } catch (error) { + console.error('Failed to refresh Claude usage for runtime:', error) + } + }, + + refreshCodexRateLimitsForTarget: async (target) => { + const current = get().rateLimits + const targetChanged = + current.codexTarget.runtime !== target.runtime || + current.codexTarget.wslDistro !== target.wslDistro + set({ + rateLimits: { + ...current, + codexTarget: target, + codex: + current.codex && !targetChanged + ? { ...current.codex, status: 'fetching' } + : { + provider: 'codex', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + }) + try { + const state = await window.api.rateLimits.refreshCodexForTarget(target) + set({ rateLimits: state }) + } catch (error) { + console.error('Failed to refresh Codex usage for runtime:', error) + } + }, + fetchInactiveClaudeAccountUsage: async () => { try { await window.api.rateLimits.fetchInactiveClaudeAccounts() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index de32e6b0b..d68eee67d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -439,7 +439,7 @@ function createWebPreloadApi(): Partial { }, pty: createPtyApi(), ssh: createSshApi(), - wsl: { isAvailable: () => Promise.resolve(false) }, + wsl: { isAvailable: () => Promise.resolve(false), listDistros: () => Promise.resolve([]) }, pwsh: { isAvailable: () => Promise.resolve(false) }, agentStatus: { onSet: () => noopUnsubscribe, @@ -1789,12 +1789,16 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { codex: null, gemini: null, opencodeGo: null, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], inactiveCodexAccounts: [] } return { get: () => Promise.resolve(empty), refresh: () => Promise.resolve(empty), + refreshCodexForTarget: () => Promise.resolve(empty), + refreshClaudeForTarget: () => Promise.resolve(empty), setPollingInterval: () => Promise.resolve(), fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), @@ -1803,7 +1807,11 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { } function createAccountsApi(): never { - const empty = { accounts: [], activeAccountId: null } + const empty = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } return { list: () => Promise.resolve(empty), add: () => Promise.resolve(empty), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 868e03b0b..7a28fb5a3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -197,6 +197,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // and Ctrl+right-click still opens the context menu when paste is enabled. terminalRightClickToPaste: true, terminalWindowsShell: 'powershell.exe', + terminalWindowsWslDistro: null, + localAccountRuntime: 'host', + localAccountWslDistro: null, // Why: Windows users expect "PowerShell" to mean modern PowerShell when it // is installed, with a safe fallback to the inbox Windows PowerShell. terminalWindowsPowerShellImplementation: 'auto', @@ -237,6 +240,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { promptCacheTtlMs: 300_000, codexManagedAccounts: [], activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index a796cc663..376351007 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -32,6 +32,11 @@ export type ProviderRateLimits = { status: ProviderRateLimitStatus } +export type RateLimitRuntimeTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + export type InactiveAccountUsage = { accountId: string claude: ProviderRateLimits | null @@ -44,6 +49,8 @@ export type RateLimitState = { codex: ProviderRateLimits | null gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null + claudeTarget: RateLimitRuntimeTarget + codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] inactiveCodexAccounts: InactiveAccountUsage[] } diff --git a/src/shared/types.ts b/src/shared/types.ts index f98dd7057..f5edbbbaf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1480,6 +1480,9 @@ export type CodexManagedAccount = { id: string email: string managedHomePath: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxHomePath?: string | null providerAccountId?: string | null workspaceLabel?: string | null workspaceAccountId?: string | null @@ -1491,6 +1494,8 @@ export type CodexManagedAccount = { export type CodexManagedAccountSummary = { id: string email: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null providerAccountId?: string | null workspaceLabel?: string | null workspaceAccountId?: string | null @@ -1502,12 +1507,21 @@ export type CodexManagedAccountSummary = { export type CodexRateLimitAccountsState = { accounts: CodexManagedAccountSummary[] activeAccountId: string | null + activeAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection +} + +export type CodexManagedAccountRuntimeSelection = { + host: string | null + wsl: Record } export type ClaudeManagedAccount = { id: string email: string managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null authMethod: 'subscription-oauth' | 'unknown' organizationUuid?: string | null organizationName?: string | null @@ -1519,6 +1533,8 @@ export type ClaudeManagedAccount = { export type ClaudeManagedAccountSummary = { id: string email: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null authMethod: 'subscription-oauth' | 'unknown' organizationUuid?: string | null organizationName?: string | null @@ -1530,6 +1546,12 @@ export type ClaudeManagedAccountSummary = { export type ClaudeRateLimitAccountsState = { accounts: ClaudeManagedAccountSummary[] activeAccountId: string | null + activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection +} + +export type ClaudeManagedAccountRuntimeSelection = { + host: string | null + wsl: Record } /** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace @@ -1725,6 +1747,20 @@ export type GlobalSettings = { * user's preferred shell. Defaults to 'powershell.exe' which is the * modern choice for an IDE context. Only consulted on Windows. */ terminalWindowsShell: string + /** Why: when WSL is the Windows default shell, users with multiple distros + * need Orca to launch terminals and scan agents in the same chosen distro + * instead of whatever WSL currently marks as its global default. */ + terminalWindowsWslDistro?: string | null + /** Why: account/auth location is independent from the user's preferred + * terminal shell. A user may default new terminals to WSL while still + * inspecting or adding Windows-scoped provider accounts. */ + localAccountRuntime: 'host' | 'wsl' + localAccountWslDistro?: string | null + /** Why: installed-agent detection is also a local environment choice. Keep + * it independent so users can inspect Windows and WSL PATH state without + * changing the default terminal shell. */ + localAgentRuntime?: 'host' | 'wsl' + localAgentWslDistro?: string | null /** Why: "PowerShell" is the product-facing shell family. Auto resolves to * PowerShell 7+ when present and falls back to inbox Windows PowerShell. */ terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -1812,11 +1848,13 @@ export type GlobalSettings = { * and external terminal sessions. */ codexManagedAccounts: CodexManagedAccount[] activeCodexManagedAccountId: string | null + activeCodexManagedAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection /** Why: Claude Code keeps conversations under one shared config root. Orca * persists only per-account auth material here so switching accounts does * not fork prior chat/session context the way CLAUDE_CONFIG_DIR swapping would. */ claudeManagedAccounts: ClaudeManagedAccount[] activeClaudeManagedAccountId: string | null + activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection /** When true, each worktree gets its own shell history file so ArrowUp * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */