Make WSL first-class for local agents and provider accounts (#2649)

This commit is contained in:
Jinwoo Hong 2026-05-29 19:06:33 -04:00 committed by GitHub
parent 5ec0e9ad5b
commit 0d060e1d21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 6757 additions and 546 deletions

View File

@ -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')

View File

@ -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<unknown> = Promise.resolve()
@ -83,18 +101,22 @@ export class ClaudeRuntimeAuthService {
void this.safeSyncForCurrentSelection()
}
async prepareForClaudeLaunch(): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection()
return this.getPreparation()
async prepareForClaudeLaunch(
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection(target)
return this.getPreparation(target)
}
async prepareForRateLimitFetch(): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection()
return this.getPreparation()
async prepareForRateLimitFetch(
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection(target)
return this.getPreparation(target)
}
async syncForCurrentSelection(): Promise<void> {
await this.serializeMutation(() => this.doSyncForCurrentSelection())
async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise<void> {
await this.serializeMutation(() => this.doSyncForCurrentSelection(target))
}
async forceMaterializeCurrentSelectionForRollback(): Promise<void> {
@ -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<void> {
@ -139,12 +161,12 @@ export class ClaudeRuntimeAuthService {
return next
}
private async doSyncForCurrentSelection(): Promise<void> {
private async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise<void> {
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<ClaudeReadBackMatch> {
@ -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
})

View File

@ -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<GlobalSettings, 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime'>
> = {}
): Pick<GlobalSettings, 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime'> {
return {
activeClaudeManagedAccountId: null,
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} },
...overrides
}
}
function createAccount(
overrides: Partial<ClaudeManagedAccount> & Pick<ClaudeManagedAccount, 'id'>
): 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 }
})
})
})

View File

@ -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<string, string | null> = {}
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<string, string | null> = {}
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
}

View File

@ -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<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
syncForCurrentSelection: vi.fn(async () => {}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
const rateLimits = {
refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null }))
}
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
const snapshot = await service.selectAccountForTarget('wsl-account', {
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(settings.activeClaudeManagedAccountId).toBe('host-account')
expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({
host: 'host-account',
wsl: { Ubuntu: 'wsl-account' }
})
expect(snapshot.activeAccountIdsByRuntime).toEqual({
host: 'host-account',
wsl: { Ubuntu: 'wsl-account' }
})
expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, {
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
})
it('rejects selecting a WSL account for the Windows target', async () => {
setPlatform('linux')
tempDir = '/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<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
syncForCurrentSelection: vi.fn(async () => {}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
const rateLimits = {
evictInactiveClaudeCache: vi.fn(),
refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null }))
}
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
await service.removeAccount('wsl-account')
expect(settings.activeClaudeManagedAccountId).toBe('host-account')
expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({
host: 'host-account',
wsl: { Ubuntu: null }
})
expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account')
expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', {
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
})
})

View File

@ -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<unknown> = Promise.resolve()
@ -66,8 +95,8 @@ export class ClaudeAccountService {
return this.getSnapshot()
}
async addAccount(): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount())
async addAccount(target?: ClaudeAccountAddTarget): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount(target))
}
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
@ -82,19 +111,29 @@ export class ClaudeAccountService {
return this.serializeMutation(() => this.doSelectAccount(accountId))
}
async selectAccountForTarget(
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
}
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
return next
}
private async doAddAccount(): Promise<ClaudeRateLimitAccountsState> {
private async doAddAccount(
target?: ClaudeAccountAddTarget
): Promise<ClaudeRateLimitAccountsState> {
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<ClaudeRateLimitAccountsState> {
private async doSelectAccount(
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRateLimitAccountsState> {
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<Store['getSettings']>): void {
this.store.updateSettings({
claudeManagedAccounts: settings.claudeManagedAccounts,
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId,
activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime
})
}
private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise<void>): Promise<void> {
private async syncRuntimeAuthWithLivePtyGate(
target?: ClaudeAccountSelectionTarget,
operation?: () => Promise<void>
): Promise<void> {
beginClaudeAuthSwitch()
try {
await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection())
await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target))
} finally {
endClaudeAuthSwitch()
}
}
private async runClaudeLoginAndCapture(): Promise<CapturedClaudeAuth> {
const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-'))
private async runClaudeLoginAndCapture(
location: ManagedClaudeAuthLocation = {
managedAuthPath: '',
managedAuthRuntime: 'host',
wslDistro: null,
wslLinuxAuthPath: null
}
): Promise<CapturedClaudeAuth> {
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<string> {
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

View File

@ -74,6 +74,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): 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()
})

View File

@ -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<string, string | null>()
private readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
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

View File

@ -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<GlobalSettings, 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime'>
> = {}
): Pick<GlobalSettings, 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime'> {
return {
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} },
...overrides
}
}
function createAccount(
overrides: Partial<CodexManagedAccount> & Pick<CodexManagedAccount, 'id'>
): 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 }
})
})
})

View File

@ -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<string, string | null> = {}
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<string, string | null> = {}
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
}

View File

@ -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> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
@ -61,6 +66,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): 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,

View File

@ -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<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount())
async addAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount(target))
}
async reauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
@ -75,9 +104,17 @@ export class CodexAccountService {
return this.serializeMutation(() => this.doSelectAccount(accountId))
}
private async doAddAccount(): Promise<CodexRateLimitAccountsState> {
async selectAccountForTarget(
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
}
private async doAddAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
private async doSelectAccount(
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
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 <accounts-root>/<uuid>/home. Removing
// just the home/ leaf leaves an empty <uuid>/ directory behind.
try {
@ -405,22 +684,45 @@ export class CodexAccountService {
private async runCodexLogin(managedHomePath: string): Promise<void> {
await new Promise<void>((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.'

View File

@ -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
})

View File

@ -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: {

View File

@ -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')
})
})
)
})

View File

@ -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<string, string>): 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

View File

@ -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
})

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
)
}

View File

@ -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)
}
)
}

View File

@ -62,7 +62,13 @@ import {
runPreflightCheck
} from './preflight'
type HandlerMap = Record<string, (_event?: unknown, args?: { force?: boolean }) => Promise<unknown>>
type HandlerMap = Record<
string,
(
_event?: unknown,
args?: { force?: boolean; wslDistro?: string | null; wslDefault?: boolean }
) => Promise<unknown>
>
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

View File

