mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* feat(accounts): add Claude and Codex accounts to a remote server from a paired client The server runs the login (Claude's pasted-code sign-in, Codex's device code) and keeps the credentials; the client only opens the page and relays the code. New capability-negotiated accounts.hostSignIn* methods; older servers degrade to an update message. Fixes #20009 * fix(accounts): settle host sign-ins by the login's outcome, pin pairing, guard stdin Review fixes: a cancel the login refused still reports the added account; the client pins each flow to its pairing revision; Claude's stdin EPIPE no longer crashes the host; both sides accept only the provider's sign-in host; pasted codes reject whitespace; a host-side supersede says so; Codex cancels its own login handle; opt-in real-CLI test. * fix(accounts): keep an early Cancel, retry polls through the host's window, scan Claude stdout only A Cancel clicked before the host answers the start is applied once it does; the client keeps polling through a short outage until the host would have ended the flow; Claude's link is read from stdout only; a timeout after the code was sent keeps its own message. * test(accounts): name the stdin sender and allow its stream write in the persistence ratchet * fix(accounts): register each Add Account attempt before its first await One attempt per click owns the pairing pin, the cancel flag and the host's flow, so a Cancel during the capability check or the start is never lost; polls retry only lost answers; the stdout forwarder is removed on cleanup. * refactor(accounts): drive the client sign-in as one explicit state machine per server and provider checking -> starting -> following -> settled, with Cancel as a flag the run loop delivers once the host names its flow and re-asks after any answer until the host takes it. A second Add joins the running attempt; a Cancel the server could not be told about is surfaced; retry policy tested. * fix(accounts): retry a lost start answer, and report a cancelled sign-in as cancelled however it ends * fix(accounts): Cancel reaches the server a sign-in runs on, and a typed code stays with its flow The host picker can move while a sign-in runs: Cancel now targets the running server sign-in (or the desktop's own login when none runs), and the pasted-code form is keyed by server and flow. * fix(accounts): run at most one server sign-in per provider, so Cancel has one meaning * fix(accounts): the server sign-in prompt owns its Cancel Cancel for a server sign-in now lives in the prompt bound to that server, which also reappears with its Cancel when Settings reopens mid-sign-in; the header Cancel is only for this desktop's own login. * fix(accounts): hide the desktop-login Cancel while a server sign-in runs; narrow the prompt's status * fix(accounts): keep the desktop-login Cancel in local scope even while a server sign-in runs * fix(accounts): the header Cancel follows the running addition, wherever Settings now points Each Add Account records where it is signing in. Cancel stops that sign-in, on a server or this desktop, and is hidden only where that server's own prompt already offers Cancel.
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import type { ClaudeAccountService } from '../claude-accounts/service'
|
|
import { retireAtRestCommandContext } from '../native-chat/agent-session-wire/at-rest-command-context'
|
|
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 {
|
|
HostAccountSignInChannel,
|
|
HostAccountSignInProvider,
|
|
HostAccountSignInStatus
|
|
} from '../../shared/host-account-sign-in-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'
|
|
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.')
|
|
}
|
|
const previous = this.hostLaunchEnv
|
|
this.hostLaunchEnv = launchEnv
|
|
if (JSON.stringify(previous) !== JSON.stringify(launchEnv)) {
|
|
retireAtRestCommandContext()
|
|
}
|
|
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) {
|
|
const previous = this.hostLaunchEnv
|
|
this.hostLaunchEnv = saved
|
|
if (JSON.stringify(previous) !== JSON.stringify(saved)) {
|
|
retireAtRestCommandContext()
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
|
|
/** Null where the desktop that owns this host's accounts polls usage instead (a WSL host). */
|
|
usageRateLimits(): RateLimitService | null {
|
|
return this.services?.rateLimits ?? null
|
|
}
|
|
|
|
/** Refuses where no poller would read a saved key, so a save is never reported for nothing. */
|
|
requireUsageRateLimits(): RateLimitService {
|
|
return this.requireServices().rateLimits
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/** Adds a host account whose login a client on another device finishes. */
|
|
async runHostSignIn(
|
|
provider: HostAccountSignInProvider,
|
|
channel: HostAccountSignInChannel
|
|
): Promise<Pick<HostAccountSignInStatus, 'claude' | 'codex'>> {
|
|
const { claudeAccounts, codexAccounts } = this.requireServices()
|
|
return provider === 'claude'
|
|
? { claude: await claudeAccounts.addAccountWithPastedCode(channel) }
|
|
: { codex: await codexAccounts.addAccountWithDeviceCode(channel) }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|