mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor: split Codex account service modules (#17277)
* Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Split local PTY provider responsibilities * Split CDP bridge responsibilities * Split relay Git handler responsibilities * Track moved relay Git fetch audit * Split Linear item drawer responsibilities * Split telemetry event schema responsibilities * Split resource usage status responsibilities * Split remote terminal multiplexer responsibilities * Split Git worktree responsibilities * Split Codex hook service responsibilities * Keep mirrored hook trust type private * Split web runtime session responsibilities * Split GitHub project view read path * Split Claude runtime auth responsibilities * Split runtime RPC server responsibilities * Split Settings page responsibilities * Split filesystem watcher responsibilities * refactor codex account service modules * fix duplicate managed home path import * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161 * Fix F7F8-codex for #17277
This commit is contained in:
@@ -7,7 +7,6 @@ inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CodexAccountIdentity } from './codex-account-identity'
|
||||
import { readCodexAuthIdentity } from './codex-auth-identity'
|
||||
|
||||
/** Encodes a JWT whose payload carries the given claims; only the payload segment is read. */
|
||||
function idTokenWithClaims(claims: Record<string, unknown>): string {
|
||||
const payload = Buffer.from(JSON.stringify(claims), 'utf-8').toString('base64url')
|
||||
return `header.${payload}.signature`
|
||||
}
|
||||
|
||||
function homeWithAuthJson(auth: Record<string, unknown>): string {
|
||||
const home = mkdtempSync(join(tmpdir(), 'codex-identity-'))
|
||||
writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), 'utf-8')
|
||||
return home
|
||||
}
|
||||
|
||||
describe('managed-home Codex identity', () => {
|
||||
const identity = new CodexAccountIdentity((candidatePath) => candidatePath)
|
||||
|
||||
it('resolves no identity for an API-key login that still holds a stale tokens blob', () => {
|
||||
// Why: the stale OAuth email is not who this credential authenticates as, and it is
|
||||
// the claim used to prove a shared-home auth.json belongs to the selected account.
|
||||
const home = homeWithAuthJson({
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
tokens: {
|
||||
account_id: 'stale-account',
|
||||
id_token: idTokenWithClaims({ email: 'stale@example.com' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(identity.readFromHome(home, 'account-1')).toEqual({
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('still resolves an OAuth identity when no API key is declared', () => {
|
||||
const home = homeWithAuthJson({
|
||||
tokens: {
|
||||
account_id: 'real-account',
|
||||
id_token: idTokenWithClaims({ email: 'real@example.com' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(identity.readFromHome(home, 'account-1')).toMatchObject({
|
||||
email: 'real@example.com',
|
||||
providerAccountId: 'real-account'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCodexAuthIdentity account-id fallback', () => {
|
||||
it('falls through to the auth claim for providerAccountId when tokens.account_id is blank', () => {
|
||||
// Why: a blank token field must not end the fallback chain — an empty string is not nullish.
|
||||
const contents = JSON.stringify({
|
||||
tokens: {
|
||||
account_id: ' ',
|
||||
id_token: idTokenWithClaims({
|
||||
'https://api.openai.com/auth': { chatgpt_account_id: 'claim-account' }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(readCodexAuthIdentity(contents)).toMatchObject({
|
||||
providerAccountId: 'claim-account',
|
||||
// workspaceAccountId's chain skips the auth claim and ends at the top-level one.
|
||||
workspaceAccountId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('falls through to the top-level claim for workspaceAccountId when tokens.account_id is blank', () => {
|
||||
const contents = JSON.stringify({
|
||||
tokens: {
|
||||
account_id: '',
|
||||
id_token: idTokenWithClaims({ chatgpt_account_id: 'top-level-account' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(readCodexAuthIdentity(contents)).toMatchObject({
|
||||
providerAccountId: 'top-level-account',
|
||||
workspaceAccountId: 'top-level-account'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
import type { CodexSystemDefaultIdentity } from '../../shared/managed-account-types'
|
||||
import { readCodexAuthIdentity, type CodexAuthIdentity } from './codex-auth-identity'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership'
|
||||
|
||||
export type ResolvedCodexIdentity = CodexAuthIdentity
|
||||
|
||||
/** API-key logins carry no OAuth identity even when a stale `tokens` blob is still present. */
|
||||
function declaresApiKeyCredential(parsed: unknown): boolean {
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
const apiKey = (parsed as Record<string, unknown>).OPENAI_API_KEY
|
||||
return typeof apiKey === 'string' && apiKey.trim() !== ''
|
||||
}
|
||||
|
||||
export class CodexAccountIdentity {
|
||||
constructor(
|
||||
private readonly assertManagedHomePath: (
|
||||
candidatePath: string,
|
||||
expectedAccountId?: string
|
||||
) => string
|
||||
) {}
|
||||
|
||||
readFromHome(managedHomePath: string, expectedAccountId: string): ResolvedCodexIdentity {
|
||||
const authFilePath = join(
|
||||
this.assertManagedHomePath(managedHomePath, expectedAccountId),
|
||||
'auth.json'
|
||||
)
|
||||
let contents: string
|
||||
try {
|
||||
contents = readFileSync(authFilePath, 'utf-8')
|
||||
} catch (error) {
|
||||
// Why: an unreadable auth.json is not a missing credential. Surfacing it as
|
||||
// a generic failure is what lets the add path's rollback delete a home
|
||||
// holding freshly authenticated bytes.
|
||||
if (isDefinitiveAbsence(error)) {
|
||||
throw error
|
||||
}
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error })
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(contents)
|
||||
} catch {
|
||||
// Why: a raw SyntaxError echoes credential bytes into logs/error UI; a
|
||||
// corrupt auth.json must fail loudly but without them (same sanitization
|
||||
// intent as the system-default identity path, which degrades instead).
|
||||
throw new Error('Codex auth.json is corrupt or not valid JSON')
|
||||
}
|
||||
// Why: API-key-based auth files have no OAuth tokens or JWT identity
|
||||
// claims. Returning nulls causes the caller to fail with a clear
|
||||
// "could not resolve the account email" error rather than crashing
|
||||
// on missing nested token fields.
|
||||
if (declaresApiKeyCredential(parsed)) {
|
||||
return {
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null
|
||||
}
|
||||
}
|
||||
const identity = readCodexAuthIdentity(contents)
|
||||
if (!identity) {
|
||||
return {
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null
|
||||
}
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// Why: the system-default (activeAccountId:null) account has no stored
|
||||
// identity — its effective login is whatever the real ~/.codex/auth.json is
|
||||
// right now. Read it live and read-only so the switcher can display who the
|
||||
// system default is and attribute usage, without ever mutating ~/.codex.
|
||||
resolveSystemDefault(): CodexSystemDefaultIdentity {
|
||||
let contents: string
|
||||
try {
|
||||
// Why: a single read avoids an exists/read race and halves filesystem
|
||||
// probes whenever an accounts snapshot resolves this live identity.
|
||||
contents = readFileSync(join(homedir(), '.codex', 'auth.json'), 'utf-8')
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
// Why: no auth.json means either a signed-out home or an env-key/custom
|
||||
// provider that authenticates via OPENAI_API_KEY instead of a token file.
|
||||
return this.systemDefaultIdentity(false, this.hasEnvApiKey() ? 'api-key' : 'none')
|
||||
}
|
||||
console.warn(
|
||||
'[codex-accounts] Failed to read system-default Codex identity',
|
||||
code ?? 'unknown-error'
|
||||
)
|
||||
return this.systemDefaultIdentity(true, 'none')
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(contents)
|
||||
} catch {
|
||||
// Why: SyntaxError messages can echo malformed input; never let auth
|
||||
// contents or token fragments reach logs while degrading safely.
|
||||
console.warn('[codex-accounts] System-default Codex auth is not valid JSON')
|
||||
return this.systemDefaultIdentity(true, 'none')
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
// Why: valid JSON can still have the wrong shape; account listing must
|
||||
// degrade to an unknown identity instead of crashing the settings pane.
|
||||
console.warn('[codex-accounts] System-default Codex auth has an unexpected format')
|
||||
return this.systemDefaultIdentity(true, 'none')
|
||||
}
|
||||
if (declaresApiKeyCredential(parsed)) {
|
||||
// Why: API-key/custom-provider logins carry no OAuth identity or ChatGPT
|
||||
// usage. Surface them as a custom provider, not a blank/broken row.
|
||||
return this.systemDefaultIdentity(true, 'api-key')
|
||||
}
|
||||
|
||||
const identity = readCodexAuthIdentity(contents)
|
||||
return {
|
||||
hasAuth: true,
|
||||
authKind: 'oauth',
|
||||
email: identity?.email ?? null,
|
||||
providerAccountId: identity?.providerAccountId ?? null,
|
||||
workspaceLabel: identity?.workspaceLabel ?? null
|
||||
}
|
||||
}
|
||||
|
||||
private systemDefaultIdentity(
|
||||
hasAuth: boolean,
|
||||
authKind: CodexSystemDefaultIdentity['authKind']
|
||||
): CodexSystemDefaultIdentity {
|
||||
return {
|
||||
hasAuth,
|
||||
authKind,
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
private hasEnvApiKey(): boolean {
|
||||
const key = process.env.OPENAI_API_KEY
|
||||
return typeof key === 'string' && key.trim() !== ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
CodexManagedAccount,
|
||||
CodexRateLimitAccountsState
|
||||
} from '../../shared/managed-account-types'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import type { ResolvedCodexIdentity } from './codex-account-identity'
|
||||
import type {
|
||||
CodexAccountAddTarget,
|
||||
CodexAccountReauthenticateOptions,
|
||||
ManagedCodexHomeLocation
|
||||
} from './codex-account-service-types'
|
||||
import type { CodexAccountSelection } from './codex-account-selection'
|
||||
import type { CodexConfigMirror } from './codex-config-mirror'
|
||||
import type { CodexManagedHomeLifecycle } from './codex-managed-home-lifecycle'
|
||||
import type { CodexManagedHomePath } from './codex-managed-home-path'
|
||||
import {
|
||||
getCodexSelectionTargetForAccount,
|
||||
getSelectedCodexAccountIdForTarget,
|
||||
normalizeCodexRuntimeSelection,
|
||||
setSelectedCodexAccountIdForTarget,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
|
||||
type CodexAccountRegistrationDependencies = {
|
||||
store: Store
|
||||
rateLimits: RateLimitService
|
||||
runtimeHome: CodexRuntimeHomeService
|
||||
readIdentityFromHome: (
|
||||
managedHomePath: string,
|
||||
expectedAccountId: string
|
||||
) => ResolvedCodexIdentity
|
||||
selection: CodexAccountSelection
|
||||
configMirror: CodexConfigMirror
|
||||
managedHomePaths: CodexManagedHomePath
|
||||
managedHomes: CodexManagedHomeLifecycle
|
||||
login: (managedHomePath: string) => Promise<void>
|
||||
}
|
||||
|
||||
export class CodexAccountRegistration {
|
||||
constructor(private readonly dependencies: CodexAccountRegistrationDependencies) {}
|
||||
|
||||
async add(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedHome = await this.dependencies.managedHomes.create(accountId, target)
|
||||
const { managedHomePath } = managedHome
|
||||
try {
|
||||
this.prepareManagedHomeForLogin(managedHomePath, accountId)
|
||||
await this.dependencies.login(managedHomePath)
|
||||
return await this.persistCapturedAccount(accountId, managedHome)
|
||||
} catch (error) {
|
||||
this.dependencies.managedHomes.removeUnlessUnproven(error, managedHomePath, accountId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async addFromHome(
|
||||
sourceHome: string,
|
||||
target?: CodexAccountAddTarget
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedHome = await this.dependencies.managedHomes.create(accountId, target)
|
||||
const { managedHomePath } = managedHome
|
||||
try {
|
||||
this.prepareManagedHomeForLogin(managedHomePath, accountId)
|
||||
this.dependencies.managedHomes.importAuthFromHome(sourceHome, managedHomePath, accountId)
|
||||
return await this.persistCapturedAccount(accountId, managedHome)
|
||||
} catch (error) {
|
||||
this.dependencies.managedHomes.removeUnlessUnproven(error, managedHomePath, accountId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async reauthenticate(
|
||||
accountId: string,
|
||||
options?: CodexAccountReauthenticateOptions
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
const account = this.dependencies.selection.requireAccount(accountId)
|
||||
const managedHomePath =
|
||||
await this.dependencies.managedHomePaths.ensureForReauthentication(account)
|
||||
const accountTarget = getCodexSelectionTargetForAccount(account)
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(
|
||||
this.dependencies.store.getSettings(),
|
||||
accountTarget
|
||||
)
|
||||
// Why: decided from the pre-login capture, never a post-login read — the
|
||||
// runtime-home poll runs outside the mutation queue and can clear this lane
|
||||
// while OAuth is open, which would look like an empty selection to activate.
|
||||
const activateAfterLogin =
|
||||
options?.activateIfSelectionWasEmpty === true && selectedAccountId === null
|
||||
|
||||
this.dependencies.configMirror.safeSyncIntoManagedHome(managedHomePath, undefined, account.id)
|
||||
await this.dependencies.login(managedHomePath)
|
||||
const identity = this.dependencies.readIdentityFromHome(managedHomePath, account.id)
|
||||
if (!identity.email) {
|
||||
throw new Error('Codex login completed, but Orca could not resolve the account email.')
|
||||
}
|
||||
|
||||
const settings = this.dependencies.store.getSettings()
|
||||
const now = Date.now()
|
||||
const updatedAccounts = settings.codexManagedAccounts.map((entry) =>
|
||||
entry.id === accountId
|
||||
? {
|
||||
...entry,
|
||||
email: identity.email!,
|
||||
providerAccountId: identity.providerAccountId,
|
||||
workspaceLabel: identity.workspaceLabel,
|
||||
workspaceAccountId: identity.workspaceAccountId,
|
||||
updatedAt: now,
|
||||
lastAuthenticatedAt: now
|
||||
}
|
||||
: entry
|
||||
)
|
||||
const activeSelection = setSelectedCodexAccountIdForTarget(
|
||||
normalizeCodexRuntimeSelection(settings),
|
||||
activateAfterLogin ? accountId : selectedAccountId,
|
||||
accountTarget
|
||||
)
|
||||
|
||||
// Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative.
|
||||
this.dependencies.store.updateSettings({
|
||||
codexManagedAccounts: updatedAccounts,
|
||||
activeCodexManagedAccountId: activeSelection.host,
|
||||
activeCodexManagedAccountIdsByRuntime: activeSelection
|
||||
})
|
||||
this.dependencies.configMirror.safeSyncToManagedHomes()
|
||||
this.dependencies.runtimeHome.clearLastWrittenAuthJson(accountId)
|
||||
this.dependencies.runtimeHome.syncForCurrentSelection(accountTarget)
|
||||
// Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota.
|
||||
this.startQuotaRefresh(undefined, accountTarget)
|
||||
return this.dependencies.selection.snapshot()
|
||||
}
|
||||
|
||||
private prepareManagedHomeForLogin(managedHomePath: string, accountId: string): void {
|
||||
const canonicalConfig = this.dependencies.configMirror.readForManagedHome(managedHomePath)
|
||||
this.dependencies.configMirror.assertOAuthAccountAddAllowed(canonicalConfig)
|
||||
this.dependencies.configMirror.safeSyncIntoManagedHome(
|
||||
managedHomePath,
|
||||
canonicalConfig,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
private async persistCapturedAccount(
|
||||
accountId: string,
|
||||
managedHome: ManagedCodexHomeLocation
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
const identity = this.dependencies.readIdentityFromHome(managedHome.managedHomePath, accountId)
|
||||
if (!identity.email) {
|
||||
throw new Error('Codex login completed, but Orca could not resolve the account email.')
|
||||
}
|
||||
const now = Date.now()
|
||||
const account: CodexManagedAccount = {
|
||||
id: accountId,
|
||||
email: identity.email,
|
||||
managedHomePath: managedHome.managedHomePath,
|
||||
managedHomeRuntime: managedHome.managedHomeRuntime,
|
||||
wslDistro: managedHome.wslDistro,
|
||||
wslLinuxHomePath: managedHome.wslLinuxHomePath,
|
||||
providerAccountId: identity.providerAccountId,
|
||||
workspaceLabel: identity.workspaceLabel,
|
||||
workspaceAccountId: identity.workspaceAccountId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastAuthenticatedAt: now
|
||||
}
|
||||
|
||||
const settings = this.dependencies.store.getSettings()
|
||||
const selection = normalizeCodexRuntimeSelection(settings)
|
||||
const targetSelection = getCodexSelectionTargetForAccount(account)
|
||||
this.dependencies.store.updateSettings({
|
||||
codexManagedAccounts: [...settings.codexManagedAccounts, account],
|
||||
activeCodexManagedAccountId: targetSelection.runtime === 'host' ? account.id : selection.host,
|
||||
activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget(
|
||||
selection,
|
||||
account.id,
|
||||
targetSelection
|
||||
)
|
||||
})
|
||||
try {
|
||||
this.dependencies.configMirror.safeSyncToManagedHomes()
|
||||
this.dependencies.runtimeHome.clearLastWrittenAuthJson(account.id)
|
||||
// Why: pass the account's selection target so a WSL account syncs the WSL
|
||||
// runtime home instead of the default host target.
|
||||
this.dependencies.runtimeHome.syncForCurrentSelection(targetSelection)
|
||||
} catch (error) {
|
||||
// Why: settings were already written; if a post-write step fails, restore the
|
||||
// previous account/selection so the caller's managed-home cleanup cannot leave
|
||||
// a dangling, broken managed account behind in settings.
|
||||
this.dependencies.store.updateSettings({
|
||||
codexManagedAccounts: settings.codexManagedAccounts,
|
||||
activeCodexManagedAccountId: settings.activeCodexManagedAccountId,
|
||||
activeCodexManagedAccountIdsByRuntime: settings.activeCodexManagedAccountIdsByRuntime
|
||||
})
|
||||
// Why: a failed post-write step must restore both persisted selection and
|
||||
// the runtime home it drives before the new managed home is removed.
|
||||
try {
|
||||
this.dependencies.runtimeHome.syncForCurrentSelection(targetSelection)
|
||||
} catch (rollbackError) {
|
||||
console.warn(
|
||||
'[codex-accounts] Failed to restore runtime home during rollback:',
|
||||
rollbackError
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection)
|
||||
// Why: switching activates the new account, so cache the outgoing account's usage for the
|
||||
// switcher — in the background, since the probe must never block or fail a durable add.
|
||||
this.startQuotaRefresh(outgoingAccountId, targetSelection)
|
||||
return this.dependencies.selection.snapshot()
|
||||
}
|
||||
|
||||
private startQuotaRefresh(
|
||||
outgoingAccountId: string | null | undefined,
|
||||
target: CodexAccountSelectionTarget | undefined
|
||||
): void {
|
||||
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
|
||||
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh.
|
||||
// The refresh synchronously flips usage to "fetching" before its first await,
|
||||
// so the switcher updates immediately; the probe itself must never block or
|
||||
// fail the already-durable account mutation.
|
||||
void this.dependencies.rateLimits
|
||||
.refreshForCodexAccountChange(outgoingAccountId, target)
|
||||
.catch((error) => {
|
||||
console.error('[codex-accounts] Quota refresh after account change failed:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
CodexManagedAccount,
|
||||
CodexRateLimitAccountsState,
|
||||
CodexSystemDefaultIdentity
|
||||
} from '../../shared/managed-account-types'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import type { CodexConfigMirror } from './codex-config-mirror'
|
||||
import type { CodexAccountServiceLifecycle } from './codex-account-service-types'
|
||||
import { toCodexManagedAccountSummary } from './codex-account-service-types'
|
||||
import {
|
||||
getCodexSelectionTargetForAccount,
|
||||
getSelectedCodexAccountIdForTarget,
|
||||
normalizeCodexAccountSelectionTarget,
|
||||
normalizeCodexRuntimeSelection,
|
||||
pruneInvalidCodexRuntimeSelection,
|
||||
removeCodexAccountIdFromSelection,
|
||||
setSelectedCodexAccountIdForTarget,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
|
||||
type CodexAccountSelectionDependencies = {
|
||||
store: Store
|
||||
rateLimits: RateLimitService
|
||||
runtimeHome: CodexRuntimeHomeService
|
||||
configMirror: CodexConfigMirror
|
||||
lifecycle: CodexAccountServiceLifecycle
|
||||
resolveSystemDefault: () => CodexSystemDefaultIdentity
|
||||
removeManagedHome: (candidatePath: string, expectedAccountId: string) => void
|
||||
discardResetAttempts: (accountId: string) => void
|
||||
}
|
||||
|
||||
export class CodexAccountSelection {
|
||||
constructor(private readonly dependencies: CodexAccountSelectionDependencies) {}
|
||||
|
||||
list(): CodexRateLimitAccountsState {
|
||||
this.normalizeActiveSelection()
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
snapshot(): CodexRateLimitAccountsState {
|
||||
const settings = this.dependencies.store.getSettings()
|
||||
return {
|
||||
accounts: settings.codexManagedAccounts
|
||||
.map(toCodexManagedAccountSummary)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
activeAccountId: normalizeCodexRuntimeSelection(settings).host,
|
||||
activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings),
|
||||
systemDefault: this.dependencies.resolveSystemDefault()
|
||||
}
|
||||
}
|
||||
|
||||
requireAccount(accountId: string): CodexManagedAccount {
|
||||
const account = this.dependencies.store
|
||||
.getSettings()
|
||||
.codexManagedAccounts.find((entry) => entry.id === accountId)
|
||||
if (!account) {
|
||||
throw new Error('That Codex rate limit account no longer exists.')
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
async remove(accountId: string): Promise<CodexRateLimitAccountsState> {
|
||||
const account = this.requireAccount(accountId)
|
||||
const settings = this.dependencies.store.getSettings()
|
||||
const nextAccounts = settings.codexManagedAccounts.filter((entry) => entry.id !== accountId)
|
||||
const nextSelection = removeCodexAccountIdFromSelection(
|
||||
normalizeCodexRuntimeSelection(settings),
|
||||
accountId
|
||||
)
|
||||
const nextActiveId =
|
||||
settings.activeCodexManagedAccountId === accountId ? null : nextSelection.host
|
||||
|
||||
this.dependencies.store.updateSettings({
|
||||
codexManagedAccounts: nextAccounts,
|
||||
activeCodexManagedAccountId: nextActiveId,
|
||||
activeCodexManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
this.dependencies.runtimeHome.syncForCurrentSelection()
|
||||
if (account.managedHomeRuntime === 'host' && nextSelection.host === null) {
|
||||
this.dependencies.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
|
||||
this.dependencies.removeManagedHome(account.managedHomePath, account.id)
|
||||
// Why: a removed account can no longer appear in the switcher dropdown,
|
||||
// so purge its cached usage to avoid stale entries.
|
||||
this.dependencies.rateLimits.evictInactiveCodexCache(accountId)
|
||||
this.dependencies.discardResetAttempts(accountId)
|
||||
const accountTarget = getCodexSelectionTargetForAccount(account)
|
||||
this.startQuotaRefresh(
|
||||
getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId
|
||||
? accountId
|
||||
: undefined,
|
||||
accountTarget
|
||||
)
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
async select(
|
||||
accountId: string | null,
|
||||
target?: CodexAccountSelectionTarget
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
let effectiveTarget = target
|
||||
if (accountId !== null) {
|
||||
const accountTarget = getCodexSelectionTargetForAccount(this.requireAccount(accountId))
|
||||
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.dependencies.store.getSettings()
|
||||
const outgoingAccountId = getSelectedCodexAccountIdForTarget(previousSettings, effectiveTarget)
|
||||
const nextSelection = setSelectedCodexAccountIdForTarget(
|
||||
normalizeCodexRuntimeSelection(previousSettings),
|
||||
accountId,
|
||||
effectiveTarget
|
||||
)
|
||||
this.dependencies.store.updateSettings({
|
||||
activeCodexManagedAccountId:
|
||||
effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId,
|
||||
activeCodexManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
this.dependencies.configMirror.safeSyncToManagedHomes()
|
||||
this.dependencies.runtimeHome.syncForCurrentSelection(effectiveTarget)
|
||||
if (
|
||||
accountId === null &&
|
||||
normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host'
|
||||
) {
|
||||
this.dependencies.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
|
||||
this.startQuotaRefresh(outgoingAccountId, effectiveTarget)
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
private normalizeActiveSelection(): void {
|
||||
const settings = this.dependencies.store.getSettings()
|
||||
const selection = normalizeCodexRuntimeSelection(settings)
|
||||
const nextSelection = pruneInvalidCodexRuntimeSelection(
|
||||
selection,
|
||||
settings.codexManagedAccounts
|
||||
)
|
||||
const changed =
|
||||
nextSelection.host !== selection.host ||
|
||||
JSON.stringify(nextSelection.wsl) !== JSON.stringify(selection.wsl)
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
this.dependencies.store.updateSettings({
|
||||
activeCodexManagedAccountId: nextSelection.host,
|
||||
activeCodexManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
if (selection.host !== null && nextSelection.host === null) {
|
||||
this.dependencies.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
}
|
||||
|
||||
private startQuotaRefresh(
|
||||
outgoingAccountId: string | null | undefined,
|
||||
target: CodexAccountSelectionTarget | undefined
|
||||
): void {
|
||||
void this.dependencies.rateLimits
|
||||
.refreshForCodexAccountChange(outgoingAccountId, target)
|
||||
.catch((error) => {
|
||||
console.error('[codex-accounts] Quota refresh after account change failed:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
CodexManagedAccount,
|
||||
CodexRateLimitAccountsState,
|
||||
CodexManagedAccountSummary
|
||||
} from '../../shared/managed-account-types'
|
||||
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
|
||||
import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
export type CodexAccountAddTarget = {
|
||||
runtime?: 'host' | 'wsl'
|
||||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
export type CodexAccountReauthenticateOptions = {
|
||||
/**
|
||||
* Local-only intent from the status bar's "Sign in to see usage" action: when
|
||||
* this account's runtime lane had no selection before login, activate the
|
||||
* account that just signed in instead of restoring the empty selection.
|
||||
*/
|
||||
activateIfSelectionWasEmpty?: boolean
|
||||
}
|
||||
|
||||
export type CodexAccountServiceLifecycle = {
|
||||
onHostSystemDefaultSelected?: () => void
|
||||
}
|
||||
|
||||
export type ManagedCodexHomeLocation = {
|
||||
managedHomePath: string
|
||||
managedHomeRuntime: 'host' | 'wsl'
|
||||
wslDistro: string | null
|
||||
wslLinuxHomePath: string | null
|
||||
}
|
||||
|
||||
export type CodexResetCreditRejectedBeforeProviderReason =
|
||||
| 'targetChanged'
|
||||
| 'accountChanged'
|
||||
| 'accountRevisionChanged'
|
||||
| 'accountRuntimeChanged'
|
||||
| 'offerUnavailable'
|
||||
| 'offerChanged'
|
||||
|
||||
export type CodexResetCreditConsumedResult = {
|
||||
outcome: CodexRateLimitResetOutcome
|
||||
scope: CodexResetCreditExpectedScope
|
||||
codex: CodexRateLimitAccountsState
|
||||
rateLimits: RateLimitState
|
||||
}
|
||||
|
||||
export type CodexResetCreditRejectedBeforeProviderResult = {
|
||||
status: 'rejectedBeforeProvider'
|
||||
retryDisposition: 'discardAttempt'
|
||||
reason: CodexResetCreditRejectedBeforeProviderReason
|
||||
scope: CodexResetCreditExpectedScope
|
||||
codex: CodexRateLimitAccountsState
|
||||
rateLimits: RateLimitState
|
||||
}
|
||||
|
||||
export type CodexResetCreditConsumeResult =
|
||||
| CodexResetCreditConsumedResult
|
||||
| CodexResetCreditRejectedBeforeProviderResult
|
||||
|
||||
export function toCodexManagedAccountSummary(
|
||||
account: CodexManagedAccount
|
||||
): CodexManagedAccountSummary {
|
||||
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,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
lastAuthenticatedAt: account.lastAuthenticatedAt
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
|
||||
type CodexAuthIdentity = {
|
||||
export type CodexAuthIdentity = {
|
||||
email: string | null
|
||||
providerAccountId: string | null
|
||||
workspaceLabel: string | null
|
||||
workspaceAccountId: string | null
|
||||
}
|
||||
|
||||
type CodexAuthOwnershipIdentity = Omit<CodexAuthIdentity, 'workspaceLabel'>
|
||||
|
||||
// Why: stale shared-home PTYs can write after an account switch, so read-back
|
||||
// needs a positive claim match instead of trusting the selected path alone.
|
||||
export function codexAuthMatchesManagedAccount(
|
||||
@@ -13,13 +16,11 @@ export function codexAuthMatchesManagedAccount(
|
||||
account: CodexManagedAccount,
|
||||
managedAuthContents: string | null
|
||||
): boolean {
|
||||
const identity = readIdentityFromAuthContents(runtimeAuthContents)
|
||||
const identity = readCodexAuthIdentity(runtimeAuthContents)
|
||||
if (!identity) {
|
||||
return false
|
||||
}
|
||||
const managedIdentity = managedAuthContents
|
||||
? readIdentityFromAuthContents(managedAuthContents)
|
||||
: null
|
||||
const managedIdentity = managedAuthContents ? readCodexAuthIdentity(managedAuthContents) : null
|
||||
const selectedEmail = firstNonNull(normalizeField(account.email), managedIdentity?.email)
|
||||
const selectedProviderId = firstNonNull(
|
||||
normalizeField(account.providerAccountId),
|
||||
@@ -65,7 +66,7 @@ export function codexAuthCouldBelongToManagedAccount(
|
||||
runtimeAuthContents: string,
|
||||
account: CodexManagedAccount
|
||||
): boolean {
|
||||
const identity = readIdentityFromAuthContents(runtimeAuthContents)
|
||||
const identity = readCodexAuthIdentity(runtimeAuthContents)
|
||||
return (
|
||||
!identity ||
|
||||
!identityContradictsSelection(
|
||||
@@ -85,8 +86,8 @@ export function codexAuthMatchesSystemDefaultIdentity(
|
||||
runtimeAuthContents: string,
|
||||
systemDefaultAuthContents: string
|
||||
): boolean {
|
||||
const runtimeIdentity = readIdentityFromAuthContents(runtimeAuthContents)
|
||||
const systemDefaultIdentity = readIdentityFromAuthContents(systemDefaultAuthContents)
|
||||
const runtimeIdentity = readCodexAuthIdentity(runtimeAuthContents)
|
||||
const systemDefaultIdentity = readCodexAuthIdentity(systemDefaultAuthContents)
|
||||
if (!runtimeIdentity || !systemDefaultIdentity) {
|
||||
return false
|
||||
}
|
||||
@@ -154,7 +155,7 @@ export function codexAuthIsFresher(
|
||||
return compareCodexAuthFreshness(candidateAuthContents, baselineAuthContents) === 1
|
||||
}
|
||||
|
||||
function readIdentityFromAuthContents(contents: string): CodexAuthIdentity | null {
|
||||
export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | null {
|
||||
const raw = parseJsonRecord(contents)
|
||||
if (!raw) {
|
||||
return null
|
||||
@@ -166,21 +167,28 @@ function readIdentityFromAuthContents(contents: string): CodexAuthIdentity | nul
|
||||
const payload = idToken ? parseJwtPayload(idToken) : null
|
||||
const authClaims = readRecordClaim(payload, 'https://api.openai.com/auth')
|
||||
const profileClaims = readRecordClaim(payload, 'https://api.openai.com/profile')
|
||||
// Why: normalize before the fallback chains, not after — a blank tokens.account_id
|
||||
// must fall through to the JWT claims rather than ending the chain on an empty string.
|
||||
const tokenAccountId = normalizeField(
|
||||
readStringClaim(tokens, 'account_id') ?? readStringClaim(tokens, 'accountId')
|
||||
)
|
||||
|
||||
return {
|
||||
email: normalizeField(
|
||||
readStringClaim(payload, 'email') ?? readStringClaim(profileClaims, 'email')
|
||||
),
|
||||
providerAccountId: normalizeField(
|
||||
readStringClaim(tokens, 'account_id') ??
|
||||
readStringClaim(tokens, 'accountId') ??
|
||||
tokenAccountId ??
|
||||
readStringClaim(authClaims, 'chatgpt_account_id') ??
|
||||
readStringClaim(payload, 'chatgpt_account_id')
|
||||
),
|
||||
workspaceLabel: normalizeField(
|
||||
readStringClaim(authClaims, 'workspace_name') ??
|
||||
readStringClaim(profileClaims, 'workspace_name')
|
||||
),
|
||||
workspaceAccountId: normalizeField(
|
||||
readStringClaim(authClaims, 'workspace_account_id') ??
|
||||
readStringClaim(tokens, 'account_id') ??
|
||||
readStringClaim(tokens, 'accountId') ??
|
||||
tokenAccountId ??
|
||||
readStringClaim(payload, 'chatgpt_account_id')
|
||||
)
|
||||
}
|
||||
@@ -274,8 +282,8 @@ function identityFieldMatches(selectedField: string | null, runtimeField: string
|
||||
// no identity claims (api key, PAT, bedrock) contradicts nothing. A ChatGPT
|
||||
// rename leaves the record email stale, so a matching account id outranks it.
|
||||
function identityContradictsSelection(
|
||||
selected: CodexAuthIdentity,
|
||||
identity: CodexAuthIdentity
|
||||
selected: CodexAuthOwnershipIdentity,
|
||||
identity: CodexAuthOwnershipIdentity
|
||||
): boolean {
|
||||
if (
|
||||
identityFieldsConflict(selected.providerAccountId, identity.providerAccountId) ||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config'
|
||||
import type { Store } from '../persistence'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
|
||||
export type CanonicalCodexConfig = {
|
||||
contents: string
|
||||
/** Host-readable source home; the mirror resolves WSL UNC paths to their Linux spelling. */
|
||||
sourceHomePath: string
|
||||
/** Preserve Linux path semantics when WSL $HOME is under /mnt/<drive>. */
|
||||
sourceConfigDir?: string
|
||||
}
|
||||
|
||||
export class CodexConfigMirror {
|
||||
constructor(
|
||||
private readonly store: Store,
|
||||
private readonly assertManagedHomePath: (
|
||||
candidatePath: string,
|
||||
expectedAccountId?: string
|
||||
) => string
|
||||
) {}
|
||||
|
||||
safeSyncToManagedHomes(): void {
|
||||
try {
|
||||
this.syncToManagedHomes()
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to sync canonical config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
safeSyncIntoManagedHome(
|
||||
managedHomePath: string,
|
||||
canonicalConfig?: CanonicalCodexConfig | null,
|
||||
expectedAccountId?: string
|
||||
): void {
|
||||
try {
|
||||
this.syncIntoManagedHome(managedHomePath, canonicalConfig, expectedAccountId)
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to seed managed config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
readForManagedHome(managedHomePath: string): CanonicalCodexConfig | null {
|
||||
const wslInfo = parseWslUncPath(managedHomePath)
|
||||
if (!wslInfo) {
|
||||
return this.readHostConfig()
|
||||
}
|
||||
|
||||
const managedRootMarker = '/.local/share/orca/codex-accounts/'
|
||||
const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker)
|
||||
if (markerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const wslHome = wslInfo.linuxPath.slice(0, markerIndex)
|
||||
const configPath = toWindowsWslPath(`${wslHome}/.codex/config.toml`, wslInfo.distro)
|
||||
if (!existsSync(configPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: the config is read over UNC but consumed by Codex inside WSL, so
|
||||
// path rewrites must anchor to the Linux-side ~/.codex, not the UNC path.
|
||||
return {
|
||||
contents: readFileSync(configPath, 'utf-8'),
|
||||
sourceHomePath: toWindowsWslPath(`${wslHome}/.codex`, wslInfo.distro),
|
||||
sourceConfigDir: `${wslHome}/.codex`
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read WSL canonical config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
assertOAuthAccountAddAllowed(canonicalConfig: CanonicalCodexConfig | null): void {
|
||||
const modelProvider = canonicalConfig
|
||||
? readCodexTopLevelModelProvider(canonicalConfig.contents)
|
||||
: null
|
||||
if (!modelProvider || modelProvider === 'openai') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: mirroring a custom-provider pin into an OAuth managed home makes
|
||||
// the new OAuth credentials inert; fail before login and leave user config intact.
|
||||
throw new Error(
|
||||
`Orca cannot add a Codex OAuth account while ~/.codex/config.toml pins the custom provider ${JSON.stringify(modelProvider)}. Keep using the system-default account for this provider, or remove model_provider (or set it to "openai") before adding an OAuth account. Orca left your config unchanged.`
|
||||
)
|
||||
}
|
||||
|
||||
private syncToManagedHomes(): void {
|
||||
for (const account of this.store.getSettings().codexManagedAccounts) {
|
||||
try {
|
||||
this.syncIntoManagedHome(account.managedHomePath, undefined, account.id)
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to sync managed config:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncIntoManagedHome(
|
||||
managedHomePath: string,
|
||||
canonicalConfig = this.readForManagedHome(managedHomePath),
|
||||
expectedAccountId?: string
|
||||
): void {
|
||||
if (canonicalConfig === null) {
|
||||
return
|
||||
}
|
||||
const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, expectedAccountId)
|
||||
// Why: every account home is Codex's own CODEX_HOME. Preserve trust Codex
|
||||
// granted there while refreshing ordinary settings from the lane's source.
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: trustedManagedHomePath,
|
||||
systemHomePath: canonicalConfig.sourceHomePath,
|
||||
systemConfigDir: canonicalConfig.sourceConfigDir
|
||||
})
|
||||
}
|
||||
|
||||
private readHostConfig(): CanonicalCodexConfig | null {
|
||||
const sourceHomePath = join(homedir(), '.codex')
|
||||
const primaryConfigPath = join(sourceHomePath, 'config.toml')
|
||||
if (!existsSync(primaryConfigPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath }
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read canonical config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
|
||||
import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import {
|
||||
buildWslCodexAvailabilityScript,
|
||||
buildWslCodexLoginArgs,
|
||||
WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
} from './wsl-codex-command'
|
||||
|
||||
const LOGIN_TIMEOUT_MS = 120_000
|
||||
const MAX_LOGIN_OUTPUT_CHARS = 4_000
|
||||
const WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS = 500
|
||||
const WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS = 5_000
|
||||
|
||||
type LoginOutputStream = {
|
||||
on(event: 'data', listener: (chunk: Buffer) => void): unknown
|
||||
off(event: 'data', listener: (chunk: Buffer) => void): unknown
|
||||
}
|
||||
|
||||
export type CodexLoginChild = {
|
||||
stdout: LoginOutputStream | null
|
||||
stderr: LoginOutputStream | null
|
||||
pid?: number
|
||||
exitCode: number | null
|
||||
signalCode: string | null
|
||||
kill(): boolean
|
||||
on(event: 'error', listener: (error: Error) => void): unknown
|
||||
on(event: 'close', listener: (code: number | null) => void): unknown
|
||||
off(event: 'error', listener: (error: Error) => void): unknown
|
||||
off(event: 'close', listener: (code: number | null) => void): unknown
|
||||
}
|
||||
|
||||
export type CodexLoginSpawnRequest = {
|
||||
command: string
|
||||
args: string[]
|
||||
env: NodeJS.ProcessEnv
|
||||
stdio: WindowsHostInteractiveLoginSpawn['stdio'] | ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
|
||||
type CodexLoginSessionDependencies = {
|
||||
wslCommand: string
|
||||
spawn: (request: CodexLoginSpawnRequest) => CodexLoginChild
|
||||
killProcessTree: (
|
||||
child: CodexLoginChild,
|
||||
interactiveLogin?: WindowsHostInteractiveLoginSpawn | null
|
||||
) => void
|
||||
}
|
||||
|
||||
function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined {
|
||||
try {
|
||||
return readFileSync(authJsonPath, 'utf-8')
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return null
|
||||
}
|
||||
// Why: codex can atomically replace auth.json while the poll runs; a later
|
||||
// poll will observe the stable credential. An unreadable initial file must
|
||||
// disable the shortcut rather than look like a fresh login.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function loginAuthChanged(
|
||||
initial: string | null | undefined,
|
||||
current: string | null | undefined
|
||||
): boolean {
|
||||
// Why: metadata-only touches can happen before OAuth finishes. Requiring new
|
||||
// credential bytes prevents reauthentication from being killed prematurely.
|
||||
return initial !== undefined && current !== undefined && current !== null && current !== initial
|
||||
}
|
||||
|
||||
export async function runCodexLoginSession(
|
||||
managedHomePath: string,
|
||||
dependencies: CodexLoginSessionDependencies
|
||||
): Promise<void> {
|
||||
const wslInfo = parseWslUncPath(managedHomePath)
|
||||
if (wslInfo) {
|
||||
await assertWslCodexCliAvailable(wslInfo)
|
||||
}
|
||||
// Why: reauthentication starts with an existing auth.json. Only new auth
|
||||
// bytes prove this login completed; existence alone would kill the
|
||||
// Windows OAuth flow five seconds after it opened.
|
||||
const initialAuthSnapshot = wslInfo
|
||||
? null
|
||||
: readLoginAuthSnapshot(join(managedHomePath, 'auth.json'))
|
||||
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
const spawnConfig = wslInfo
|
||||
? {
|
||||
command: dependencies.wslCommand,
|
||||
args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath),
|
||||
env: process.env,
|
||||
codexCommand: 'codex',
|
||||
interactiveLogin: null
|
||||
}
|
||||
: createHostLoginSpawn(managedHomePath)
|
||||
const child = dependencies.spawn({
|
||||
command: spawnConfig.command,
|
||||
args: spawnConfig.args,
|
||||
env: spawnConfig.env,
|
||||
stdio: spawnConfig.interactiveLogin
|
||||
? spawnConfig.interactiveLogin.stdio
|
||||
: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
let settled = false
|
||||
let output = ''
|
||||
const appendOutput = (chunk: Buffer): void => {
|
||||
output = `${output}${chunk.toString()}`
|
||||
if (output.length > MAX_LOGIN_OUTPUT_CHARS) {
|
||||
output = output.slice(-MAX_LOGIN_OUTPUT_CHARS)
|
||||
}
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let authWatchInterval: ReturnType<typeof setInterval> | null = null
|
||||
let postAuthExitTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let loginTreeKilledAfterAuth = false
|
||||
const authJsonPath = join(managedHomePath, 'auth.json')
|
||||
const cleanupListeners = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (authWatchInterval) {
|
||||
clearInterval(authWatchInterval)
|
||||
authWatchInterval = null
|
||||
}
|
||||
if (postAuthExitTimeout) {
|
||||
clearTimeout(postAuthExitTimeout)
|
||||
postAuthExitTimeout = null
|
||||
}
|
||||
child.stdout?.off('data', appendOutput)
|
||||
child.stderr?.off('data', appendOutput)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
spawnConfig.interactiveLogin?.cleanup?.()
|
||||
}
|
||||
|
||||
const settle = (callback: () => void): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanupListeners()
|
||||
callback()
|
||||
}
|
||||
|
||||
const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.')
|
||||
timeout = setTimeout(() => {
|
||||
dependencies.killProcessTree(child, spawnConfig.interactiveLogin)
|
||||
settle(() => rejectPromise(timeoutError))
|
||||
}, LOGIN_TIMEOUT_MS)
|
||||
|
||||
// Why: on Windows the codex login CLI can linger after writing auth.json,
|
||||
// and its open handles on the managed home (log/codex-login.log) make the
|
||||
// post-login file operations fail with ENOTEMPTY. Once auth.json exists,
|
||||
// give the tree a short grace period to exit, then force it down.
|
||||
if (process.platform === 'win32' && !wslInfo) {
|
||||
authWatchInterval = setInterval(() => {
|
||||
if (!loginAuthChanged(initialAuthSnapshot, readLoginAuthSnapshot(authJsonPath))) {
|
||||
return
|
||||
}
|
||||
if (authWatchInterval) {
|
||||
clearInterval(authWatchInterval)
|
||||
authWatchInterval = null
|
||||
}
|
||||
postAuthExitTimeout = setTimeout(() => {
|
||||
loginTreeKilledAfterAuth = true
|
||||
dependencies.killProcessTree(child, spawnConfig.interactiveLogin)
|
||||
}, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS)
|
||||
}, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const onError = (error: Error): void => {
|
||||
settle(() => {
|
||||
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
// Why: ENOENT is ambiguous — missing codex binary or missing node in PATH; a resolved full path implies node is missing.
|
||||
const isBareCommand = spawnConfig.codexCommand === 'codex'
|
||||
const message = isEnoent
|
||||
? isBareCommand
|
||||
? 'Codex CLI not found.'
|
||||
: 'Codex CLI found but could not run — Node.js may not be in your PATH.'
|
||||
: error.message
|
||||
rejectPromise(new Error(message))
|
||||
})
|
||||
}
|
||||
|
||||
const onClose = (code: number | null): void => {
|
||||
settle(() => {
|
||||
// Why: the post-auth tree kill is a success path — auth.json already
|
||||
// exists and codex only failed to exit on its own, so the forced
|
||||
// non-zero exit must not surface as a login failure.
|
||||
// Why: the kill only arms after the watcher observed new credential
|
||||
// bytes, so an unreadable auth.json here is a lock, not a failed login.
|
||||
// Only a definitive absence may revoke that verdict — reading a lock as
|
||||
// failure sends the caller's rollback at a home that just authenticated.
|
||||
if (
|
||||
code === 0 ||
|
||||
(loginTreeKilledAfterAuth && readLoginAuthSnapshot(authJsonPath) !== null)
|
||||
) {
|
||||
resolvePromise()
|
||||
return
|
||||
}
|
||||
const trimmedOutput = output.trim()
|
||||
rejectPromise(
|
||||
new Error(
|
||||
trimmedOutput
|
||||
? `Codex login failed: ${trimmedOutput}`
|
||||
: `Codex login exited with code ${code ?? 'unknown'}.`
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
child.stdout?.on('data', appendOutput)
|
||||
child.stderr?.on('data', appendOutput)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
function createHostLoginSpawn(managedHomePath: string): {
|
||||
command: string
|
||||
args: string[]
|
||||
env: NodeJS.ProcessEnv
|
||||
codexCommand: string
|
||||
interactiveLogin: WindowsHostInteractiveLoginSpawn | null
|
||||
} {
|
||||
const codexCommand = resolveCodexCommand()
|
||||
// Why: Windows host login needs a real console; otherwise inherit/hide
|
||||
// leaves the child unable to read a paste-code / device-auth prompt.
|
||||
const interactiveLogin =
|
||||
process.platform === 'win32'
|
||||
? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login'])
|
||||
: null
|
||||
const { spawnCmd, spawnArgs } = interactiveLogin
|
||||
? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args }
|
||||
: getSpawnArgsForWindows(codexCommand, ['login'])
|
||||
return {
|
||||
command: spawnCmd,
|
||||
args: spawnArgs,
|
||||
env: withCliRuntimeOnPath(codexCommand, { ...process.env, CODEX_HOME: managedHomePath }),
|
||||
codexCommand,
|
||||
interactiveLogin
|
||||
}
|
||||
}
|
||||
|
||||
async function assertWslCodexCliAvailable(wslInfo: {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}): Promise<void> {
|
||||
// This is a PATH lookup, so it needs the login PATH: an nvm-installed codex
|
||||
// lives nowhere else. Marking it 'none' reports a working install as absent.
|
||||
const result = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'preferred',
|
||||
script: buildWslCodexAvailabilityScript(),
|
||||
// POSIX command lookup; declared because the payload is opaque here.
|
||||
shell: 'sh',
|
||||
timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
})
|
||||
if (result.code !== 0 && !result.environmentResolved) {
|
||||
// A miss without the login PATH is "we could not check", not "not
|
||||
// installed" -- claiming absence here is #9725.
|
||||
throw new Error('Could not check the Codex CLI in WSL. Try again.')
|
||||
}
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(
|
||||
`Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`,
|
||||
{ cause: new Error(result.stderr.trim() || `codex lookup exited with ${result.code}`) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import type { CodexAccountAddTarget, ManagedCodexHomeLocation } from './codex-account-service-types'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership'
|
||||
import type { CodexManagedHomePath } from './codex-managed-home-path'
|
||||
|
||||
// Why: mirrors the Windows rm retry policy in local-worktree-filesystem — a
|
||||
// just-terminated codex login can briefly keep handles inside a managed home.
|
||||
const WINDOWS_RM_MAX_RETRIES = 8
|
||||
const WINDOWS_RM_RETRY_DELAY_MS = 150
|
||||
const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000
|
||||
|
||||
function removeManagedHomeTreeSync(targetPath: string): void {
|
||||
// Why: codex login descendants can briefly keep Windows handles on files in
|
||||
// the managed home (e.g. log/codex-login.log); bounded retries absorb the
|
||||
// transient lock instead of failing with ENOTEMPTY and orphaning the home.
|
||||
rmSync(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: WINDOWS_RM_MAX_RETRIES,
|
||||
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
|
||||
})
|
||||
}
|
||||
|
||||
export class CodexManagedHomeLifecycle {
|
||||
constructor(private readonly paths: CodexManagedHomePath) {}
|
||||
|
||||
async create(
|
||||
accountId: string,
|
||||
target?: CodexAccountAddTarget
|
||||
): Promise<ManagedCodexHomeLocation> {
|
||||
const wslHome = await this.tryCreateWslHome(accountId, target)
|
||||
if (wslHome) {
|
||||
return wslHome
|
||||
}
|
||||
|
||||
const managedHomePath = join(this.paths.getRoot(), accountId, 'home')
|
||||
mkdirSync(managedHomePath, { recursive: true })
|
||||
// Why: marker lets future cleanup prove the path belongs to Orca before deleting anything.
|
||||
writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8')
|
||||
return {
|
||||
managedHomePath: this.paths.assert(managedHomePath, accountId),
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxHomePath: null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: copy the auth.json from an already-authenticated CODEX_HOME (e.g. a temp
|
||||
// dir the CLI ran `codex login` into) into the managed home. Mirrors the login
|
||||
// step of doAddAccount without spawning an interactive browser flow.
|
||||
importAuthFromHome(sourceHome: string, managedHomePath: string, accountId: string): void {
|
||||
const trimmed = sourceHome.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('A Codex home directory path is required.')
|
||||
}
|
||||
const resolvedSourceHome = resolve(trimmed)
|
||||
let sourceAuthContents: string
|
||||
try {
|
||||
sourceAuthContents = readFileSync(join(resolvedSourceHome, 'auth.json'), 'utf-8')
|
||||
} catch (error) {
|
||||
// Why: "no credentials here, run codex login" is only true for a definitive
|
||||
// absence. A locked source file used to produce that advice, which sends the
|
||||
// user to re-run a login they had already completed.
|
||||
if (!isDefinitiveAbsence(error)) {
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error })
|
||||
}
|
||||
throw new Error(
|
||||
`No Codex credentials found in ${resolvedSourceHome}. Run \`codex login\` into this directory first.`
|
||||
)
|
||||
}
|
||||
const trustedHome = this.paths.assert(managedHomePath, accountId)
|
||||
writeFileAtomically(join(trustedHome, 'auth.json'), sourceAuthContents, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback deletes the managed home, so it may only run on a *proven* failure.
|
||||
* An unreadable credential file means the login may well have succeeded, and a
|
||||
* kept home is a recoverable leak where a deleted one is permanent data loss.
|
||||
*/
|
||||
removeUnlessUnproven(error: unknown, managedHomePath: string, accountId: string): void {
|
||||
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
|
||||
return
|
||||
}
|
||||
this.safeRemove(managedHomePath, accountId)
|
||||
}
|
||||
|
||||
safeRemove(candidatePath: string, expectedAccountId: string): void {
|
||||
let managedHomePath: string
|
||||
try {
|
||||
managedHomePath = this.paths.assert(candidatePath, expectedAccountId)
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Refusing to remove untrusted managed home:', error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
removeManagedHomeTreeSync(managedHomePath)
|
||||
} catch (error) {
|
||||
// Why: this runs from error-cleanup paths; a still-held Windows handle
|
||||
// must not mask the original failure with an ENOTEMPTY from rmSync.
|
||||
console.warn('[codex-accounts] Failed to remove managed home:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (parseWslUncPath(managedHomePath)) {
|
||||
try {
|
||||
removeManagedHomeTreeSync(dirname(managedHomePath))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Why: homes live at <accounts-root>/<uuid>/home; removing the home/ leaf leaves an empty <uuid>/ behind.
|
||||
try {
|
||||
const parentDir = resolve(managedHomePath, '..')
|
||||
// Why: canonicalize the root too so the prefix check works on macOS where userData resolves through /private/var.
|
||||
const root = realpathSync(this.paths.getRoot())
|
||||
if (parentDir.startsWith(root + sep) && parentDir !== root) {
|
||||
removeManagedHomeTreeSync(parentDir)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
private async tryCreateWslHome(
|
||||
accountId: string,
|
||||
target?: CodexAccountAddTarget
|
||||
): Promise<ManagedCodexHomeLocation | null> {
|
||||
if (process.platform !== 'win32' || target?.runtime !== 'wsl') {
|
||||
return null
|
||||
}
|
||||
const requestedDistro = target.wslDistro?.trim() || undefined
|
||||
const info = await runWslProcess({
|
||||
distro: requestedDistro,
|
||||
loginPath: 'none',
|
||||
script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"',
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
if (info.code !== 0 || info.timedOut) {
|
||||
throw new Error('Could not resolve the active WSL home directory for Codex login.')
|
||||
}
|
||||
const [rawDistro, rawHome] = info.stdout
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
const distro = requestedDistro || rawDistro
|
||||
const home = rawHome
|
||||
if (!distro || !home?.startsWith('/')) {
|
||||
throw new Error('Could not resolve the active WSL home directory for Codex login.')
|
||||
}
|
||||
|
||||
const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home`
|
||||
const markerPath = `${linuxPath}/.orca-managed-home`
|
||||
const created = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: `mkdir -p ${quotePosixShell(linuxPath)} && printf '%s\\n' ${quotePosixShell(accountId)} > ${quotePosixShell(markerPath)}`,
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
if (created.code !== 0 || created.timedOut) {
|
||||
throw new Error('Could not create the managed Codex home inside WSL.')
|
||||
}
|
||||
|
||||
const managedHomePath = toWindowsWslPath(linuxPath, distro)
|
||||
try {
|
||||
return {
|
||||
managedHomePath: this.paths.assert(managedHomePath, accountId),
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: distro,
|
||||
wslLinuxHomePath: linuxPath
|
||||
}
|
||||
} catch (error) {
|
||||
await this.safeRemoveWslCandidate(distro, linuxPath, accountId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async safeRemoveWslCandidate(
|
||||
distro: string,
|
||||
linuxHomePath: string,
|
||||
expectedAccountId: string
|
||||
): Promise<void> {
|
||||
// Why: creation can fail after mkdir/marker but before trust, so cleanup must verify the marker/account ID inside WSL.
|
||||
try {
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${quotePosixShell(linuxHomePath)}`,
|
||||
`expected_marker=${quotePosixShell(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'),
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(`WSL cleanup exited with ${result.timedOut ? 'a timeout' : result.code}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
|
||||
|
||||
const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000
|
||||
|
||||
export class CodexManagedHomePath {
|
||||
constructor(private readonly validateWslPath: (distro: string, script: string) => string) {}
|
||||
|
||||
getRoot(): string {
|
||||
const root = join(app.getPath('userData'), 'codex-accounts')
|
||||
mkdirSync(root, { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
assertHostOwnership(candidatePath: string, expectedAccountId: string): string {
|
||||
return assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath,
|
||||
managedAccountsRoot: join(app.getPath('userData'), 'codex-accounts'),
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId
|
||||
})
|
||||
}
|
||||
|
||||
async ensureForReauthentication(account: CodexManagedAccount): Promise<string> {
|
||||
const wslInfo = parseWslUncPath(account.managedHomePath)
|
||||
if (wslInfo && process.platform === 'win32') {
|
||||
await this.ensureExpectedWslHome(account, wslInfo)
|
||||
return this.assert(account.managedHomePath, account.id)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.assert(account.managedHomePath, account.id)
|
||||
} catch (error) {
|
||||
if (!this.isMissingHomeError(error)) {
|
||||
throw error
|
||||
}
|
||||
return this.recreateExpectedHostHome(account, error)
|
||||
}
|
||||
}
|
||||
|
||||
assert(candidatePath: string, expectedAccountId?: string): string {
|
||||
const wslInfo = parseWslUncPath(candidatePath)
|
||||
if (!wslInfo) {
|
||||
return assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath,
|
||||
managedAccountsRoot: this.getRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId
|
||||
})
|
||||
}
|
||||
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 (
|
||||
expectedAccountId !== undefined &&
|
||||
!wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${expectedAccountId}/home`)
|
||||
) {
|
||||
throw new Error('Managed WSL Codex home does not match its persisted account ID.')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return this.assertWindowsWslPath(wslInfo, expectedAccountId)
|
||||
}
|
||||
return this.assertMountedWslPath(candidatePath, wslInfo.linuxPath, expectedAccountId)
|
||||
}
|
||||
|
||||
private recreateExpectedHostHome(account: CodexManagedAccount, originalError: unknown): string {
|
||||
const expectedPath = join(this.getRoot(), account.id, 'home')
|
||||
if (!this.pathsEqual(account.managedHomePath, expectedPath)) {
|
||||
throw originalError
|
||||
}
|
||||
// Why: re-auth may recreate a lost empty home, but only at the exact Orca-owned path persisted for this account.
|
||||
mkdirSync(expectedPath, { recursive: true })
|
||||
writeFileSync(join(expectedPath, '.orca-managed-home'), `${account.id}\n`, 'utf-8')
|
||||
return this.assert(expectedPath, account.id)
|
||||
}
|
||||
|
||||
private async ensureExpectedWslHome(
|
||||
account: CodexManagedAccount,
|
||||
wslInfo: { distro: string; linuxPath: string }
|
||||
): Promise<void> {
|
||||
if (
|
||||
account.managedHomeRuntime !== 'wsl' ||
|
||||
account.wslDistro !== wslInfo.distro ||
|
||||
account.wslLinuxHomePath !== wslInfo.linuxPath ||
|
||||
!wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${account.id}/home`)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const result = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'none',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${quotePosixShell(wslInfo.linuxPath)}`,
|
||||
`expected_marker=${quotePosixShell(account.id)}`,
|
||||
'marker="$candidate/.orca-managed-home"',
|
||||
'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi',
|
||||
'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi',
|
||||
'mkdir -p -- "$candidate"',
|
||||
'printf "%s\\n" "$expected_marker" > "$marker"'
|
||||
].join('\n'),
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
// Why: 41/42 mean the path is not this account's home; re-auth must refuse
|
||||
// rather than write credentials into someone else's directory.
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(
|
||||
`Could not prepare the managed Codex home in WSL ${wslInfo.distro} for re-authentication.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private assertWindowsWslPath(
|
||||
wslInfo: { distro: string; linuxPath: string },
|
||||
expectedAccountId?: string
|
||||
): string {
|
||||
try {
|
||||
const canonicalLinuxPath = this.validateWslPath(
|
||||
wslInfo.distro,
|
||||
[
|
||||
'set -euo pipefail',
|
||||
`candidate=${quotePosixShell(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"',
|
||||
...(expectedAccountId === undefined
|
||||
? [
|
||||
'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
]
|
||||
: [
|
||||
`expected_marker=${quotePosixShell(expectedAccountId)}`,
|
||||
'test "$candidate_real" = "$managed_root_real/$expected_marker/home"',
|
||||
'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"',
|
||||
'printf "%s\\n" "$candidate_real"'
|
||||
])
|
||||
].join('\n')
|
||||
).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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private assertMountedWslPath(
|
||||
candidatePath: string,
|
||||
linuxPath: string,
|
||||
expectedAccountId?: string
|
||||
): string {
|
||||
if (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.')
|
||||
}
|
||||
const markerPath = join(candidatePath, '.orca-managed-home')
|
||||
if (!existsSync(markerPath)) {
|
||||
throw new Error('Managed Codex home is missing Orca ownership marker.')
|
||||
}
|
||||
if (
|
||||
expectedAccountId !== undefined &&
|
||||
readFileSync(markerPath, 'utf-8').trim() !== expectedAccountId
|
||||
) {
|
||||
throw new Error('Managed WSL Codex home ownership marker does not match its account ID.')
|
||||
}
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
private isMissingHomeError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message === 'Managed Codex home directory does not exist on disk.'
|
||||
)
|
||||
}
|
||||
|
||||
private pathsEqual(left: string, right: string): boolean {
|
||||
const resolvedLeft = resolve(left)
|
||||
const resolvedRight = resolve(right)
|
||||
return process.platform === 'win32'
|
||||
? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
||||
: resolvedLeft === resolvedRight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
CodexManagedAccount,
|
||||
CodexManagedAccountSummary,
|
||||
CodexRateLimitAccountsState
|
||||
} from '../../shared/managed-account-types'
|
||||
import {
|
||||
buildCodexResetCreditExpectedScope,
|
||||
type CodexResetCreditExpectedScope
|
||||
} from '../../shared/codex-reset-credit-scope'
|
||||
import type {
|
||||
CodexRateLimitResetResult,
|
||||
RateLimitState,
|
||||
RateLimitRuntimeTarget
|
||||
} from '../../shared/rate-limit-types'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import { getSelectedCodexAccountIdForTarget } from './runtime-selection'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership'
|
||||
import type {
|
||||
CodexResetCreditConsumeResult,
|
||||
CodexResetCreditConsumedResult
|
||||
} from './codex-account-service-types'
|
||||
import type { CodexManagedHomePath } from './codex-managed-home-path'
|
||||
import {
|
||||
CodexResetCreditLedger,
|
||||
resetAccountScopeKey,
|
||||
resetScopeKey,
|
||||
type CodexResetCreditAttempt
|
||||
} from './codex-reset-credit-ledger'
|
||||
import {
|
||||
CodexResetCreditScopeRejection,
|
||||
validateCodexResetCreditScope,
|
||||
type CodexResetCreditScopeValidation
|
||||
} from './codex-reset-credit-scope-validation'
|
||||
|
||||
function sameTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean {
|
||||
return left.runtime === right.runtime && left.wslDistro === right.wslDistro
|
||||
}
|
||||
|
||||
type CodexResetCreditCoordinatorDependencies = {
|
||||
store: Store
|
||||
rateLimits: RateLimitService
|
||||
runtimeHome: CodexRuntimeHomeService
|
||||
managedHomePaths: CodexManagedHomePath
|
||||
serializeMutation: <T>(operation: () => Promise<T>) => Promise<T>
|
||||
getSnapshot: () => CodexRateLimitAccountsState
|
||||
toSummary: (account: CodexManagedAccount) => CodexManagedAccountSummary
|
||||
}
|
||||
|
||||
export class CodexResetCreditCoordinator {
|
||||
private readonly ledger: CodexResetCreditLedger
|
||||
|
||||
constructor(private readonly dependencies: CodexResetCreditCoordinatorDependencies) {
|
||||
this.ledger = new CodexResetCreditLedger(dependencies.store)
|
||||
}
|
||||
|
||||
consume(
|
||||
idempotencyKey: string,
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
): Promise<CodexResetCreditConsumeResult> {
|
||||
if (this.ledger.error) {
|
||||
return Promise.reject(this.ledger.error)
|
||||
}
|
||||
const scopeKey = resetScopeKey(expectedScope)
|
||||
const accountScopeKey = resetAccountScopeKey(expectedScope)
|
||||
const existing = this.ledger.get(idempotencyKey)
|
||||
if (existing) {
|
||||
if (existing.scopeKey !== scopeKey) {
|
||||
return Promise.reject(new Error('That idempotency key belongs to a different reset scope.'))
|
||||
}
|
||||
if (existing.state === 'settled' && existing.settledOutcome) {
|
||||
return this.dependencies.serializeMutation(async () => {
|
||||
const { rateLimits } = this.validateScope(expectedScope, { kind: 'settledReplay' })
|
||||
return {
|
||||
outcome: existing.settledOutcome!,
|
||||
scope: existing.expectedScope,
|
||||
codex: this.dependencies.getSnapshot(),
|
||||
rateLimits
|
||||
}
|
||||
})
|
||||
}
|
||||
if (existing.promise) {
|
||||
return existing.promise as Promise<CodexResetCreditConsumeResult>
|
||||
}
|
||||
return this.startAttempt(idempotencyKey, expectedScope, existing)
|
||||
}
|
||||
|
||||
const unresolvedKey = this.ledger.getUnresolvedKey(accountScopeKey)
|
||||
if (unresolvedKey && unresolvedKey !== idempotencyKey) {
|
||||
return Promise.reject(
|
||||
new Error('A previous reset attempt for this account still has an unknown outcome.')
|
||||
)
|
||||
}
|
||||
const claimedKey = this.ledger.getClaimedKey(scopeKey)
|
||||
if (claimedKey && claimedKey !== idempotencyKey) {
|
||||
return Promise.reject(new Error('That reset-credit offer was already attempted.'))
|
||||
}
|
||||
return this.startAttempt(
|
||||
idempotencyKey,
|
||||
expectedScope,
|
||||
this.ledger.createFresh(idempotencyKey, expectedScope)
|
||||
)
|
||||
}
|
||||
|
||||
async consumeCurrent(): Promise<CodexRateLimitResetResult> {
|
||||
if (this.ledger.error) {
|
||||
throw this.ledger.error
|
||||
}
|
||||
const initialRateLimits = this.dependencies.rateLimits.getState()
|
||||
const initialTarget = { ...initialRateLimits.codexTarget }
|
||||
const initialSettings = this.dependencies.store.getSettings()
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(initialSettings, initialTarget)
|
||||
if (selectedAccountId) {
|
||||
const account = initialSettings.codexManagedAccounts.find(
|
||||
(candidate) => candidate.id === selectedAccountId
|
||||
)
|
||||
const pendingAttempt = account
|
||||
? this.ledger.getPendingForAccount(initialTarget, account)
|
||||
: null
|
||||
const expectedScope =
|
||||
pendingAttempt?.expectedScope ??
|
||||
(account
|
||||
? buildCodexResetCreditExpectedScope({
|
||||
target: initialTarget,
|
||||
account: this.dependencies.toSummary(account),
|
||||
limits: initialRateLimits.codex
|
||||
})
|
||||
: null)
|
||||
if (!expectedScope) {
|
||||
throw new Error('The managed Codex reset-credit offer is no longer available.')
|
||||
}
|
||||
// Why: do not enter the mutation queue first; the coordinator owns that
|
||||
// queue and nested serialization would deadlock behind this operation.
|
||||
const result = await this.consume(
|
||||
pendingAttempt?.idempotencyKey ?? randomUUID(),
|
||||
expectedScope
|
||||
)
|
||||
if ('status' in result) {
|
||||
throw new Error('The Codex account or reset offer changed before reset.')
|
||||
}
|
||||
return { outcome: result.outcome, state: result.rateLimits }
|
||||
}
|
||||
|
||||
return this.dependencies.serializeMutation(async () => {
|
||||
if (this.ledger.error) {
|
||||
throw this.ledger.error
|
||||
}
|
||||
const target = this.dependencies.rateLimits.getState().codexTarget
|
||||
if (!sameTarget(target, initialTarget)) {
|
||||
throw new Error('The active Codex rate-limit target changed before reset.')
|
||||
}
|
||||
if (getSelectedCodexAccountIdForTarget(this.dependencies.store.getSettings(), target)) {
|
||||
throw new Error('The selected Codex account changed before reset.')
|
||||
}
|
||||
if (this.ledger.hasPendingForTarget(target)) {
|
||||
throw new Error('A previous reset attempt for this target still has an unknown outcome.')
|
||||
}
|
||||
const homeResolution = this.dependencies.runtimeHome.prepareForRateLimitFetch(target)
|
||||
// Why: reject before the provider mutation — a skip must never be spent
|
||||
// against the system-default home (#STA-4422).
|
||||
if (homeResolution.kind === 'skip') {
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError()
|
||||
}
|
||||
return this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey: randomUUID(),
|
||||
target,
|
||||
codexHomePath: homeResolution.codexHomePath
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
discardForRemovedAccount(accountId: string): void {
|
||||
this.ledger.discardForRemovedAccount(accountId)
|
||||
}
|
||||
|
||||
private startAttempt(
|
||||
idempotencyKey: string,
|
||||
expectedScope: CodexResetCreditExpectedScope,
|
||||
attempt: CodexResetCreditAttempt
|
||||
): Promise<CodexResetCreditConsumeResult> {
|
||||
const promise = this.dependencies.serializeMutation(
|
||||
async (): Promise<CodexResetCreditConsumeResult> => {
|
||||
const isFresh = attempt.state === 'fresh'
|
||||
let validation: { managedHomePath: string; rateLimits: RateLimitState }
|
||||
try {
|
||||
validation = this.validateScope(expectedScope, {
|
||||
kind: 'providerMutation',
|
||||
requireCurrentOffer: isFresh
|
||||
})
|
||||
} catch (error) {
|
||||
if (isFresh && error instanceof CodexResetCreditScopeRejection) {
|
||||
this.ledger.releaseFresh(idempotencyKey, attempt)
|
||||
return {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: error.reason,
|
||||
scope: expectedScope,
|
||||
codex: this.dependencies.getSnapshot(),
|
||||
rateLimits: error.rateLimits
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (isFresh) {
|
||||
this.ledger.markProviderPending(idempotencyKey, attempt)
|
||||
}
|
||||
const { outcome, state } =
|
||||
await this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey,
|
||||
target: expectedScope.target,
|
||||
codexHomePath: validation.managedHomePath
|
||||
})
|
||||
// Why: queued account selection may start as soon as this mutation resolves;
|
||||
// capture both account selection and usage before releasing the queue.
|
||||
const result: CodexResetCreditConsumedResult = {
|
||||
outcome,
|
||||
scope: expectedScope,
|
||||
codex: this.dependencies.getSnapshot(),
|
||||
rateLimits: state
|
||||
}
|
||||
this.ledger.markSettled(idempotencyKey, attempt, outcome)
|
||||
return result
|
||||
}
|
||||
)
|
||||
attempt.promise = promise
|
||||
void promise.then(
|
||||
() => {
|
||||
attempt.promise = null
|
||||
},
|
||||
() => {
|
||||
attempt.promise = null
|
||||
if (attempt.state === 'fresh') {
|
||||
this.ledger.releaseFresh(idempotencyKey, attempt)
|
||||
}
|
||||
}
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
private validateScope(
|
||||
expectedScope: CodexResetCreditExpectedScope,
|
||||
validation: CodexResetCreditScopeValidation
|
||||
): { managedHomePath: string; rateLimits: RateLimitState } {
|
||||
return validateCodexResetCreditScope(expectedScope, validation, this.dependencies)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import type {
|
||||
CodexResetCreditAttemptLedger,
|
||||
DurableCodexResetCreditAttempt
|
||||
} from '../../shared/codex-reset-credit-attempt-ledger'
|
||||
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
|
||||
import type {
|
||||
CodexRateLimitResetOutcome,
|
||||
RateLimitRuntimeTarget
|
||||
} from '../../shared/rate-limit-types'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
export type CodexResetCreditAttempt = {
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
scopeKey: string
|
||||
accountScopeKey: string
|
||||
state: 'fresh' | 'providerPending' | 'settled'
|
||||
promise: Promise<unknown> | null
|
||||
settledOutcome: CodexRateLimitResetOutcome | null
|
||||
}
|
||||
|
||||
export function resetScopeKey(scope: CodexResetCreditExpectedScope): string {
|
||||
return JSON.stringify([
|
||||
scope.target.runtime,
|
||||
scope.target.wslDistro,
|
||||
scope.accountId,
|
||||
scope.accountRevision,
|
||||
scope.offerRevision
|
||||
])
|
||||
}
|
||||
|
||||
export function resetAccountScopeKey(
|
||||
scope: Pick<CodexResetCreditExpectedScope, 'target' | 'accountId' | 'accountRevision'>
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
scope.target.runtime,
|
||||
scope.target.wslDistro,
|
||||
scope.accountId,
|
||||
scope.accountRevision
|
||||
])
|
||||
}
|
||||
|
||||
export class CodexResetCreditLedger {
|
||||
private readonly attemptsByKey = new Map<string, CodexResetCreditAttempt>()
|
||||
private readonly attemptKeyByOffer = new Map<string, string>()
|
||||
private readonly unresolvedKeyByAccountScope = new Map<string, string>()
|
||||
private durableLedger: CodexResetCreditAttemptLedger | null = null
|
||||
private loadError: Error | null = null
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.hydrate()
|
||||
}
|
||||
|
||||
get error(): Error | null {
|
||||
return this.loadError
|
||||
}
|
||||
|
||||
get(idempotencyKey: string): CodexResetCreditAttempt | undefined {
|
||||
return this.attemptsByKey.get(idempotencyKey)
|
||||
}
|
||||
|
||||
getUnresolvedKey(accountScopeKey: string): string | undefined {
|
||||
return this.unresolvedKeyByAccountScope.get(accountScopeKey)
|
||||
}
|
||||
|
||||
getClaimedKey(scopeKey: string): string | undefined {
|
||||
return this.attemptKeyByOffer.get(scopeKey)
|
||||
}
|
||||
|
||||
createFresh(
|
||||
idempotencyKey: string,
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
): CodexResetCreditAttempt {
|
||||
const attempt: CodexResetCreditAttempt = {
|
||||
expectedScope,
|
||||
scopeKey: resetScopeKey(expectedScope),
|
||||
accountScopeKey: resetAccountScopeKey(expectedScope),
|
||||
state: 'fresh',
|
||||
promise: null,
|
||||
settledOutcome: null
|
||||
}
|
||||
this.attemptsByKey.set(idempotencyKey, attempt)
|
||||
this.attemptKeyByOffer.set(attempt.scopeKey, idempotencyKey)
|
||||
return attempt
|
||||
}
|
||||
|
||||
getPendingForAccount(
|
||||
target: RateLimitRuntimeTarget,
|
||||
account: CodexManagedAccount
|
||||
): { idempotencyKey: string; expectedScope: CodexResetCreditExpectedScope } | null {
|
||||
const accountScopeKey = resetAccountScopeKey({
|
||||
target,
|
||||
accountId: account.id,
|
||||
accountRevision: account.updatedAt
|
||||
})
|
||||
const idempotencyKey = this.unresolvedKeyByAccountScope.get(accountScopeKey)
|
||||
if (!idempotencyKey) {
|
||||
return null
|
||||
}
|
||||
const attempt = this.attemptsByKey.get(idempotencyKey)
|
||||
if (attempt?.state !== 'providerPending') {
|
||||
throw new Error('Codex reset-credit attempt state is inconsistent.')
|
||||
}
|
||||
// Why: a durable providerPending attempt can only be resolved with its original key.
|
||||
return { idempotencyKey, expectedScope: attempt.expectedScope }
|
||||
}
|
||||
|
||||
hasPendingForTarget(target: RateLimitRuntimeTarget): boolean {
|
||||
return [...this.attemptsByKey.values()].some(
|
||||
(attempt) =>
|
||||
attempt.state === 'providerPending' &&
|
||||
attempt.expectedScope.target.runtime === target.runtime &&
|
||||
attempt.expectedScope.target.wslDistro === target.wslDistro
|
||||
)
|
||||
}
|
||||
|
||||
markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): void {
|
||||
this.persist({ idempotencyKey, expectedScope: attempt.expectedScope, state: 'providerPending' })
|
||||
attempt.state = 'providerPending'
|
||||
this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey)
|
||||
}
|
||||
|
||||
markSettled(
|
||||
idempotencyKey: string,
|
||||
attempt: CodexResetCreditAttempt,
|
||||
outcome: CodexRateLimitResetOutcome
|
||||
): void {
|
||||
this.persist({
|
||||
idempotencyKey,
|
||||
expectedScope: attempt.expectedScope,
|
||||
state: 'settled',
|
||||
outcome
|
||||
})
|
||||
attempt.state = 'settled'
|
||||
attempt.settledOutcome = outcome
|
||||
if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) {
|
||||
this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
releaseFresh(idempotencyKey: string, attempt: CodexResetCreditAttempt): void {
|
||||
if (attempt.state !== 'fresh') {
|
||||
return
|
||||
}
|
||||
this.attemptsByKey.delete(idempotencyKey)
|
||||
if (this.attemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) {
|
||||
this.attemptKeyByOffer.delete(attempt.scopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a removed account's managed home is gone, so its unresolved providerPending
|
||||
// attempt can never validate or be replayed; drop it so a target-scoped default reset
|
||||
// is not wedged forever by hasPendingResetForTarget matching the orphan.
|
||||
discardForRemovedAccount(accountId: string): void {
|
||||
const staleAttempts = [...this.attemptsByKey].filter(
|
||||
([, attempt]) => attempt.expectedScope.accountId === accountId
|
||||
)
|
||||
if (staleAttempts.length === 0) {
|
||||
return
|
||||
}
|
||||
const staleKeySet = new Set(staleAttempts.map(([idempotencyKey]) => idempotencyKey))
|
||||
if (this.durableLedger) {
|
||||
const attempts = this.durableLedger.attempts.filter(
|
||||
(attempt) => !staleKeySet.has(attempt.idempotencyKey)
|
||||
)
|
||||
if (attempts.length !== this.durableLedger.attempts.length) {
|
||||
const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts }
|
||||
// Persist first so a failed durability barrier leaves the in-memory
|
||||
// fail-closed guards aligned with the ledger that will reload.
|
||||
this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
|
||||
this.durableLedger = structuredClone(nextLedger)
|
||||
}
|
||||
}
|
||||
for (const [idempotencyKey, attempt] of staleAttempts) {
|
||||
this.attemptsByKey.delete(idempotencyKey)
|
||||
if (this.attemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) {
|
||||
this.attemptKeyByOffer.delete(attempt.scopeKey)
|
||||
}
|
||||
if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) {
|
||||
this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hydrate(): void {
|
||||
try {
|
||||
const ledger = this.store.getCodexResetCreditAttemptLedger()
|
||||
this.durableLedger = ledger
|
||||
for (const durable of ledger.attempts) {
|
||||
const attempt: CodexResetCreditAttempt = {
|
||||
expectedScope: durable.expectedScope,
|
||||
scopeKey: resetScopeKey(durable.expectedScope),
|
||||
accountScopeKey: resetAccountScopeKey(durable.expectedScope),
|
||||
state: durable.state,
|
||||
promise: null,
|
||||
settledOutcome: durable.state === 'settled' ? durable.outcome : null
|
||||
}
|
||||
this.attemptsByKey.set(durable.idempotencyKey, attempt)
|
||||
this.attemptKeyByOffer.set(attempt.scopeKey, durable.idempotencyKey)
|
||||
if (durable.state === 'providerPending') {
|
||||
this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, durable.idempotencyKey)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.loadError =
|
||||
error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt')
|
||||
}
|
||||
}
|
||||
|
||||
private persist(nextAttempt: DurableCodexResetCreditAttempt): void {
|
||||
if (!this.durableLedger) {
|
||||
throw this.loadError ?? new Error('Codex reset-credit attempt ledger is unavailable')
|
||||
}
|
||||
const index = this.durableLedger.attempts.findIndex(
|
||||
(attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey
|
||||
)
|
||||
const attempts = [...this.durableLedger.attempts]
|
||||
if (index === -1) {
|
||||
attempts.push(nextAttempt)
|
||||
} else {
|
||||
attempts[index] = nextAttempt
|
||||
}
|
||||
const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts }
|
||||
this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
|
||||
this.durableLedger = structuredClone(nextLedger)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
CodexManagedAccount,
|
||||
CodexManagedAccountSummary
|
||||
} from '../../shared/managed-account-types'
|
||||
import {
|
||||
buildCodexResetCreditExpectedScope,
|
||||
type CodexResetCreditExpectedScope
|
||||
} from '../../shared/codex-reset-credit-scope'
|
||||
import type { RateLimitState, RateLimitRuntimeTarget } from '../../shared/rate-limit-types'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { CodexResetCreditRejectedBeforeProviderReason } from './codex-account-service-types'
|
||||
import type { CodexManagedHomePath } from './codex-managed-home-path'
|
||||
import { resetScopeKey } from './codex-reset-credit-ledger'
|
||||
import {
|
||||
getCodexSelectionTargetForAccount,
|
||||
getSelectedCodexAccountIdForTarget,
|
||||
normalizeCodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
|
||||
export type CodexResetCreditScopeValidation =
|
||||
| { kind: 'settledReplay' }
|
||||
| { kind: 'providerMutation'; requireCurrentOffer: boolean }
|
||||
|
||||
export class CodexResetCreditScopeRejection extends Error {
|
||||
constructor(
|
||||
readonly reason: CodexResetCreditRejectedBeforeProviderReason,
|
||||
readonly rateLimits: RateLimitState,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'CodexResetCreditScopeRejection'
|
||||
}
|
||||
}
|
||||
|
||||
type CodexResetCreditScopeDependencies = {
|
||||
store: Store
|
||||
rateLimits: RateLimitService
|
||||
managedHomePaths: CodexManagedHomePath
|
||||
toSummary: (account: CodexManagedAccount) => CodexManagedAccountSummary
|
||||
}
|
||||
|
||||
function sameTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean {
|
||||
return left.runtime === right.runtime && left.wslDistro === right.wslDistro
|
||||
}
|
||||
|
||||
export function validateCodexResetCreditScope(
|
||||
expectedScope: CodexResetCreditExpectedScope,
|
||||
validation: CodexResetCreditScopeValidation,
|
||||
dependencies: CodexResetCreditScopeDependencies
|
||||
): { managedHomePath: string; rateLimits: RateLimitState } {
|
||||
const rateLimitState = dependencies.rateLimits.getState()
|
||||
if (!sameTarget(rateLimitState.codexTarget, expectedScope.target)) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'targetChanged',
|
||||
rateLimitState,
|
||||
'The active Codex rate-limit target changed before reset.'
|
||||
)
|
||||
}
|
||||
const settings = dependencies.store.getSettings()
|
||||
if (
|
||||
getSelectedCodexAccountIdForTarget(settings, expectedScope.target) !== expectedScope.accountId
|
||||
) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'accountChanged',
|
||||
rateLimitState,
|
||||
'The selected Codex account changed before reset.'
|
||||
)
|
||||
}
|
||||
const account = settings.codexManagedAccounts.find(
|
||||
(candidate) => candidate.id === expectedScope.accountId
|
||||
)
|
||||
if (!account || account.updatedAt !== expectedScope.accountRevision) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'accountRevisionChanged',
|
||||
rateLimitState,
|
||||
'The selected Codex account was updated before reset.'
|
||||
)
|
||||
}
|
||||
const accountTarget = normalizeCodexAccountSelectionTarget(
|
||||
getCodexSelectionTargetForAccount(account)
|
||||
)
|
||||
if (!sameTarget(accountTarget, expectedScope.target)) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'accountRuntimeChanged',
|
||||
rateLimitState,
|
||||
'The selected Codex account belongs to a different runtime.'
|
||||
)
|
||||
}
|
||||
if (validation.kind === 'providerMutation' && validation.requireCurrentOffer) {
|
||||
const currentScope = buildCodexResetCreditExpectedScope({
|
||||
target: rateLimitState.codexTarget,
|
||||
account: dependencies.toSummary(account),
|
||||
limits: rateLimitState.codex
|
||||
})
|
||||
if (!currentScope) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'offerUnavailable',
|
||||
rateLimitState,
|
||||
'The Codex reset-credit offer is no longer available.'
|
||||
)
|
||||
}
|
||||
if (resetScopeKey(expectedScope) !== resetScopeKey(currentScope)) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'offerChanged',
|
||||
rateLimitState,
|
||||
'The Codex reset-credit offer changed before reset.'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (validation.kind === 'providerMutation' && expectedScope.target.runtime === 'host') {
|
||||
dependencies.managedHomePaths.assertHostOwnership(account.managedHomePath, account.id)
|
||||
}
|
||||
return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState }
|
||||
}
|
||||
+113
-1864
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user