Files
orca/src/main/runtime/runtime-account-controller.ts
T
Neil 31e5003572 fix(accounts): launches a WSL host starts itself use the account selected for its distro (#28464)
* fix(accounts): launches a WSL host starts itself use the selected account

The desktop now pushes the account env it selected for a WSL host's distro
to that host's orcad (accounts.setHostLaunchEnv, capability-gated). orcad
lays it over every terminal create (CLI, orchestration, automations, bare
commands and presets), structured chats and AI commit text, as the
desktop's own launches into the distro do. `orca account` on the host now
points at the desktop that owns the accounts.

* fix(accounts): cover splits, child agents and restarts; skip SSH workspaces

Review follow-ups: terminal splits and structured chats' child agents take
the host account env too; an SSH workspace's terminals never get distro
paths; orcad saves the pushed env so a launch before the desktop reconnects
keeps the account; a failed push retries unless a newer one supersedes it.

* fix(accounts): retry a host account push when the host's capabilities are unknown

* test(orcad): the startup mock restores host account env

* refactor(accounts): serialize host account pushes with the shared keyed queue
2026-10-13 19:15:28 -07:00

276 lines
9.4 KiB
TypeScript

import type { ClaudeAccountService } from '../claude-accounts/service'
import { hasAppEnvironment } from '../../shared/app-environment'
import { getManagedDataAccountService } from '../managed-data-accounts/service'
import type {
ClaudeAccountSignIn,
ClaudeRateLimitAccountsState,
ClaudeSignInRequest,
CodexRateLimitAccountsState,
ManagedDataAccountProvider,
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import type {
CodexAccountService,
CodexResetCreditRejectedBeforeProviderReason
} from '../codex-accounts/service'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import type { RateLimitService } from '../rate-limits/service'
import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types'
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import {
hostAccountLaunchEnvFor,
hostAccountsManagedByDesktopMessage,
type HostAccountLaunchEnv
} from '../../shared/host-account-launch-env'
export type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
codexAccounts: CodexAccountService
rateLimits: RateLimitService
}
export type AccountsSnapshot = {
opencode?: ManagedDataAccountsState
devin?: ManagedDataAccountsState
claude: ClaudeRateLimitAccountsState
codex: CodexRateLimitAccountsState
rateLimits: RateLimitState
}
export type CodexRateLimitResetRpcResult = {
scope: CodexResetCreditExpectedScope
snapshot: AccountsSnapshot
} & (
| { outcome: CodexRateLimitResetOutcome }
| {
status: 'rejectedBeforeProvider'
retryDisposition: 'discardAttempt'
reason: CodexResetCreditRejectedBeforeProviderReason
}
)
export class RuntimeAccountController {
private services: RuntimeAccountServices | null = null
private commitMessageAgentEnvironment: CommitMessageAgentEnvironmentResolvers | null = null
/** Pushed by the desktop that owns this host's accounts; held in memory, re-pushed on connect. */
private hostLaunchEnv: HostAccountLaunchEnv | null = null
private persistHostLaunchEnv: ((launchEnv: HostAccountLaunchEnv) => void) | null = null
setServices(services: RuntimeAccountServices): void {
this.services = services
}
setCommitMessageAgentEnvironment(resolvers: CommitMessageAgentEnvironmentResolvers): void {
this.commitMessageAgentEnvironment = resolvers
}
getCommitMessageAgentEnvironment(): CommitMessageAgentEnvironmentResolvers | undefined {
if (this.commitMessageAgentEnvironment) {
return this.commitMessageAgentEnvironment
}
const hostLaunchEnv = this.hostLaunchEnv
if (!hostLaunchEnv) {
return undefined
}
return {
prepareForCodexLaunch: () => hostLaunchEnv.terminal.CODEX_HOME ?? null,
prepareForClaudeLaunch: async () => ({
configDir: hostLaunchEnv.claude.CLAUDE_CONFIG_DIR ?? '',
envPatch: { ...hostLaunchEnv.claude },
provenance: 'desktop-host-launch-env'
})
}
}
/** Refused where this host has account services of its own: they already decide every launch. */
setHostLaunchEnv(launchEnv: HostAccountLaunchEnv): void {
if (this.services) {
throw new Error('This host manages its own provider accounts.')
}
this.hostLaunchEnv = launchEnv
try {
this.persistHostLaunchEnv?.(launchEnv)
} catch (error) {
// Why not refuse: the env already applies; only a restart before the next push loses it.
console.warn('[accounts] Could not save the host account env:', error)
}
}
/** orcad's store: starts from the last pushed env, so launches before a reconnect keep it. */
restoreHostLaunchEnv(
saved: HostAccountLaunchEnv | null,
persist: (launchEnv: HostAccountLaunchEnv) => void
): void {
this.persistHostLaunchEnv = persist
if (saved && !this.services) {
this.hostLaunchEnv = saved
}
}
/** The desktop-selected account env for a process `agent` starts here; empty when none. */
hostLaunchEnvFor(agent: string | null | undefined): Readonly<Record<string, string>> {
return hostAccountLaunchEnvFor(this.hostLaunchEnv, agent)
}
getClaudeConfigDirectory(target: ClaudeAccountSelectionTarget): string | null {
return this.services?.claudeAccounts.getRuntimeConfigDir(target) ?? null
}
getSnapshot(): AccountsSnapshot {
const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices()
return {
...this.dataAccountsSnapshot(),
claude: claudeAccounts.listAccounts(),
codex: codexAccounts.listAccounts(),
rateLimits: rateLimits.getState()
}
}
dataAccountsSnapshot(): Pick<AccountsSnapshot, 'opencode' | 'devin'> {
if (!hasAppEnvironment()) {
return {}
}
const service = getManagedDataAccountService()
return { opencode: service.list('opencode'), devin: service.list('devin') }
}
addDataFromHome(
provider: ManagedDataAccountProvider,
sourceDataHome: string,
label: string
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().add(provider, sourceDataHome, label)
}
selectData(
provider: ManagedDataAccountProvider,
accountId: string | null
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().select(provider, accountId)
}
removeData(
provider: ManagedDataAccountProvider,
accountId: string
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().remove(provider, accountId)
}
async refreshForMobile(): Promise<void> {
const { rateLimits } = this.requireServices()
await Promise.allSettled([
rateLimits.refresh(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
async refreshForMobileSubscriber(): Promise<void> {
const { rateLimits } = this.requireServices()
await Promise.allSettled([
rateLimits.refreshIfStale(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
selectClaude(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.selectAccount(accountId)
}
selectCodex(accountId: string | null): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.selectAccount(accountId)
}
selectCodexForTarget(
accountId: string | null,
target: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.selectAccountForTarget(accountId, target)
}
async consumeCodexResetCredit(
idempotencyKey: string,
expectedScope: CodexResetCreditExpectedScope
): Promise<CodexRateLimitResetRpcResult> {
const { claudeAccounts, codexAccounts } = this.requireServices()
const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope)
const snapshot = {
claude: claudeAccounts.listAccounts(),
codex: result.codex,
rateLimits: result.rateLimits
}
if ('status' in result) {
return {
status: result.status,
retryDisposition: result.retryDisposition,
reason: result.reason,
scope: result.scope,
snapshot
}
}
return { outcome: result.outcome, scope: result.scope, snapshot }
}
removeClaude(accountId: string): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.removeAccount(accountId)
}
beginClaudeSignIn(request: ClaudeSignInRequest): Promise<ClaudeAccountSignIn> {
return this.requireServices().claudeAccounts.beginSignIn(request)
}
finishClaudeSignIn(
signIn: Omit<ClaudeAccountSignIn, 'configDir'>
): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.finishSignIn(signIn)
}
cancelClaudeSignIn(signIn: Omit<ClaudeAccountSignIn, 'configDir'>): Promise<void> {
return this.requireServices().claudeAccounts.cancelSignIn(signIn)
}
removeCodex(accountId: string): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.removeAccount(accountId)
}
addCodexFromHome(
sourceHome: string,
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }
): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.addAccountFromHome(sourceHome, target)
}
onChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
const services = this.requireServices()
const unsubscribeData = hasAppEnvironment()
? getManagedDataAccountService().onChanged(() => listener(this.getSnapshot()))
: () => {}
const unsubscribeUsage = services.rateLimits.onStateChange((rateLimits) => {
listener({
...this.dataAccountsSnapshot(),
claude: services.claudeAccounts.listAccounts(),
codex: services.codexAccounts.listAccounts(),
rateLimits
})
})
return () => {
unsubscribeData()
unsubscribeUsage()
}
}
private requireServices(): RuntimeAccountServices {
if (!this.services) {
throw new Error(
this.hostLaunchEnv
? hostAccountsManagedByDesktopMessage(this.hostLaunchEnv.wslDistro)
: 'Account services are not configured on this runtime'
)
}
return this.services
}
}