Make WSL first-class for local agents and provider accounts (#2649)
This commit is contained in:
parent
5ec0e9ad5b
commit
0d060e1d21
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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(':')
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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'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'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's DevTools → Network → any opencode.ai request → Cookie header.
|
||||
OpenCode Go auth is web-based and shared across Windows and WSL terminals.
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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) }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue