Files
orca/src/main/runtime/runtime-account-controller.ts
T
Merge Sim 4e287f5486 Do not offer structured Claude under a WSL-only managed account
Structured Claude launches against the ambient Claude config, which the account
service keeps in sync with the selected HOST account. A WSL-bound managed
account lives inside the distro and is never synced there, so on Windows a
structured session would authenticate as whatever the ambient identity happens
to be while the UI names the WSL account — the user is told one identity and
given another.

That was unreachable only because nothing offered structured Claude on win32.
Enabling it makes it reachable, so gate it here rather than patching the auth
layer: refuse the structured path when the active managed Claude account is
WSL-bound, and let the terminal-backed path — which resolves the account per
runtime — handle that account shape.

The answer rides the agentSession.createSupport seam the renderer already
consumes, so no new capability and no renderer knowledge of account internals.
A create the host declines becomes the definitive refusal the launch fallback
already turns into a legacy native chat tab, with no error toast.

Unknown answers refuse. An install with no managed accounts claims no identity
and is fine, but an active selection that cannot be resolved — or account state
that cannot be read at all — is not evidence that the ambient identity is right.

Claude only. Codex resolves its account through a different path and its
createSupport answer is untouched, as is every Codex routing decision.
2026-09-03 14:06:54 -07:00

179 lines
5.9 KiB
TypeScript

import type { ClaudeAccountService } from '../claude-accounts/service'
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 {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState
} from '../../shared/managed-account-types'
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'
export type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
codexAccounts: CodexAccountService
rateLimits: RateLimitService
}
export type AccountsSnapshot = {
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
setServices(services: RuntimeAccountServices): void {
this.services = services
}
setCommitMessageAgentEnvironment(resolvers: CommitMessageAgentEnvironmentResolvers): void {
this.commitMessageAgentEnvironment = resolvers
}
getCommitMessageAgentEnvironment(): CommitMessageAgentEnvironmentResolvers | undefined {
return this.commitMessageAgentEnvironment ?? undefined
}
getClaudeConfigDirectory(target: ClaudeAccountSelectionTarget): string | null {
return this.services?.claudeAccounts.getRuntimeConfigDir(target) ?? null
}
/** Null when the account state cannot be read, so gates can fail closed instead of throwing. */
readClaudeManagedAccounts(): ClaudeRateLimitAccountsState | null {
try {
return this.services?.claudeAccounts.listAccounts() ?? null
} catch {
return null
}
}
getSnapshot(): AccountsSnapshot {
const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices()
return {
claude: claudeAccounts.listAccounts(),
codex: codexAccounts.listAccounts(),
rateLimits: rateLimits.getState()
}
}
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)
}
addClaudeFromConfigDir(
configDir: string,
options?: {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
previousLegacyCredentialsSha256?: string | null
}
): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.addAccountFromConfigDir(configDir, options)
}
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()
return services.rateLimits.onStateChange((rateLimits) => {
listener({
claude: services.claudeAccounts.listAccounts(),
codex: services.codexAccounts.listAccounts(),
rateLimits
})
})
}
private requireServices(): RuntimeAccountServices {
if (!this.services) {
throw new Error('Account services are not configured on this runtime')
}
return this.services
}
}