@ -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<boolean> {
async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
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<boolean> {
async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise<boolean> {
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<string[]> {
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<boolean> {
async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
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<boolean> {
// 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<boolean> {
async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
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<PreflightStatus> {
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()

View File

@ -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<ClaudeRuntimeAuthPreparation>
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<string, string>,
@ -795,9 +830,9 @@ export function unbindLocalProviderListeners(): void {
export function registerPtyHandlers(
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService,
getSelectedCodexHomePath?: () => string | null,
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>,
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<ClaudeRuntimeAuthPreparation>,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store
): void {
// Why: headless `orca serve` has no renderer window, but the runtime still

View File

@ -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)
)

View File

@ -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 () => {

View File

@ -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<string, string>,
ctx?: { command?: string; isWsl?: boolean }
ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null }
) => Record<string, string>
/** 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/<drive> 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

View File

@ -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

View File

@ -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([

View File

@ -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
}

View File

@ -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)
})
})

View File

@ -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('/')
}

View File

@ -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({

View File

@ -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<ProviderRateLimits> {
export async function fetchClaudeRateLimits(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
}): Promise<ProviderRateLimits> {
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<string | null> {
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<ProviderRateLimits> {

View File

@ -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<string, string>,
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',

View File

@ -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<GlobalSettings>
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' })
})
})

View File

@ -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' }
}

View File

@ -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
})
}
})
})

View File

@ -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<Provi
let resolved = false
let rpcId = 0
const codexCommand = resolveCodexCommand()
const codexArgs = ['-s', 'read-only', '-a', 'untrusted', 'app-server']
const wslCodex = options?.codexHomePath
? buildWslCodexCommand(options.codexHomePath, codexArgs)
: null
// Why: cold WSL process startup plus Codex app-server initialization can
// exceed the host RPC budget, causing a false "unavailable" on app launch.
const rpcTimeoutMs = wslCodex ? WSL_RPC_TIMEOUT_MS : RPC_TIMEOUT_MS
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file.
// spawn() cannot execute batch scripts directly without shell:true, but
// shell:true with an args array triggers DEP0190 (args are concatenated,
// not escaped). Fix: detect batch scripts and route through cmd.exe /c.
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, [
'-s',
'read-only',
'-a',
'untrusted',
'app-server'
])
const { spawnCmd, spawnArgs } = wslCodex
? { spawnCmd: wslCodex.command, spawnArgs: wslCodex.args }
: getSpawnArgsForWindows(codexCommand, codexArgs)
const child = spawn(spawnCmd, spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
// Why: the selected Codex rate-limit account must only affect this fetch
@ -117,8 +154,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
// poll on Windows.
windowsHide: true,
env: {
...process.env,
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
}
})
@ -135,7 +172,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
status: 'error'
})
}
}, RPC_TIMEOUT_MS)
}, rpcTimeoutMs)
function sendRpc(method: string, params?: unknown): number {
const id = ++rpcId
@ -318,7 +355,8 @@ function parsePtyStatus(output: string): {
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
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<Provi
// even for bare 'codex' (not just .cmd/.bat) to let PATHEXT resolution
// succeed under a minimal Electron PATH. /d matches the rest of the codebase.
const isWin32 = process.platform === 'win32'
const spawnFile = isWin32 ? getCmdExePath() : codexCommand
const spawnArgs = isWin32 ? ['/d', '/c', codexCommand] : []
const spawnFile = wslCodex ? wslCodex.command : isWin32 ? getCmdExePath() : codexCommand
const spawnArgs = wslCodex ? wslCodex.args : isWin32 ? ['/d', '/c', codexCommand] : []
return new Promise<ProviderRateLimits>((resolve) => {
let output = ''
@ -341,9 +379,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
cols: 120,
rows: 40,
env: {
...process.env,
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
TERM: 'xterm-256color',
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
}
})
const termDisposables: { dispose: () => 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
}

View File

@ -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<GlobalSettings>
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' })
})
})

View File

@ -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' }
}

View File

@ -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<ProviderRateLimits>()
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()

View File

@ -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<ClaudeRuntimeAuthPreparation>
// 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<ClaudeRuntimeAuthPreparation>) | 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<ClaudeRuntimeAuthPreparation>): 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<RateLimitState> {
if (outgoingAccountId && this.state.codex?.session) {
async refreshForCodexAccountChange(
outgoingAccountId?: string | null,
target?: CodexAccountSelectionTarget
): Promise<RateLimitState> {
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<RateLimitState> {
async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise<RateLimitState> {
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<RateLimitState> {
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<RateLimitState> {
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<void> {
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<void> {
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<void> {
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<void> {
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,

View File

@ -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'

View File

@ -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 })
})
})

View File

@ -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

View File

@ -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<ClaudeRuntimeAuthPreparation>,
getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null,
prepareClaudeAuth?: (
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>,
options?: {
onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void
}

View File

@ -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)
})
})

View File

@ -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`
}

17
src/main/wsl-env.ts Normal file
View File

@ -0,0 +1,17 @@
export function addWslEnvKeys(
env: Record<string, string | undefined>,
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(':')
}

View File

@ -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<PreflightStatus>
detectAgents: (args?: { wslDistro?: string | null }) => Promise<string[]>
refreshAgents: (args?: { wslDistro?: string | null }) => Promise<RefreshAgentsResult>
check: (args?: {
force?: boolean
wslDistro?: string | null
wslDefault?: boolean
}) => Promise<PreflightStatus>
detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }) => Promise<string[]>
refreshAgents: (args?: {
wslDistro?: string | null
wslDefault?: boolean
}) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
}
@ -1329,17 +1336,31 @@ export type PreloadApi = {
}
codexAccounts: {
list: () => Promise<CodexRateLimitAccountsState>
add: () => Promise<CodexRateLimitAccountsState>
add: (args?: {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<CodexRateLimitAccountsState>
reauthenticate: (args: { accountId: string }) => Promise<CodexRateLimitAccountsState>
remove: (args: { accountId: string }) => Promise<CodexRateLimitAccountsState>
select: (args: { accountId: string | null }) => Promise<CodexRateLimitAccountsState>
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<CodexRateLimitAccountsState>
}
claudeAccounts: {
list: () => Promise<ClaudeRateLimitAccountsState>
add: () => Promise<ClaudeRateLimitAccountsState>
add: (args?: {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<ClaudeRateLimitAccountsState>
reauthenticate: (args: { accountId: string }) => Promise<ClaudeRateLimitAccountsState>
remove: (args: { accountId: string }) => Promise<ClaudeRateLimitAccountsState>
select: (args: { accountId: string | null }) => Promise<ClaudeRateLimitAccountsState>
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<ClaudeRateLimitAccountsState>
}
cli: {
getInstallStatus: () => Promise<CliInstallStatus>
@ -2017,6 +2038,8 @@ export type PreloadApi = {
rateLimits: {
get: () => Promise<RateLimitState>
refresh: () => Promise<RateLimitState>
refreshCodexForTarget: (target: RateLimitRuntimeTarget) => Promise<RateLimitState>
refreshClaudeForTarget: (target: RateLimitRuntimeTarget) => Promise<RateLimitState>
setPollingInterval: (ms: number) => Promise<void>
fetchInactiveClaudeAccounts: () => Promise<void>
fetchInactiveCodexAccounts: () => Promise<void>
@ -2101,6 +2124,7 @@ export type PreloadApi = {
}
wsl: {
isAvailable: () => Promise<boolean>
listDistros: () => Promise<string[]>
}
pwsh: {
isAvailable: () => Promise<boolean>

View File

@ -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<boolean> => ipcRenderer.invoke('wsl:isAvailable')
isAvailable: (): Promise<boolean> => ipcRenderer.invoke('wsl:isAvailable'),
listDistros: (): Promise<string[]> => ipcRenderer.invoke('wsl:listDistros')
},
pwsh: {
@ -1412,24 +1413,32 @@ const api = {
codexAccounts: {
list: (): Promise<unknown> => ipcRenderer.invoke('codexAccounts:list'),
add: (): Promise<unknown> => ipcRenderer.invoke('codexAccounts:add'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:add', args),
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:remove', args),
select: (args: { accountId: string | null }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:select', args)
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}): Promise<unknown> => ipcRenderer.invoke('codexAccounts:select', args)
},
claudeAccounts: {
list: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:list'),
add: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:add'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:add', args),
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:remove', args),
select: (args: { accountId: string | null }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:select', args)
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}): Promise<unknown> => 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<string[]> =>
detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectAgents', args),
refreshAgents: (args?: { wslDistro?: string | null }): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
refreshAgents: (args?: {
wslDistro?: string | null
wslDefault?: boolean
}): Promise<RefreshAgentsResult> => ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args)
},
@ -3056,6 +3067,10 @@ const api = {
rateLimits: {
get: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:get'),
refresh: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refresh'),
refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise<RateLimitState> =>
ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target),
refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise<RateLimitState> =>
ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target),
setPollingInterval: (ms: number): Promise<void> =>
ipcRenderer.invoke('rateLimits:setPollingInterval', ms),
fetchInactiveClaudeAccounts: (): Promise<void> =>

View File

@ -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<GlobalSettings>) => 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<Set<'cookie' | 'workspaceId'>>(new Set())
const accountRuntime = getSelectedAccountRuntime(
settings,
wslAvailable,
wslDistros,
wslCapabilitiesLoading
)
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
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<ClaudeRateLimitAccountsState>({
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<string | null>(null)
const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState<string | null>(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 = (
<SearchableSetting
title="Account Location"
description={`Choose whether provider accounts are inspected and added in ${getHostRuntimeLabel()} or WSL.`}
keywords={['account', 'location', 'windows', 'wsl', 'linux', 'provider', 'auth']}
>
<SettingsRow
label="Account location"
alignTop
description={
accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading
? 'WSL is not available on this machine.'
: 'Choose which local environment to inspect and where new managed Claude and Codex accounts are added.'
}
control={
<div className="flex w-44 flex-col items-stretch gap-2">
<SettingsSegmentedControl
ariaLabel="Account location"
value={accountRuntime.runtime}
onChange={(value) => updateSettings({ localAccountRuntime: value })}
equalWidth
options={[
{ value: 'host', label: getHostRuntimeLabel() },
{
value: 'wsl',
label: 'WSL',
disabled: wslCapabilitiesLoading || !wslAvailable
}
]}
/>
{accountRuntime.runtime === 'wsl' ? (
<Select
value={accountRuntime.wslDistro ?? '__default__'}
onValueChange={(value) =>
updateSettings({
localAccountRuntime: 'wsl',
localAccountWslDistro: value === '__default__' ? null : value
})
}
disabled={wslCapabilitiesLoading || !wslAvailable}
>
<SelectTrigger size="sm" className="w-full min-w-44">
<SelectValue
placeholder={wslCapabilitiesLoading ? 'Loading WSL' : 'WSL default'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">WSL default</SelectItem>
{wslDistros.map((distro) => (
<SelectItem key={distro} value={distro}>
{distro}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
}
/>
</SearchableSetting>
)
const runCodexAccountAction = async (
action: typeof codexAction,
operation: () => Promise<CodexRateLimitAccountsState>
): Promise<void> => {
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<ClaudeRateLimitAccountsState>
): Promise<void> => {
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) ? (
<section key="account-runtime" id="accounts-runtime" className="space-y-3 scroll-mt-6">
{accountRuntimeControls}
</section>
) : null,
matchesSettingsSearch(searchQuery, ACCOUNTS_CLAUDE_SEARCH_ENTRIES) ? (
<section key="claude-accounts" id="accounts-claude" className="space-y-4 scroll-mt-6">
<div className="space-y-1">
@ -282,16 +499,23 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
<div className="space-y-0.5">
<Label>Accounts</Label>
<p className="text-xs text-muted-foreground">
Orca swaps Claude auth only; config and chat history stay in the shared Claude root.
Showing {accountRuntime.label} accounts. New accounts are added there.
</p>
</div>
<Button
variant="outline"
size="xs"
onClick={() =>
void runClaudeAccountAction('adding', () => window.api.claudeAccounts.add())
void runClaudeAccountAction('adding', () =>
window.api.claudeAccounts.add({
runtime: accountRuntime.runtime,
wslDistro: accountRuntime.wslDistro
})
)
}
disabled={
claudeAction !== 'idle' || wslCapabilitiesLoading || accountRuntimeUnavailable
}
disabled={claudeAction !== 'idle'}
className="gap-1.5"
>
{claudeAction === 'adding' ? (
@ -308,20 +532,24 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
type="button"
onClick={() =>
void runClaudeAccountAction('select:system', () =>
window.api.claudeAccounts.select({ accountId: null })
window.api.claudeAccounts.select({
accountId: null,
runtime: accountRuntime.runtime,
wslDistro: accountRuntime.wslDistro
})
)
}
disabled={claudeAction !== 'idle'}
disabled={claudeAction !== 'idle' || accountRuntimeUnavailable}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
claudeAccounts.activeAccountId === null
activeClaudeAccountId === null
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
}`}
} disabled:cursor-default disabled:opacity-100`}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">System default</span>
{claudeAccounts.activeAccountId === null ? (
{activeClaudeAccountId === null ? (
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
@ -331,20 +559,20 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
) : null}
</div>
<span className="truncate text-[11px] text-muted-foreground">
Use your current system Claude login.
Use your current {accountRuntime.label} Claude login.
</span>
</div>
</button>
{claudeAccounts.accounts.length === 0 ? (
{visibleClaudeAccounts.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-3 py-4 text-xs text-muted-foreground">
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&apos;s system default Claude login until you add one here.
</div>
) : (
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 (
<div
@ -360,7 +588,11 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
type="button"
onClick={() =>
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
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{account.email}</span>
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/70"
>
{getClaudeAccountRuntimeLabel(account)}
</Badge>
{isActive ? (
<Badge
variant="outline"
@ -437,12 +675,6 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
Optional. Orca can use your normal Codex login; add accounts only if you want quick
switching in Orca.
</p>
{activeWslDistro ? (
<p className="text-xs text-muted-foreground">
WSL terminals use the Codex login inside {activeWslDistro}. Managed Codex account
switching applies to host terminals.
</p>
) : null}
<p className="text-xs text-muted-foreground">
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
<div className="space-y-0.5">
<Label>Accounts</Label>
<p className="text-xs text-muted-foreground">
{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.
</p>
</div>
<Button
variant="outline"
size="xs"
onClick={() =>
void runCodexAccountAction('adding', () => window.api.codexAccounts.add())
void runCodexAccountAction('adding', () =>
window.api.codexAccounts.add({
runtime: accountRuntime.runtime,
wslDistro: accountRuntime.wslDistro
})
)
}
disabled={
codexAction !== 'idle' || wslCapabilitiesLoading || accountRuntimeUnavailable
}
disabled={codexAction !== 'idle'}
className="gap-1.5"
>
{codexAction === 'adding' ? (
@ -495,50 +732,53 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
</Button>
</div>
{codexAccounts.accounts.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-3 py-4 text-xs text-muted-foreground">
{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.'}
</div>
) : (
<div className="space-y-2">
<button
type="button"
onClick={() =>
void runCodexAccountAction('select:system', () =>
window.api.codexAccounts.select({ accountId: null })
)
}
disabled={codexAction !== 'idle'}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
codexAccounts.activeAccountId === null
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
} disabled:cursor-default disabled:opacity-100`}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">System default</span>
{codexAccounts.activeAccountId === null ? (
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
>
Active
</Badge>
) : null}
</div>
<span className="truncate text-[11px] text-muted-foreground">
Use your current system Codex login.
</span>
<div className="space-y-2">
<button
type="button"
onClick={() =>
void runCodexAccountAction('select:system', () =>
window.api.codexAccounts.select({
accountId: null,
runtime: accountRuntime.runtime,
wslDistro: accountRuntime.wslDistro
})
)
}
disabled={codexAction !== 'idle' || accountRuntimeUnavailable}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
activeCodexAccountId === null
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
} disabled:cursor-default disabled:opacity-100`}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">System default</span>
{activeCodexAccountId === null ? (
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
>
Active
</Badge>
) : null}
</div>
</button>
{codexAccounts.accounts.map((account) => {
const isActive = codexAccounts.activeAccountId === account.id
<span className="truncate text-[11px] text-muted-foreground">
Use your current {accountRuntime.label} Codex login.
</span>
</div>
</button>
{visibleCodexAccounts.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-3 py-4 text-xs text-muted-foreground">
No managed Codex accounts for {accountRuntime.label}. Orca will use that
environment&apos;s system default Codex login until you add one here.
</div>
) : (
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 (
<div
@ -554,7 +794,11 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
type="button"
onClick={() =>
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
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{account.email}</span>
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/70"
>
{getCodexAccountRuntimeLabel(account)}
</Badge>
{isActive ? (
<Badge
variant="outline"
@ -628,9 +878,9 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
</div>
</div>
)
})}
</div>
)}
})
)}
</div>
</SearchableSetting>
</section>
) : null,
@ -662,8 +912,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
<Label>Use Gemini CLI credentials (experimental)</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<button
@ -735,6 +985,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
Paste either the raw token value (e.g. <code className="text-xs">Fe26.2**</code>) or
the full cookie header (e.g. <code className="text-xs">auth=Fe26.2**</code>). Find it
in your browser&apos;s DevTools Network any opencode.ai request Cookie header.
OpenCode Go auth is web-based and shared across Windows and WSL terminals.
</p>
</SearchableSetting>

View File

@ -0,0 +1,126 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
type AgentDetectionRuntime = {
runtime: 'host' | 'wsl'
wslDistro?: string | null
label: string
}
type AgentLocationSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
refresh: () => Promise<unknown>
wslAvailable?: boolean
wslDistros?: string[]
wslCapabilitiesLoading?: boolean
}
function getHostRuntimeLabel(): string {
return navigator.userAgent.includes('Windows') ? 'Windows' : 'This device'
}
function getSelectedAgentRuntime(
settings: GlobalSettings,
wslAvailable: boolean,
wslDistros: string[],
wslCapabilitiesLoading: boolean
): AgentDetectionRuntime {
const configuredRuntime =
settings.localAgentRuntime ?? (settings.terminalWindowsShell === 'wsl.exe' ? 'wsl' : 'host')
if (configuredRuntime === 'wsl') {
if (!wslAvailable && !wslCapabilitiesLoading) {
return { runtime: 'wsl', label: 'WSL' }
}
const configuredDistro =
settings.localAgentWslDistro?.trim() || settings.terminalWindowsWslDistro?.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 AgentLocationSetting({
settings,
updateSettings,
refresh,
wslAvailable = false,
wslDistros = [],
wslCapabilitiesLoading = false
}: AgentLocationSettingProps): React.JSX.Element {
const agentRuntime = getSelectedAgentRuntime(
settings,
wslAvailable,
wslDistros,
wslCapabilitiesLoading
)
const updateAgentLocation = (updates: Partial<GlobalSettings>): void => {
void Promise.resolve(updateSettings(updates)).then(() => refresh())
}
return (
<section className="space-y-3">
<SettingsRow
label="Agent location"
alignTop
description={
agentRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading
? 'WSL is not available on this machine.'
: `Show installed agents from ${agentRuntime.label}. Refresh re-checks PATH in that environment.`
}
control={
<div className="flex w-44 flex-col items-stretch gap-2">
<SettingsSegmentedControl
ariaLabel="Agent location"
value={agentRuntime.runtime}
onChange={(value) => updateAgentLocation({ localAgentRuntime: value })}
equalWidth
options={[
{ value: 'host', label: getHostRuntimeLabel() },
{
value: 'wsl',
label: 'WSL',
disabled: wslCapabilitiesLoading || !wslAvailable
}
]}
/>
{agentRuntime.runtime === 'wsl' ? (
<Select
value={agentRuntime.wslDistro ?? '__default__'}
onValueChange={(value) =>
updateAgentLocation({
localAgentRuntime: 'wsl',
localAgentWslDistro: value === '__default__' ? null : value
})
}
disabled={wslCapabilitiesLoading || !wslAvailable}
>
<SelectTrigger size="sm" className="w-full min-w-44">
<SelectValue
placeholder={wslCapabilitiesLoading ? 'Loading WSL' : 'WSL default'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">WSL default</SelectItem>
{wslDistros.map((distro) => (
<SelectItem key={distro} value={distro}>
{distro}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
}
/>
</section>
)
}

View File

@ -2,7 +2,7 @@ import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/types'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy'
import { getAgentAwakeDescription } from './agent-awake-copy'
@ -16,16 +16,34 @@ import {
} from './AgentsPane'
import { matchesSettingsSearch } from './settings-search'
const detectedAgentsMock = vi.hoisted(() => ({
detectedIds: ['claude'] as TuiAgent[] | null,
refresh: vi.fn()
}))
vi.mock('@/hooks/useDetectedAgents', () => ({
useDetectedAgents: () => ({
detectedIds: detectedAgentsMock.detectedIds,
isLoading: detectedAgentsMock.detectedIds === null,
isRefreshing: false,
refresh: detectedAgentsMock.refresh
})
}))
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function renderPane(settings: GlobalSettings): string {
function renderPane(
settings: GlobalSettings,
props: Partial<React.ComponentProps<typeof AgentsPane>> = {}
): string {
return renderToStaticMarkup(
React.createElement(AgentsPane, {
settings,
updateSettings: vi.fn()
updateSettings: vi.fn(),
...props
})
)
}
@ -77,6 +95,8 @@ function findSwitchRow(node: unknown, ariaLabel: string): ReactElementLike {
describe('AgentsPane', () => {
beforeEach(() => {
detectedAgentsMock.detectedIds = ['claude']
detectedAgentsMock.refresh.mockReset()
useAppStore.setState({
settingsSearchQuery: '',
detectedAgentIds: ['claude'],
@ -87,7 +107,10 @@ describe('AgentsPane', () => {
it('renders the keep-awake toggle from settings', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
const hostRuntimeLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device'
expect(markup).toContain('Agent location')
expect(markup).toContain(`Show installed agents from ${hostRuntimeLabel}`)
expect(markup).toContain('Keep computer awake while agents are working')
expect(markup).toContain(
'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.'
@ -95,6 +118,19 @@ describe('AgentsPane', () => {
expect(markup).toContain('aria-checked="false"')
})
it('keeps the agent location aligned with a WSL default terminal while capabilities load', () => {
const markup = renderPane(
{
...getDefaultSettings('/tmp'),
terminalWindowsShell: 'wsl.exe'
},
{ wslCapabilitiesLoading: true }
)
expect(markup).toContain('Show installed agents from WSL default.')
expect(markup).toContain('role="radio" aria-checked="true" disabled=""')
})
it('describes Windows lid behavior according to the device', () => {
expect(getAgentAwakeDescription('Windows')).toBe(
"Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings."
@ -226,4 +262,9 @@ describe('AgentsPane', () => {
disabledTuiAgents: []
})
})
it('includes agent location search metadata', () => {
expect(matchesSettingsSearch('wsl', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('windows', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
})
})

View File

@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: the Agents pane keeps catalog rows, default
selection, and per-agent controls together so settings reconciliation stays
visible in one file. */
selection, per-agent controls, and runtime location together so settings
reconciliation stays visible in one file. */
import { useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
@ -11,6 +11,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { AgentLocationSetting } from './AgentLocationSetting'
import { AGENT_STATUS_HOOKS_DESCRIPTION, AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy'
import {
SettingsBadge,
@ -27,7 +28,10 @@ export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search'
type AgentsPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
wslAvailable?: boolean
wslDistros?: string[]
wslCapabilitiesLoading?: boolean
}
type AgentRowProps = {
@ -315,7 +319,13 @@ function DefaultAgentPill({ active, onClick, children }: DefaultAgentPillProps):
)
}
export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React.JSX.Element {
export function AgentsPane({
settings,
updateSettings,
wslAvailable = false,
wslDistros = [],
wslCapabilitiesLoading = false
}: AgentsPaneProps): React.JSX.Element {
const { detectedIds: detectedList, isRefreshing, refresh } = useDetectedAgents()
// Why: refresh re-spawns the user's login shell to re-capture PATH
// (preflight:refreshAgents on the main side). This handles the
@ -351,11 +361,14 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
updateSettings({ agentCmdOverrides: next })
}
const enabledDetectedAgents = AGENT_CATALOG.filter(
(a) =>
(detectedIds === null || detectedIds.has(a.id)) && isTuiAgentEnabled(a.id, disabledAgents)
// Why: null means detection is in flight, not "all agents are installed".
// Showing the full catalog here makes the default-agent picker flash invalid
// options while switching between Windows and WSL detection contexts.
const detectedAgents =
detectedIds === null ? [] : AGENT_CATALOG.filter((agent) => detectedIds.has(agent.id))
const enabledDetectedAgents = detectedAgents.filter((agent) =>
isTuiAgentEnabled(agent.id, disabledAgents)
)
const detectedAgents = AGENT_CATALOG.filter((a) => detectedIds === null || detectedIds.has(a.id))
const undetectedAgents = AGENT_CATALOG.filter(
(a) => detectedIds !== null && !detectedIds.has(a.id)
)
@ -371,6 +384,15 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
return (
<div className="space-y-8">
<AgentLocationSetting
settings={settings}
updateSettings={updateSettings}
refresh={refresh}
wslAvailable={wslAvailable}
wslDistros={wslDistros}
wslCapabilitiesLoading={wslCapabilitiesLoading}
/>
<section className="space-y-4">
<SettingsSubsectionHeader
title="Default Agent"

View File

@ -420,7 +420,11 @@ function Settings(): React.JSX.Element {
[activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds]
)
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
isWindows && neededSectionIds.has('terminal')
isWindows &&
(neededSectionIds.has('terminal') ||
neededSectionIds.has('accounts') ||
neededSectionIds.has('agents')),
true
)
useEffect(() => {
@ -742,7 +746,13 @@ function Settings(): React.JSX.Element {
searchEntries={getSectionSearchEntries('agents')}
>
{isSectionMounted('agents') ? (
<AgentsPane settings={settings} updateSettings={updateSettings} />
<AgentsPane
settings={settings}
updateSettings={updateSettings}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
/>
) : null}
</SettingsSection>
@ -754,7 +764,13 @@ function Settings(): React.JSX.Element {
searchEntries={getSectionSearchEntries('accounts')}
>
{isSectionMounted('accounts') ? (
<AccountsPane settings={settings} updateSettings={updateSettings} />
<AccountsPane
settings={settings}
updateSettings={updateSettings}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
/>
) : null}
</SettingsSection>
@ -842,6 +858,8 @@ function Settings(): React.JSX.Element {
setScrollbackMode={setScrollbackMode}
ghostty={ghostty}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
pwshAvailable={windowsTerminalCapabilities.pwshAvailable}
/>
) : null}

View File

@ -124,6 +124,7 @@ type SettingsSegmentedControlProps<T extends string | number> = {
options: readonly SegmentedOption<T>[]
ariaLabel?: string
size?: 'sm' | 'md'
equalWidth?: boolean
}
/** Canonical segmented control for theme/ligatures/cursor/shell/etc. */
@ -132,13 +133,17 @@ export function SettingsSegmentedControl<T extends string | number>({
onChange,
options,
ariaLabel,
size = 'md'
size = 'md',
equalWidth = false
}: SettingsSegmentedControlProps<T>): React.JSX.Element {
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className="inline-flex items-center rounded-md border border-border bg-background/50 p-0.5"
className={cn(
'inline-flex items-center rounded-md border border-border bg-background/50 p-0.5',
equalWidth && 'w-full'
)}
>
{options.map((opt) => {
const active = opt.value === value
@ -156,8 +161,9 @@ export function SettingsSegmentedControl<T extends string | number>({
}
}}
className={cn(
'rounded-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
'rounded-sm text-center outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
size === 'sm' ? 'px-2.5 py-0.5 text-xs' : 'px-3 py-1 text-sm',
equalWidth && 'flex-1',
active
? 'bg-accent font-medium text-accent-foreground'
: opt.disabled

View File

@ -266,6 +266,7 @@ describe('TerminalPane PowerShell version setting', () => {
setScrollbackMode: () => {},
ghostty: ghosttyMock,
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: false
})
@ -292,4 +293,31 @@ describe('TerminalPane PowerShell version setting', () => {
expect(collectText(element)).not.toContain('WSL')
})
it('shows WSL distro choices when WSL is the selected Windows shell', () => {
const element = TerminalPane({
settings: {
terminalScrollbackBytes: 10_000_000,
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Debian',
terminalWindowsPowerShellImplementation: 'auto',
terminalWordSeparator: ''
} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
scrollbackMode: 'preset',
setScrollbackMode: () => {},
ghostty: ghosttyMock,
wslAvailable: true,
wslDistros: ['Ubuntu', 'Debian'],
pwshAvailable: false
})
const text = collectText(element)
expect(text).toContain('Choose which WSL distribution')
expect(text).toContain('Windows default')
expect(text).toContain('Ubuntu')
expect(text).toContain('Debian')
})
})

View File

@ -18,6 +18,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Separator } from '../ui/separator'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Minus, Plus } from 'lucide-react'
import { clampNumber, resolvePaneStyleOptions } from '@/lib/terminal-theme'
import {
@ -74,6 +75,10 @@ type TerminalPaneProps = {
ghostty: UseGhosttyImportReturn
/** Whether WSL is installed on this Windows machine. */
wslAvailable?: boolean
/** Installed WSL distro names, used to choose the default WSL terminal target. */
wslDistros?: string[]
/** Whether WSL capability probing is still in flight. */
wslCapabilitiesLoading?: boolean
/** Whether PowerShell 7+ (pwsh.exe) is installed on this Windows machine. */
pwshAvailable?: boolean
}
@ -87,6 +92,8 @@ export function TerminalPane({
setScrollbackMode,
ghostty,
wslAvailable,
wslDistros = [],
wslCapabilitiesLoading = false,
pwshAvailable
}: TerminalPaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
@ -112,6 +119,12 @@ export function TerminalPane({
const scrollbackToggleValue =
scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom'
const windowsShell = settings.terminalWindowsShell ?? 'powershell.exe'
const selectedWslDistroName = settings.terminalWindowsWslDistro?.trim() || null
const selectedWslDistro = selectedWslDistroName || '__default__'
const wslDistroOptions =
selectedWslDistroName && !wslDistros.includes(selectedWslDistroName)
? [selectedWslDistroName, ...wslDistros]
: wslDistros
const powerShellImplementation = settings.terminalWindowsPowerShellImplementation ?? 'auto'
const showWindowsPowerShellImplementation = isWindows && windowsShell === 'powershell.exe'
@ -154,6 +167,45 @@ export function TerminalPane({
}
/>
</SearchableSetting>
{windowsShell === 'wsl.exe' ? (
<SearchableSetting
title="WSL Distribution"
description="Choose which WSL distribution new WSL terminals and local agent scans use."
keywords={['terminal', 'windows', 'wsl', 'linux', 'distribution', 'distro', 'ubuntu']}
>
<SettingsRow
label="WSL Distribution"
description="Used for new WSL terminal panes and local agent detection when the active workspace is not already inside WSL."
control={
<Select
value={selectedWslDistro}
onValueChange={(value) =>
updateSettings({
terminalWindowsWslDistro: value === '__default__' ? null : value
})
}
disabled={wslCapabilitiesLoading || !wslAvailable}
>
<SelectTrigger size="sm" aria-label="WSL Distribution" className="min-w-44">
<SelectValue
placeholder={
wslCapabilitiesLoading ? 'Loading distributions' : 'Windows default'
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Windows default</SelectItem>
{wslDistroOptions.map((distro) => (
<SelectItem key={distro} value={distro}>
{distro}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
</div>
</section>
) : null,

View File

@ -1,5 +1,14 @@
import type { SettingsSearchEntry } from './settings-search'
export const ACCOUNTS_LOCATION_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Account Location',
description:
'Choose whether provider accounts are inspected and added on this device or in WSL.',
keywords: ['account', 'location', 'windows', 'wsl', 'linux', 'provider', 'auth']
}
]
export const ACCOUNTS_CLAUDE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Claude Accounts',
@ -44,6 +53,7 @@ export const ACCOUNTS_OPENCODE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
export const ACCOUNTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...ACCOUNTS_LOCATION_SEARCH_ENTRIES,
...ACCOUNTS_CLAUDE_SEARCH_ENTRIES,
...ACCOUNTS_CODEX_SEARCH_ENTRIES,
...ACCOUNTS_GEMINI_SEARCH_ENTRIES,

View File

@ -56,6 +56,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'show'
]
},
{
title: 'Agent Location',
description: 'Choose whether installed agents are detected on this device or in WSL.',
keywords: ['agent', 'location', 'windows', 'wsl', 'linux', 'detect', 'installed', 'path']
},
{
title: AGENT_STATUS_HOOKS_TITLE,
description: AGENT_STATUS_HOOKS_DESCRIPTION,

View File

@ -38,6 +38,24 @@ export const TERMINAL_WINDOWS_POWERSHELL_IMPLEMENTATION_SEARCH_ENTRY: SettingsSe
}
]
export const TERMINAL_WINDOWS_WSL_DISTRO_SEARCH_ENTRY: SettingsSearchEntry[] = [
{
title: 'WSL Distribution',
description: 'Choose which WSL distribution new WSL terminals and local agent scans use.',
keywords: [
'terminal',
'windows',
'wsl',
'linux',
'distribution',
'distro',
'ubuntu',
'debian',
'default'
]
}
]
export const TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY: SettingsSearchEntry[] = [
{
title: 'Right-click to paste',
@ -49,6 +67,7 @@ export const TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY: SettingsSearchEntry[] =
export const TERMINAL_WINDOWS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...TERMINAL_WINDOWS_SHELL_SEARCH_ENTRY,
...TERMINAL_WINDOWS_WSL_DISTRO_SEARCH_ENTRY,
...TERMINAL_WINDOWS_POWERSHELL_IMPLEMENTATION_SEARCH_ENTRY,
...TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY
]

View File

@ -25,9 +25,14 @@ import {
import { useAppStore } from '../../store'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState
CodexRateLimitAccountsState,
GlobalSettings
} from '../../../../shared/types'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import type {
ProviderRateLimits,
RateLimitRuntimeTarget,
RateLimitWindow
} from '../../../../shared/rate-limit-types'
import { ProviderIcon, ProviderPanel, barColor } from './tooltip'
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
import { formatWindowLabel } from '@/lib/window-label-formatter'
@ -43,11 +48,57 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu'
import { summarizeCodexRestartStatus } from './codex-restart-status-summary'
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
type StatusBarProps = {
floatingTerminalOpen: boolean
}
export type CodexStatusRuntimeTarget = {
runtime: 'host' | 'wsl'
wslDistro: string | null
}
type CodexStatusAccount = CodexRateLimitAccountsState['accounts'][number]
type ClaudeStatusAccount = ClaudeRateLimitAccountsState['accounts'][number]
export type CodexStatusSwitchTarget = {
id: string | null
label: string
active: boolean
runtimeTarget: CodexStatusRuntimeTarget
}
export type CodexStatusSwitchGroup = {
key: string
label: string
runtimeTarget: CodexStatusRuntimeTarget
targets: CodexStatusSwitchTarget[]
}
export type ClaudeStatusSwitchTarget = {
id: string | null
label: string
active: boolean
runtimeTarget: CodexStatusRuntimeTarget
}
export type ClaudeStatusSwitchGroup = {
key: string
label: string
runtimeTarget: CodexStatusRuntimeTarget
targets: ClaudeStatusSwitchTarget[]
}
type StatusSwitchGroupOptions = {
fallbackWslDistro?: string | null
includeFallbackWsl?: boolean
}
function getHostRuntimeLabel(): string {
return navigator.userAgent.includes('Windows') ? 'Windows' : 'This device'
}
function getCodexAccountLabel(
state: CodexRateLimitAccountsState,
accountId: string | null | undefined
@ -58,6 +109,369 @@ function getCodexAccountLabel(
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account'
}
function getCodexAccountDisplayLabel(account: CodexStatusAccount): string {
return account.workspaceLabel ? `${account.email} (${account.workspaceLabel})` : account.email
}
function getCodexStatusWslKey(wslDistro: string | null | undefined): string {
const trimmed = wslDistro?.trim()
return trimmed ? trimmed : '__default__'
}
function getCodexStatusRuntimeLabel(target: CodexStatusRuntimeTarget): string {
if (target.runtime === 'host') {
return getHostRuntimeLabel()
}
return target.wslDistro ? `WSL ${target.wslDistro}` : 'WSL default'
}
function getCodexStatusRuntimeKey(target: CodexStatusRuntimeTarget): string {
return target.runtime === 'host' ? 'host' : `wsl:${getCodexStatusWslKey(target.wslDistro)}`
}
function toCodexStatusRuntimeTarget(
target: RateLimitRuntimeTarget | undefined
): CodexStatusRuntimeTarget {
if (target?.runtime === 'wsl') {
return { runtime: 'wsl', wslDistro: target.wslDistro }
}
return { runtime: 'host', wslDistro: null }
}
function getStatusBarPreferredWslDistro(
settings: GlobalSettings | null | undefined,
wslDistros: string[]
): string | null {
const configuredDistro =
settings?.localAccountWslDistro?.trim() || settings?.terminalWindowsWslDistro?.trim() || null
if (configuredDistro) {
return configuredDistro
}
return wslDistros.length === 1 ? wslDistros[0] : null
}
function shouldIncludeSettingsWslRuntime(settings: GlobalSettings | null | undefined): boolean {
return settings?.localAccountRuntime === 'wsl'
}
function getSingleConcreteCodexWslDistro(state: CodexRateLimitAccountsState): string | null {
const keys = new Set<string>()
for (const [key, accountId] of Object.entries(state.activeAccountIdsByRuntime?.wsl ?? {})) {
if (accountId && key !== '__default__') {
keys.add(key)
}
}
for (const account of state.accounts) {
const key = getCodexStatusWslKey(account.wslDistro)
if (account.managedHomeRuntime === 'wsl' && key !== '__default__') {
keys.add(key)
}
}
return keys.size === 1 ? Array.from(keys)[0] : null
}
function normalizeCodexStatusRuntimeTarget(
state: CodexRateLimitAccountsState,
target: CodexStatusRuntimeTarget
): CodexStatusRuntimeTarget {
if (target.runtime !== 'wsl' || target.wslDistro) {
return target
}
const concreteDistro = getSingleConcreteCodexWslDistro(state)
return concreteDistro ? { runtime: 'wsl', wslDistro: concreteDistro } : target
}
function getCodexStatusActiveId(
state: CodexRateLimitAccountsState,
target: CodexStatusRuntimeTarget
): string | null {
const selection = state.activeAccountIdsByRuntime
if (target.runtime === 'host') {
return selection?.host ?? state.activeAccountId ?? null
}
const distroSelection = selection?.wsl?.[getCodexStatusWslKey(target.wslDistro)]
if (target.wslDistro || distroSelection) {
return distroSelection ?? null
}
const selectedIds = Array.from(new Set(Object.values(selection?.wsl ?? {}).filter(Boolean)))
return selectedIds.length === 1 ? selectedIds[0] : null
}
function getCodexStatusAccountsForTarget(
state: CodexRateLimitAccountsState,
target: CodexStatusRuntimeTarget
): CodexStatusAccount[] {
if (target.runtime === 'host') {
return state.accounts.filter((account) => account.managedHomeRuntime !== 'wsl')
}
return state.accounts.filter(
(account) =>
account.managedHomeRuntime === 'wsl' &&
getCodexStatusWslKey(account.wslDistro) === getCodexStatusWslKey(target.wslDistro)
)
}
export function buildCodexStatusSwitchGroups(
state: CodexRateLimitAccountsState,
currentTarget: CodexStatusRuntimeTarget,
options: StatusSwitchGroupOptions = {}
): CodexStatusSwitchGroup[] {
const groups: CodexStatusSwitchGroup[] = []
const normalizedCurrentTarget = normalizeCodexStatusRuntimeTarget(state, currentTarget)
const makeGroup = (target: CodexStatusRuntimeTarget): CodexStatusSwitchGroup => {
const activeId = getCodexStatusActiveId(state, target)
const accountsForTarget = getCodexStatusAccountsForTarget(state, target)
return {
key: getCodexStatusRuntimeKey(target),
label: getCodexStatusRuntimeLabel(target),
runtimeTarget: target,
targets: [
{
id: null,
label: 'System default',
active: activeId === null,
runtimeTarget: target
},
...accountsForTarget.map((account) => ({
id: account.id,
label: getCodexAccountDisplayLabel(account),
active: account.id === activeId,
runtimeTarget: target
}))
]
}
}
groups.push(makeGroup({ runtime: 'host', wslDistro: null }))
const wslKeys = new Set<string>(Object.keys(state.activeAccountIdsByRuntime?.wsl ?? {}))
if (normalizedCurrentTarget.runtime === 'wsl') {
wslKeys.add(getCodexStatusWslKey(normalizedCurrentTarget.wslDistro))
}
for (const account of state.accounts) {
if (account.managedHomeRuntime === 'wsl') {
wslKeys.add(getCodexStatusWslKey(account.wslDistro))
}
}
if (options.includeFallbackWsl) {
wslKeys.add(getCodexStatusWslKey(options.fallbackWslDistro))
}
if (currentTarget.runtime === 'wsl' && currentTarget.wslDistro === null) {
const concreteDistro = getSingleConcreteCodexWslDistro(state)
if (concreteDistro) {
wslKeys.delete('__default__')
}
}
for (const key of Array.from(wslKeys).sort((a, b) => {
if (a === '__default__') {
return -1
}
if (b === '__default__') {
return 1
}
return a.localeCompare(b)
})) {
groups.push(makeGroup({ runtime: 'wsl', wslDistro: key === '__default__' ? null : key }))
}
return groups
}
function getCodexStatusAccountsFromSettings(
settings: GlobalSettings | null | undefined
): CodexRateLimitAccountsState | null {
if (!settings) {
return null
}
return {
accounts: settings.codexManagedAccounts
.map((account) => ({
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,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastAuthenticatedAt: account.lastAuthenticatedAt
}))
.sort((a, b) => b.updatedAt - a.updatedAt),
activeAccountId:
settings.activeCodexManagedAccountIdsByRuntime?.host ??
settings.activeCodexManagedAccountId ??
null,
activeAccountIdsByRuntime: {
host:
settings.activeCodexManagedAccountIdsByRuntime?.host ??
settings.activeCodexManagedAccountId ??
null,
wsl: { ...settings.activeCodexManagedAccountIdsByRuntime?.wsl }
}
}
}
function getSingleConcreteClaudeWslDistro(state: ClaudeRateLimitAccountsState): string | null {
const keys = new Set<string>()
for (const [key, accountId] of Object.entries(state.activeAccountIdsByRuntime?.wsl ?? {})) {
if (accountId && key !== '__default__') {
keys.add(key)
}
}
for (const account of state.accounts) {
const key = getCodexStatusWslKey(account.wslDistro)
if (account.managedAuthRuntime === 'wsl' && key !== '__default__') {
keys.add(key)
}
}
return keys.size === 1 ? Array.from(keys)[0] : null
}
function normalizeClaudeStatusRuntimeTarget(
state: ClaudeRateLimitAccountsState,
target: CodexStatusRuntimeTarget
): CodexStatusRuntimeTarget {
if (target.runtime !== 'wsl' || target.wslDistro) {
return target
}
const concreteDistro = getSingleConcreteClaudeWslDistro(state)
return concreteDistro ? { runtime: 'wsl', wslDistro: concreteDistro } : target
}
function getClaudeStatusActiveId(
state: ClaudeRateLimitAccountsState,
target: CodexStatusRuntimeTarget
): string | null {
const selection = state.activeAccountIdsByRuntime
if (target.runtime === 'host') {
return selection?.host ?? state.activeAccountId ?? null
}
const distroSelection = selection?.wsl?.[getCodexStatusWslKey(target.wslDistro)]
if (target.wslDistro || distroSelection) {
return distroSelection ?? null
}
const selectedIds = Array.from(new Set(Object.values(selection?.wsl ?? {}).filter(Boolean)))
return selectedIds.length === 1 ? selectedIds[0] : null
}
function getClaudeStatusAccountsForTarget(
state: ClaudeRateLimitAccountsState,
target: CodexStatusRuntimeTarget
): ClaudeStatusAccount[] {
if (target.runtime === 'host') {
return state.accounts.filter((account) => account.managedAuthRuntime !== 'wsl')
}
return state.accounts.filter(
(account) =>
account.managedAuthRuntime === 'wsl' &&
getCodexStatusWslKey(account.wslDistro) === getCodexStatusWslKey(target.wslDistro)
)
}
export function buildClaudeStatusSwitchGroups(
state: ClaudeRateLimitAccountsState,
currentTarget: CodexStatusRuntimeTarget,
options: StatusSwitchGroupOptions = {}
): ClaudeStatusSwitchGroup[] {
const groups: ClaudeStatusSwitchGroup[] = []
const normalizedCurrentTarget = normalizeClaudeStatusRuntimeTarget(state, currentTarget)
const makeGroup = (target: CodexStatusRuntimeTarget): ClaudeStatusSwitchGroup => {
const activeId = getClaudeStatusActiveId(state, target)
const accountsForTarget = getClaudeStatusAccountsForTarget(state, target)
return {
key: getCodexStatusRuntimeKey(target),
label: getCodexStatusRuntimeLabel(target),
runtimeTarget: target,
targets: [
{
id: null,
label: 'System default',
active: activeId === null,
runtimeTarget: target
},
...accountsForTarget.map((account) => ({
id: account.id,
label: account.email,
active: account.id === activeId,
runtimeTarget: target
}))
]
}
}
groups.push(makeGroup({ runtime: 'host', wslDistro: null }))
const wslKeys = new Set<string>(Object.keys(state.activeAccountIdsByRuntime?.wsl ?? {}))
if (normalizedCurrentTarget.runtime === 'wsl') {
wslKeys.add(getCodexStatusWslKey(normalizedCurrentTarget.wslDistro))
}
for (const account of state.accounts) {
if (account.managedAuthRuntime === 'wsl') {
wslKeys.add(getCodexStatusWslKey(account.wslDistro))
}
}
if (options.includeFallbackWsl) {
wslKeys.add(getCodexStatusWslKey(options.fallbackWslDistro))
}
if (currentTarget.runtime === 'wsl' && currentTarget.wslDistro === null) {
const concreteDistro = getSingleConcreteClaudeWslDistro(state)
if (concreteDistro) {
wslKeys.delete('__default__')
}
}
for (const key of Array.from(wslKeys).sort((a, b) => {
if (a === '__default__') {
return -1
}
if (b === '__default__') {
return 1
}
return a.localeCompare(b)
})) {
groups.push(makeGroup({ runtime: 'wsl', wslDistro: key === '__default__' ? null : key }))
}
return groups
}
function getClaudeStatusAccountsFromSettings(
settings: GlobalSettings | null | undefined
): ClaudeRateLimitAccountsState | null {
if (!settings) {
return null
}
return {
accounts: settings.claudeManagedAccounts
.map((account) => ({
id: account.id,
email: account.email,
managedAuthRuntime: account.managedAuthRuntime ?? 'host',
wslDistro: account.wslDistro ?? null,
authMethod: account.authMethod ?? 'unknown',
organizationUuid: account.organizationUuid ?? null,
organizationName: account.organizationName ?? null,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastAuthenticatedAt: account.lastAuthenticatedAt
}))
.sort((a, b) => b.updatedAt - a.updatedAt),
activeAccountId:
settings.activeClaudeManagedAccountIdsByRuntime?.host ??
settings.activeClaudeManagedAccountId ??
null,
activeAccountIdsByRuntime: {
host:
settings.activeClaudeManagedAccountIdsByRuntime?.host ??
settings.activeClaudeManagedAccountId ??
null,
wsl: { ...settings.activeClaudeManagedAccountIdsByRuntime?.wsl }
}
}
}
function CodexRestartStatusPrompt(): React.JSX.Element | null {
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
@ -109,6 +523,52 @@ function CodexRestartStatusPrompt(): React.JSX.Element | null {
)
}
function AccountRuntimeToggle<TGroup extends { key: string; label: string }>({
groups,
value,
onChange,
ariaLabel
}: {
groups: TGroup[]
value: string
onChange: (group: TGroup) => void
ariaLabel: string
}): React.JSX.Element | null {
if (groups.length <= 1) {
return null
}
return (
<div className="px-2 pt-2">
<div
role="radiogroup"
aria-label={ariaLabel}
className="inline-flex w-full items-center rounded-md border border-border bg-background/50 p-0.5"
>
{groups.map((group) => {
const active = group.key === value
return (
<button
key={group.key}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(group)}
className={`min-w-0 flex-1 rounded-sm px-2 py-1 text-center text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
active
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className="block truncate">{group.label}</span>
</button>
)
})}
</div>
</div>
)
}
function ClaudeSwitcherMenu({
claude,
compact,
@ -122,22 +582,30 @@ function ClaudeSwitcherMenu({
const [accountsExpanded, setAccountsExpanded] = useState(false)
const [accounts, setAccounts] = useState<ClaudeRateLimitAccountsState>({
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<void> => {
const handleSelectAccount = async (
accountId: string | null,
target: CodexStatusRuntimeTarget
): Promise<void> => {
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<void> => {
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 (
<ProviderDetailsMenu
@ -201,6 +696,14 @@ function ClaudeSwitcherMenu({
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Claude details and account switcher"
topContent={
<AccountRuntimeToggle
groups={switchGroups}
value={selectedGroup?.key ?? selectedRuntimeKey}
onChange={(group) => void handleSelectRuntime(group)}
ariaLabel="Claude usage runtime"
/>
}
open={open}
onOpenChange={handleOpenChange}
>
@ -212,7 +715,7 @@ function ClaudeSwitcherMenu({
}}
>
<span className="max-w-[180px] truncate text-[12px] text-foreground">
{activeAccountLabel}
{activeTarget?.label ?? 'System default'}
</span>
{accountsExpanded ? (
<ChevronDown className="ml-auto size-3.5 text-muted-foreground/85" />
@ -226,25 +729,34 @@ function ClaudeSwitcherMenu({
Switch to
</div>
<div className="max-h-[220px] overflow-y-auto rounded-md border border-border/60 bg-accent/5 p-1 scrollbar-sleek">
{availableSwitchTargets.length === 0 ? (
{selectedGroup?.targets.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">No other accounts</div>
) : null}
{availableSwitchTargets.map((target) => {
{selectedGroup?.targets.map((target) => {
const inactiveUsage = target.id
? inactiveClaudeAccounts.find((a) => a.accountId === target.id)
: null
return (
<DropdownMenuItem
key={target.id ?? 'system'}
disabled={isSwitching}
key={`${selectedGroup.key}:${target.id ?? 'system'}`}
disabled={isSwitching || target.active}
onSelect={(event) => {
event.preventDefault()
void handleSelectAccount(target.id)
if (!target.active) {
void handleSelectAccount(target.id, target.runtimeTarget)
}
}}
>
<div className="flex w-full flex-col gap-0.5">
<span className="max-w-[220px] truncate">{target.label}</span>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">{target.label}</span>
{target.active ? (
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
Active
</span>
) : null}
</div>
{inactiveUsage?.isFetching && !inactiveUsage.claude ? (
<InlineUsageSkeleton />
) : 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<void> => {
const handleSelectAccount = async (
accountId: string | null,
target: CodexStatusRuntimeTarget
): Promise<void> => {
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<void> => {
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 (
<ProviderDetailsMenu
@ -579,6 +1121,14 @@ function CodexSwitcherMenu({
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Codex details and account switcher"
topContent={
<AccountRuntimeToggle
groups={switchGroups}
value={selectedGroup?.key ?? selectedRuntimeKey}
onChange={(group) => void handleSelectRuntime(group)}
ariaLabel="Codex usage runtime"
/>
}
open={open}
onOpenChange={handleOpenChange}
>
@ -589,9 +1139,13 @@ function CodexSwitcherMenu({
setAccountsExpanded((prev) => !prev)
}}
>
<span className="max-w-[180px] truncate text-[12px] text-foreground">
{activeAccountLabel}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5 text-[12px]">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-foreground">
{activeTarget?.label ?? 'System default'}
</span>
</div>
</div>
{accountsExpanded ? (
<ChevronDown className="ml-auto size-3.5 text-muted-foreground/85" />
) : (
@ -600,45 +1154,52 @@ function CodexSwitcherMenu({
</DropdownMenuItem>
{accountsExpanded ? (
<div className="px-1 pb-1">
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Switch to
</div>
<div className="max-h-[220px] overflow-y-auto rounded-md border border-border/60 bg-accent/5 p-1 scrollbar-sleek">
{availableSwitchTargets.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">No other accounts</div>
) : 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 (
<DropdownMenuItem
key={target.id ?? 'system'}
onSelect={(event) => {
// 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}
>
<div className="flex w-full flex-col gap-0.5">
<span className="truncate">{target.label}</span>
{inactiveUsage?.isFetching && !inactiveUsage.claude ? (
<InlineUsageSkeleton />
) : inactiveUsage?.claude ? (
<InlineUsageBars
limits={inactiveUsage.claude}
isFetching={inactiveUsage.isFetching}
/>
) : null}
</div>
</DropdownMenuItem>
)
})}
return (
<DropdownMenuItem
key={`${selectedGroup.key}:${target.id ?? 'system'}`}
onSelect={(event) => {
// 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}
>
<div className="flex w-full min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">{target.label}</span>
{target.active ? (
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
Active
</span>
) : null}
</div>
{inactiveUsage?.isFetching && !inactiveUsage.claude ? (
<InlineUsageSkeleton />
) : inactiveUsage?.claude ? (
<InlineUsageBars
limits={inactiveUsage.claude}
isFetching={inactiveUsage.isFetching}
/>
) : null}
</div>
</DropdownMenuItem>
)
})}
</>
) : null}
</div>
</div>
) : 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({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" sideOffset={8} className="w-[260px]">
{topContent}
<div className="p-2">
<ProviderPanel p={provider} />
</div>

View File

@ -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'] }
])
})
})

View File

@ -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) }
}
})

View File

@ -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')
})
})

View File

@ -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<string, NonNullable<LocalPreflightContext>>()
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<LocalPreflightCo
}
export function getLocalPreflightContext(state: AppState): LocalPreflightContext {
const wslDistro = getLocalPreflightWslDistro(state)
return wslDistro ? getWslPreflightContext(wslDistro) : undefined
}
export function getLocalAgentPreflightContext(state: AppState): LocalPreflightContext {
const explicitAgentRuntime = state.settings?.localAgentRuntime
if (explicitAgentRuntime === 'host') {
return undefined
}
if (explicitAgentRuntime === 'wsl') {
const explicitDistro =
state.settings?.localAgentWslDistro?.trim() ||
state.settings?.terminalWindowsWslDistro?.trim()
if (explicitDistro) {
return getWslPreflightContext(explicitDistro)
}
return wslDefaultPreflightContext
}
const wslDistro = getLocalPreflightWslDistro(state)
if (wslDistro) {
return getWslPreflightContext(wslDistro)
}
if (state.settings?.terminalWindowsShell === 'wsl.exe') {
const preferredDistro = state.settings.terminalWindowsWslDistro?.trim()
if (preferredDistro) {
return getWslPreflightContext(preferredDistro)
}
return wslDefaultPreflightContext
}
return undefined
}
function getLocalPreflightWslDistro(state: AppState): string | null {
const activeWorktree = state.activeWorktreeId
? Object.values(state.worktreesByRepo ?? {})
.flat()
@ -30,10 +65,12 @@ export function getLocalPreflightContext(state: AppState): LocalPreflightContext
: null
const activePath =
activeWorktree?.path ?? (state.repos ?? []).find((repo) => 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'
}

View File

@ -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<typeof vi.fn>
wslListDistros: ReturnType<typeof vi.fn>
pwshIsAvailable: ReturnType<typeof vi.fn>
} {
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 }
}
})

View File

@ -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<WindowsTerminalCap
return loadWindowsTerminalCapabilities({ force: true })
}
export function useWindowsTerminalCapabilities(enabled: boolean): WindowsTerminalCapabilities {
export function useWindowsTerminalCapabilities(
enabled: boolean,
forceRefreshOnMount = false
): WindowsTerminalCapabilities {
const [capabilities, setCapabilities] = useState(getCachedWindowsTerminalCapabilities)
useEffect(() => {
@ -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
}

View File

@ -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()

View File

@ -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<AppState, [], [], DetectedA
pathFailureReason: null,
ensureDetectedAgents: () => {
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<AppState, [], [], DetectedA
},
refreshDetectedAgents: () => {
const context = getLocalPreflightContext(get())
const context = getLocalAgentPreflightContext(get())
const contextKey = localPreflightContextKey(context)
if (refreshPromise?.key === contextKey) {
return refreshPromise.promise

View File

@ -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
}

View File

@ -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<void>
refreshRateLimits: () => Promise<void>
refreshClaudeRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise<void>
refreshCodexRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise<void>
fetchInactiveClaudeAccountUsage: () => Promise<void>
fetchInactiveCodexAccountUsage: () => Promise<void>
setRateLimitsFromPush: (state: RateLimitState) => void
}
export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice> = (set) => ({
export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice> = (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<AppState, [], [], RateLimitSlice
}
},
refreshClaudeRateLimitsForTarget: async (target) => {
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()

View File

@ -439,7 +439,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
},
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<Partial<PreloadApi>['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<Partial<PreloadApi>['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),

View File

@ -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,

View File

@ -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[]
}

View File

@ -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<string, string | null>
}
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<string, string | null>
}
/** 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. */