@bitkyc08/opencodex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/codex/auth-api.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
1
|
+
import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
2
2
|
import { withCodexAccountLogLabel } from "./account-label";
|
|
3
3
|
import {
|
|
4
4
|
getCodexAccountCredential,
|
|
5
5
|
getValidCodexToken,
|
|
6
6
|
isCodexAccountGenerationLive,
|
|
7
7
|
markCodexAccountValidated,
|
|
8
|
+
readCodexAccountRecord,
|
|
8
9
|
saveCodexAccountCredential,
|
|
9
10
|
CodexCredentialGenerationConflictError,
|
|
10
11
|
CodexCredentialRefreshLockTimeoutError,
|
|
12
|
+
CodexCredentialRefreshBusyError,
|
|
13
|
+
CodexCredentialRefreshStaleError,
|
|
11
14
|
TokenRefreshError,
|
|
12
15
|
} from "./account-store";
|
|
13
16
|
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
@@ -50,6 +53,8 @@ export {
|
|
|
50
53
|
} from "./quota";
|
|
51
54
|
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
|
|
52
55
|
import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
56
|
+
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
57
|
+
import { reconcileLiveStateStores } from "../lib/state-store-registrations";
|
|
53
58
|
import {
|
|
54
59
|
clearMainAccountInfoCache,
|
|
55
60
|
getMainAccountInfoCache,
|
|
@@ -77,6 +82,7 @@ import {
|
|
|
77
82
|
isValidCodexAccountId,
|
|
78
83
|
} from "./account-id";
|
|
79
84
|
import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
|
|
85
|
+
import { ResourceAdmissionError } from "../lib/admission";
|
|
80
86
|
|
|
81
87
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
82
88
|
return new Response(JSON.stringify(data), {
|
|
@@ -87,7 +93,30 @@ function jsonResponse(data: unknown, status = 200): Response {
|
|
|
87
93
|
|
|
88
94
|
const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
|
|
89
95
|
|
|
90
|
-
const
|
|
96
|
+
const MAX_CODEX_LOGIN_STATE_ROWS = 32;
|
|
97
|
+
const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
|
|
98
|
+
interface CodexLoginStateRow { status: string; startedAt: number; accountId?: string; email?: string; error?: string; doneAt?: number }
|
|
99
|
+
const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
|
|
100
|
+
export class CodexLoginStateBusyError extends ResourceAdmissionError {
|
|
101
|
+
constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
|
|
105
|
+
const row = codexAuthLoginState.get(flowId);
|
|
106
|
+
if (row) Object.assign(row, patch);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pruneCodexLoginState(now = Date.now()): void {
|
|
110
|
+
for (const [id, row] of codexAuthLoginState) {
|
|
111
|
+
if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
|
|
112
|
+
}
|
|
113
|
+
while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
114
|
+
const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
|
|
115
|
+
.sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
|
|
116
|
+
if (!terminal) break;
|
|
117
|
+
codexAuthLoginState.delete(terminal[0]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
91
120
|
|
|
92
121
|
function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
|
|
93
122
|
if (!isValidCodexAccountId(accountId)) return null;
|
|
@@ -151,6 +180,7 @@ function poolAccountDto(
|
|
|
151
180
|
quota: quota ? { ...quota } : null,
|
|
152
181
|
needsReauth,
|
|
153
182
|
hasCredential,
|
|
183
|
+
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
|
|
154
184
|
...oauthAccountHealthFields("codex", account.id, health),
|
|
155
185
|
};
|
|
156
186
|
}
|
|
@@ -244,8 +274,15 @@ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"):
|
|
|
244
274
|
? [flowId]
|
|
245
275
|
: [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
|
|
246
276
|
for (const id of ids) {
|
|
247
|
-
|
|
248
|
-
|
|
277
|
+
let owner = codexAuthLoginState.get(id);
|
|
278
|
+
if (!owner) {
|
|
279
|
+
pruneCodexLoginState();
|
|
280
|
+
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
|
|
281
|
+
owner = { status: "error", startedAt: Date.now() };
|
|
282
|
+
codexAuthLoginState.set(id, owner);
|
|
283
|
+
}
|
|
284
|
+
Object.assign(owner, { status: "error", error, doneAt: Date.now() });
|
|
285
|
+
setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
|
|
249
286
|
}
|
|
250
287
|
}
|
|
251
288
|
|
|
@@ -346,6 +383,7 @@ async function retryMainAccountInfoIfIdentityChanged(
|
|
|
346
383
|
}
|
|
347
384
|
|
|
348
385
|
async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
|
|
386
|
+
const writerGeneration = captureConfigGeneration();
|
|
349
387
|
reconcileMainCodexAccountRuntimeState();
|
|
350
388
|
const tokenRead = readCodexTokensResult();
|
|
351
389
|
if (tokenRead.status !== "ok") {
|
|
@@ -375,7 +413,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
375
413
|
if (retried) return retried;
|
|
376
414
|
if (terminalAuthFailure) {
|
|
377
415
|
clearMainAccountInfoCache();
|
|
378
|
-
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
416
|
+
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
|
|
379
417
|
}
|
|
380
418
|
return { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
381
419
|
}
|
|
@@ -397,7 +435,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
397
435
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
398
436
|
setMainAccountPlan(result.plan);
|
|
399
437
|
if (result.quota) {
|
|
400
|
-
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
|
|
438
|
+
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
|
|
401
439
|
}
|
|
402
440
|
return {
|
|
403
441
|
info: result,
|
|
@@ -413,6 +451,8 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
413
451
|
interface PoolQuotaResult {
|
|
414
452
|
quota: StoredAccountQuota | null;
|
|
415
453
|
needsReauth: boolean;
|
|
454
|
+
/** Credential generation whose cache or network result this DTO state belongs to. */
|
|
455
|
+
credentialGeneration?: number;
|
|
416
456
|
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
417
457
|
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
418
458
|
/** Present only when this call's WHAM response included a non-empty `plan_type`. */
|
|
@@ -421,6 +461,49 @@ interface PoolQuotaResult {
|
|
|
421
461
|
freshCredentialGeneration?: number;
|
|
422
462
|
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
423
463
|
freshResetCredits?: number;
|
|
464
|
+
quotaProbeSkipped?: true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
interface PoolQuotaRefreshFlight {
|
|
468
|
+
state: {
|
|
469
|
+
startCredentialGeneration?: number;
|
|
470
|
+
resolvedCredentialGeneration?: number;
|
|
471
|
+
};
|
|
472
|
+
promise: Promise<PoolQuotaResult>;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
|
|
476
|
+
const MAX_POOL_QUOTA_FLIGHTS = 16;
|
|
477
|
+
|
|
478
|
+
export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
|
|
479
|
+
constructor() {
|
|
480
|
+
super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
|
|
481
|
+
this.name = "PoolQuotaProbeBusyError";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function poolQuotaFlightCount(): number {
|
|
486
|
+
let count = 0;
|
|
487
|
+
for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
|
|
488
|
+
return count;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
|
|
492
|
+
export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
|
|
493
|
+
const prefix = `admission-test-${crypto.randomUUID()}`;
|
|
494
|
+
for (let index = 0; index < (options.loginFlows ?? 0); index++) {
|
|
495
|
+
codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
|
|
496
|
+
}
|
|
497
|
+
for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
|
|
498
|
+
poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
|
|
499
|
+
state: {},
|
|
500
|
+
promise: new Promise<PoolQuotaResult>(() => {}),
|
|
501
|
+
}]));
|
|
502
|
+
}
|
|
503
|
+
return () => {
|
|
504
|
+
for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
|
|
505
|
+
for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
|
|
506
|
+
};
|
|
424
507
|
}
|
|
425
508
|
|
|
426
509
|
export interface CodexAuthAccountDto {
|
|
@@ -438,41 +521,170 @@ export interface CodexAuthAccountDto {
|
|
|
438
521
|
healthLabel: OAuthHealthLabel;
|
|
439
522
|
healthSummary: string;
|
|
440
523
|
healthAction?: string;
|
|
524
|
+
quotaProbeSkipped?: true;
|
|
441
525
|
}
|
|
442
526
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
527
|
+
interface FreshPoolPlanUpdate {
|
|
528
|
+
accountId: string;
|
|
529
|
+
plan: string;
|
|
530
|
+
credentialGeneration: number;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
|
|
535
|
+
* the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
|
|
536
|
+
* WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
|
|
537
|
+
* recreate a deleted config from the server's older in-memory snapshot.
|
|
538
|
+
*/
|
|
539
|
+
function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
|
|
540
|
+
if (updates.length === 0) return;
|
|
541
|
+
let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
|
|
542
|
+
try {
|
|
543
|
+
outcome = mutatePersistedConfig(persistedConfig => {
|
|
544
|
+
const accepted: FreshPoolPlanUpdate[] = [];
|
|
545
|
+
let changed = false;
|
|
546
|
+
for (const update of updates) {
|
|
547
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
548
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
549
|
+
const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
|
|
550
|
+
if (!liveAccount || !persistedAccount) continue;
|
|
551
|
+
accepted.push(update);
|
|
552
|
+
if (persistedAccount.plan !== update.plan) {
|
|
553
|
+
persistedAccount.plan = update.plan;
|
|
554
|
+
changed = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return { changed, value: accepted };
|
|
558
|
+
});
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// Plan persistence is derived metadata on a read route. Contention must fail closed without
|
|
561
|
+
// turning account listing into a 500; a later refresh can retry against the latest files.
|
|
562
|
+
if (error instanceof ConfigMutationLockError) return;
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
565
|
+
if (outcome.status === "unavailable") return;
|
|
566
|
+
for (const update of outcome.value) {
|
|
567
|
+
// A replacement immediately after the durable commit is allowed to supersede the result, but
|
|
568
|
+
// the long-lived object must never be updated from that stale generation.
|
|
569
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
570
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
571
|
+
if (liveAccount) {
|
|
572
|
+
liveAccount.plan = update.plan;
|
|
573
|
+
}
|
|
447
574
|
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function fetchFreshPoolAccountQuota(
|
|
578
|
+
accountId: string,
|
|
579
|
+
existing: StoredAccountQuota | null,
|
|
580
|
+
configuredPlan?: string,
|
|
581
|
+
onCredentialGeneration?: (generation: number) => void,
|
|
582
|
+
): Promise<PoolQuotaResult> {
|
|
583
|
+
const writerGeneration = captureConfigGeneration();
|
|
584
|
+
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
|
|
448
585
|
try {
|
|
449
586
|
const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
|
|
587
|
+
requestCredentialGeneration = generation;
|
|
588
|
+
onCredentialGeneration?.(generation);
|
|
450
589
|
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
451
590
|
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
452
591
|
signal: AbortSignal.timeout(8000),
|
|
453
592
|
});
|
|
454
|
-
if (!resp.ok)
|
|
593
|
+
if (!resp.ok) {
|
|
594
|
+
return {
|
|
595
|
+
quota: existing ?? null,
|
|
596
|
+
needsReauth: resp.status === 401,
|
|
597
|
+
credentialGeneration: generation,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
455
600
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
456
601
|
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
457
602
|
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
|
|
458
603
|
const freshResetCredits = quota?.resetCredits;
|
|
459
|
-
if (!quota)
|
|
604
|
+
if (!quota) {
|
|
605
|
+
return {
|
|
606
|
+
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
|
|
607
|
+
needsReauth: false,
|
|
608
|
+
credentialGeneration: generation,
|
|
609
|
+
...(freshPlan !== undefined
|
|
610
|
+
? { freshPlan, freshCredentialGeneration: generation }
|
|
611
|
+
: {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
460
614
|
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
461
|
-
return { quota:
|
|
615
|
+
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
462
616
|
}
|
|
463
|
-
setAccountQuotaFromParsed(accountId, quota);
|
|
617
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration);
|
|
464
618
|
return {
|
|
465
619
|
quota: getAccountQuota(accountId),
|
|
466
620
|
needsReauth: false,
|
|
621
|
+
credentialGeneration: generation,
|
|
467
622
|
freshQuota: quota,
|
|
468
623
|
freshCredentialGeneration: generation,
|
|
469
624
|
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
470
625
|
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
471
626
|
};
|
|
472
627
|
} catch (e) {
|
|
473
|
-
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
474
|
-
|
|
475
|
-
|
|
628
|
+
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
629
|
+
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
630
|
+
return {
|
|
631
|
+
quota: existing ?? null,
|
|
632
|
+
needsReauth: false,
|
|
633
|
+
credentialGeneration: requestCredentialGeneration,
|
|
634
|
+
...(e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError
|
|
635
|
+
? { quotaProbeSkipped: true as const }
|
|
636
|
+
: {}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (e instanceof TokenRefreshError) {
|
|
640
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
|
|
641
|
+
}
|
|
642
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
|
|
647
|
+
const existing = getAccountQuota(accountId);
|
|
648
|
+
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
649
|
+
return {
|
|
650
|
+
quota: existing,
|
|
651
|
+
needsReauth: false,
|
|
652
|
+
credentialGeneration: readCodexAccountRecord(accountId)?.generation,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// A token refresh may increment the generation (and rotate the refresh token) before WHAM
|
|
656
|
+
// completes. Join a flight whose starting or resolved generation is still current, but let a
|
|
657
|
+
// replacement credential with the same pool id start its own request.
|
|
658
|
+
const record = readCodexAccountRecord(accountId);
|
|
659
|
+
const flights = poolQuotaRefreshInFlight.get(accountId);
|
|
660
|
+
const current = flights && [...flights].find(flight => {
|
|
661
|
+
const generation = flight.state.resolvedCredentialGeneration
|
|
662
|
+
?? flight.state.startCredentialGeneration;
|
|
663
|
+
return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
|
|
664
|
+
});
|
|
665
|
+
if (current) return current.promise;
|
|
666
|
+
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
|
|
667
|
+
|
|
668
|
+
const state: PoolQuotaRefreshFlight["state"] = {
|
|
669
|
+
startCredentialGeneration: record?.generation,
|
|
670
|
+
};
|
|
671
|
+
const refresh = fetchFreshPoolAccountQuota(
|
|
672
|
+
accountId,
|
|
673
|
+
existing,
|
|
674
|
+
configuredPlan,
|
|
675
|
+
generation => { state.resolvedCredentialGeneration = generation; },
|
|
676
|
+
);
|
|
677
|
+
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
678
|
+
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
679
|
+
activeFlights.add(flight);
|
|
680
|
+
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
|
|
681
|
+
try {
|
|
682
|
+
return await refresh;
|
|
683
|
+
} finally {
|
|
684
|
+
activeFlights.delete(flight);
|
|
685
|
+
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
|
|
686
|
+
poolQuotaRefreshInFlight.delete(accountId);
|
|
687
|
+
}
|
|
476
688
|
}
|
|
477
689
|
}
|
|
478
690
|
|
|
@@ -542,12 +754,67 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
|
|
|
542
754
|
const runtimeConfig = getRuntimeConfig(config);
|
|
543
755
|
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
544
756
|
const mainInfo = await fetchMainAccountInfo(forceRefresh);
|
|
545
|
-
const
|
|
546
|
-
const cred = getCodexAccountCredential(
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
757
|
+
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
758
|
+
const cred = getCodexAccountCredential(account.id);
|
|
759
|
+
let quotaResult: PoolQuotaResult;
|
|
760
|
+
if (!cred) {
|
|
761
|
+
quotaResult = { quota: null, needsReauth: true };
|
|
762
|
+
} else {
|
|
763
|
+
try {
|
|
764
|
+
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
|
|
767
|
+
quotaResult = {
|
|
768
|
+
quota: getAccountQuota(account.id),
|
|
769
|
+
needsReauth: false,
|
|
770
|
+
credentialGeneration: readCodexAccountRecord(account.id)?.generation,
|
|
771
|
+
quotaProbeSkipped: true,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return { accountId: account.id, quotaResult };
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
|
|
779
|
+
// changes after every parallel read settles, then apply one narrow disk patch for the batch.
|
|
780
|
+
const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
|
|
781
|
+
const plan = quotaResult.freshPlan;
|
|
782
|
+
const credentialGeneration = quotaResult.freshCredentialGeneration;
|
|
783
|
+
return plan && credentialGeneration !== undefined
|
|
784
|
+
? [{ accountId, plan, credentialGeneration }]
|
|
785
|
+
: [];
|
|
786
|
+
});
|
|
787
|
+
reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
|
|
788
|
+
|
|
789
|
+
const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
|
|
790
|
+
const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
|
|
791
|
+
if (!currentAccount) return [];
|
|
792
|
+
const currentCredential = getCodexAccountCredential(accountId);
|
|
793
|
+
if (!currentCredential) {
|
|
794
|
+
return [poolAccountDto(
|
|
795
|
+
currentAccount,
|
|
796
|
+
{ quota: null, needsReauth: true },
|
|
797
|
+
false,
|
|
798
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
799
|
+
)];
|
|
800
|
+
}
|
|
801
|
+
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
802
|
+
const generationLive = resultGeneration === undefined
|
|
803
|
+
|| isCodexAccountGenerationLive(accountId, resultGeneration);
|
|
804
|
+
const effectiveQuotaResult = !generationLive
|
|
805
|
+
? { quota: null, needsReauth: false }
|
|
806
|
+
: quotaResult;
|
|
807
|
+
// Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
|
|
808
|
+
// missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
|
|
809
|
+
const dtoAccount = generationLive && quotaResult.freshPlan
|
|
810
|
+
? { ...currentAccount, plan: quotaResult.freshPlan }
|
|
811
|
+
: currentAccount;
|
|
812
|
+
return [poolAccountDto(
|
|
813
|
+
dtoAccount,
|
|
814
|
+
effectiveQuotaResult,
|
|
815
|
+
true,
|
|
816
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
817
|
+
)];
|
|
551
818
|
});
|
|
552
819
|
const hasMainCredential = readCodexTokens() !== null;
|
|
553
820
|
const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
@@ -686,6 +953,7 @@ export async function handleCodexAuthAPI(
|
|
|
686
953
|
accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
|
|
687
954
|
latestConfig.codexAccounts = accounts;
|
|
688
955
|
saveRuntimeConfig(config, latestConfig);
|
|
956
|
+
reconcileLiveStateStores();
|
|
689
957
|
return jsonResponse({ ok: true });
|
|
690
958
|
}
|
|
691
959
|
|
|
@@ -700,6 +968,7 @@ export async function handleCodexAuthAPI(
|
|
|
700
968
|
}
|
|
701
969
|
deleteCodexAccount(runtimeConfig, id);
|
|
702
970
|
saveRuntimeConfig(config, runtimeConfig);
|
|
971
|
+
reconcileLiveStateStores();
|
|
703
972
|
return jsonResponse({ ok: true });
|
|
704
973
|
}
|
|
705
974
|
|
|
@@ -970,6 +1239,11 @@ export async function handleCodexAuthAPI(
|
|
|
970
1239
|
}
|
|
971
1240
|
return jsonResponse(result);
|
|
972
1241
|
} catch (e) {
|
|
1242
|
+
if (e instanceof PoolQuotaProbeBusyError) {
|
|
1243
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
1244
|
+
response.headers.set("Retry-After", "1");
|
|
1245
|
+
return response;
|
|
1246
|
+
}
|
|
973
1247
|
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
|
|
974
1248
|
}
|
|
975
1249
|
}
|
|
@@ -993,7 +1267,16 @@ export async function handleCodexAuthAPI(
|
|
|
993
1267
|
return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
|
|
994
1268
|
}
|
|
995
1269
|
}
|
|
1270
|
+
pruneCodexLoginState();
|
|
1271
|
+
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
1272
|
+
const busy = new CodexLoginStateBusyError();
|
|
1273
|
+
const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
|
|
1274
|
+
response.headers.set("Retry-After", "1");
|
|
1275
|
+
return response;
|
|
1276
|
+
}
|
|
996
1277
|
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1278
|
+
const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
|
|
1279
|
+
codexAuthLoginState.set(flowId, loginOwner);
|
|
997
1280
|
try {
|
|
998
1281
|
const { startLoginFlow, getLoginStatus } = await import("../oauth");
|
|
999
1282
|
const result = await startLoginFlow("chatgpt", { forceLogin: true });
|
|
@@ -1006,178 +1289,203 @@ export async function handleCodexAuthAPI(
|
|
|
1006
1289
|
}
|
|
1007
1290
|
|
|
1008
1291
|
(async () => {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
let email = cred.email || accountId;
|
|
1029
|
-
let plan: string | undefined;
|
|
1030
|
-
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
1031
|
-
try {
|
|
1032
|
-
const tokens = { access_token: cred.access, account_id: oauthAccountId };
|
|
1033
|
-
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1034
|
-
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
1035
|
-
signal: AbortSignal.timeout(8000),
|
|
1036
|
-
});
|
|
1037
|
-
if (resp.ok) {
|
|
1038
|
-
const data = (await resp.json()) as WhamUsageResponse;
|
|
1039
|
-
email = data.email ?? email;
|
|
1040
|
-
plan = data.plan_type ?? undefined;
|
|
1041
|
-
quota = parseUsageQuota(data);
|
|
1292
|
+
try {
|
|
1293
|
+
let completed = false;
|
|
1294
|
+
for (let i = 0; i < 150; i++) {
|
|
1295
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1296
|
+
const st = getLoginStatus("chatgpt");
|
|
1297
|
+
if (st.done && st.loggedIn) {
|
|
1298
|
+
const { getCredential } = await import("../oauth/store");
|
|
1299
|
+
const cred = getCredential("chatgpt");
|
|
1300
|
+
if (cred) {
|
|
1301
|
+
const oauthAccountId = cred.accountId;
|
|
1302
|
+
if (!oauthAccountId) {
|
|
1303
|
+
setCodexLoginState(flowId, {
|
|
1304
|
+
status: "error",
|
|
1305
|
+
error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
|
|
1306
|
+
doneAt: Date.now(),
|
|
1307
|
+
});
|
|
1308
|
+
completed = true;
|
|
1309
|
+
break;
|
|
1042
1310
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
if (
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
});
|
|
1059
|
-
completed = true;
|
|
1060
|
-
break;
|
|
1311
|
+
|
|
1312
|
+
let email = cred.email || accountId;
|
|
1313
|
+
let plan: string | undefined;
|
|
1314
|
+
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
1315
|
+
try {
|
|
1316
|
+
const tokens = { access_token: cred.access, account_id: oauthAccountId };
|
|
1317
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1318
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
1319
|
+
signal: AbortSignal.timeout(8000),
|
|
1320
|
+
});
|
|
1321
|
+
if (resp.ok) {
|
|
1322
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
1323
|
+
email = data.email ?? email;
|
|
1324
|
+
plan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
1325
|
+
quota = parseUsageQuota(data);
|
|
1061
1326
|
}
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1327
|
+
} catch { /* wham fetch is non-blocking */ }
|
|
1328
|
+
// Reauth must refresh the same ChatGPT identity already bound to this pool slot.
|
|
1329
|
+
// Otherwise a different login would silently overwrite credentials under a trusted id.
|
|
1330
|
+
if (reauth) {
|
|
1331
|
+
const existingCred = getCodexAccountCredential(accountId);
|
|
1332
|
+
const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
|
|
1333
|
+
const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
|
|
1334
|
+
const expectedEmail = poolAccount?.email?.trim().toLowerCase();
|
|
1335
|
+
const gotEmail = email.trim().toLowerCase();
|
|
1336
|
+
if (expectedChatgptId) {
|
|
1337
|
+
if (expectedChatgptId !== oauthAccountId) {
|
|
1338
|
+
setCodexLoginState(flowId, {
|
|
1339
|
+
status: "error",
|
|
1340
|
+
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
1341
|
+
doneAt: Date.now(),
|
|
1342
|
+
});
|
|
1343
|
+
completed = true;
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
1346
|
+
} else if (expectedEmail) {
|
|
1347
|
+
if (!gotEmail || gotEmail !== expectedEmail) {
|
|
1348
|
+
setCodexLoginState(flowId, {
|
|
1349
|
+
status: "error",
|
|
1350
|
+
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
1351
|
+
doneAt: Date.now(),
|
|
1352
|
+
});
|
|
1353
|
+
completed = true;
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
} else {
|
|
1357
|
+
// No chatgptAccountId and no pool email — refuse silent identity replacement
|
|
1358
|
+
// (including empty credential slots that still have a pool row).
|
|
1359
|
+
setCodexLoginState(flowId, {
|
|
1065
1360
|
status: "error",
|
|
1066
|
-
error: "
|
|
1361
|
+
error: "Cannot verify account identity for reauth. Remove this account and add it again.",
|
|
1067
1362
|
doneAt: Date.now(),
|
|
1068
1363
|
});
|
|
1069
1364
|
completed = true;
|
|
1070
1365
|
break;
|
|
1071
1366
|
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
1370
|
+
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
1371
|
+
if (collision.collision) {
|
|
1372
|
+
setCodexLoginState(flowId, {
|
|
1373
|
+
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
1374
|
+
});
|
|
1375
|
+
completed = true;
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
1380
|
+
if (!warmup.ok) {
|
|
1381
|
+
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
1382
|
+
setCodexLoginState(flowId, {
|
|
1076
1383
|
status: "error",
|
|
1077
|
-
error: "
|
|
1384
|
+
error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
|
|
1078
1385
|
doneAt: Date.now(),
|
|
1079
1386
|
});
|
|
1080
1387
|
completed = true;
|
|
1081
1388
|
break;
|
|
1082
1389
|
}
|
|
1083
|
-
}
|
|
1084
1390
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1391
|
+
const latestConfig = getRuntimeConfig(config);
|
|
1392
|
+
const accounts = latestConfig.codexAccounts ?? [];
|
|
1393
|
+
const existingIdx = accounts.findIndex(account => account.id === accountId);
|
|
1394
|
+
const commitConflict = codexAccountPersistenceConflict(
|
|
1395
|
+
latestConfig,
|
|
1396
|
+
accountId,
|
|
1397
|
+
reauth ? "reauth" : "create",
|
|
1398
|
+
);
|
|
1399
|
+
if (commitConflict) {
|
|
1400
|
+
setCodexLoginState(flowId, {
|
|
1401
|
+
status: "error",
|
|
1402
|
+
error: commitConflict,
|
|
1403
|
+
doneAt: Date.now(),
|
|
1404
|
+
});
|
|
1405
|
+
completed = true;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1094
1408
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
|
|
1101
|
-
doneAt: Date.now(),
|
|
1409
|
+
saveCodexAccountCredential(accountId, {
|
|
1410
|
+
accessToken: cred.access,
|
|
1411
|
+
refreshToken: cred.refresh,
|
|
1412
|
+
expiresAt: cred.expires,
|
|
1413
|
+
chatgptAccountId: oauthAccountId,
|
|
1102
1414
|
});
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1415
|
+
// A successful reauthentication replaces the credential generation. Do not let a
|
|
1416
|
+
// failed optional WHAM probe make the replacement inherit quota from the old record.
|
|
1417
|
+
if (reauth) clearAccountQuota(accountId);
|
|
1418
|
+
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
1419
|
+
clearAccountNeedsReauth(accountId);
|
|
1420
|
+
if (quota) {
|
|
1421
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
1422
|
+
}
|
|
1106
1423
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1424
|
+
if (existingIdx >= 0) {
|
|
1425
|
+
// Keep the pool id stable; refresh display metadata after a successful login/reauth.
|
|
1426
|
+
accounts[existingIdx] = withCodexAccountLogLabel({
|
|
1427
|
+
...accounts[existingIdx],
|
|
1428
|
+
email,
|
|
1429
|
+
plan: plan ?? accounts[existingIdx].plan,
|
|
1430
|
+
isMain: false,
|
|
1431
|
+
}, accounts);
|
|
1432
|
+
latestConfig.codexAccounts = accounts;
|
|
1433
|
+
saveRuntimeConfig(config, latestConfig);
|
|
1434
|
+
} else {
|
|
1435
|
+
accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
|
|
1436
|
+
latestConfig.codexAccounts = accounts;
|
|
1437
|
+
saveRuntimeConfig(config, latestConfig);
|
|
1438
|
+
}
|
|
1439
|
+
reconcileLiveStateStores();
|
|
1440
|
+
setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
|
|
1121
1441
|
completed = true;
|
|
1122
|
-
break;
|
|
1123
1442
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
expiresAt: cred.expires,
|
|
1129
|
-
chatgptAccountId: oauthAccountId,
|
|
1130
|
-
});
|
|
1131
|
-
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
1132
|
-
clearAccountNeedsReauth(accountId);
|
|
1133
|
-
if (quota) {
|
|
1134
|
-
setAccountQuotaFromParsed(accountId, quota);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
if (existingIdx >= 0) {
|
|
1138
|
-
// Keep the pool id stable; refresh display metadata after a successful login/reauth.
|
|
1139
|
-
accounts[existingIdx] = withCodexAccountLogLabel({
|
|
1140
|
-
...accounts[existingIdx],
|
|
1141
|
-
email,
|
|
1142
|
-
plan: plan ?? accounts[existingIdx].plan,
|
|
1143
|
-
isMain: false,
|
|
1144
|
-
}, accounts);
|
|
1145
|
-
latestConfig.codexAccounts = accounts;
|
|
1146
|
-
saveRuntimeConfig(config, latestConfig);
|
|
1147
|
-
} else {
|
|
1148
|
-
accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
|
|
1149
|
-
latestConfig.codexAccounts = accounts;
|
|
1150
|
-
saveRuntimeConfig(config, latestConfig);
|
|
1151
|
-
}
|
|
1152
|
-
codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() });
|
|
1443
|
+
break;
|
|
1444
|
+
}
|
|
1445
|
+
if (st.done && st.error) {
|
|
1446
|
+
setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() });
|
|
1153
1447
|
completed = true;
|
|
1448
|
+
break;
|
|
1154
1449
|
}
|
|
1155
|
-
break;
|
|
1156
1450
|
}
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1451
|
+
if (!completed) {
|
|
1452
|
+
setCodexLoginState(flowId, {
|
|
1453
|
+
status: "error",
|
|
1454
|
+
error: "Login timed out before OAuth completed.",
|
|
1455
|
+
doneAt: Date.now(),
|
|
1456
|
+
});
|
|
1161
1457
|
}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
const message = error instanceof ConfigMutationLockError
|
|
1460
|
+
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
1461
|
+
? "Configuration is busy; retry login shortly."
|
|
1462
|
+
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|
|
1463
|
+
? "Credential refresh is busy; retry login shortly."
|
|
1464
|
+
: error instanceof Error ? error.message : String(error);
|
|
1465
|
+
setCodexLoginState(flowId, {
|
|
1165
1466
|
status: "error",
|
|
1166
|
-
error:
|
|
1467
|
+
error: message,
|
|
1167
1468
|
doneAt: Date.now(),
|
|
1168
1469
|
});
|
|
1470
|
+
} finally {
|
|
1471
|
+
// TTL: keep completed flow state available for clients that miss a short polling window.
|
|
1472
|
+
setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
|
|
1169
1473
|
}
|
|
1170
|
-
// TTL: keep completed flow state available for clients that miss a short polling window.
|
|
1171
|
-
setTimeout(() => codexAuthLoginState.delete(flowId), 300_000);
|
|
1172
1474
|
})();
|
|
1173
1475
|
|
|
1174
|
-
|
|
1476
|
+
setCodexLoginState(flowId, { status: "pending" });
|
|
1175
1477
|
return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
|
|
1176
1478
|
} catch (e) {
|
|
1479
|
+
if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
|
|
1177
1480
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1178
1481
|
if (msg.includes("already in progress")) {
|
|
1179
1482
|
return jsonResponse({ error: msg, status: "pending" }, 409);
|
|
1180
1483
|
}
|
|
1484
|
+
if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
1485
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
1486
|
+
response.headers.set("Retry-After", "1");
|
|
1487
|
+
return response;
|
|
1488
|
+
}
|
|
1181
1489
|
return jsonResponse({ error: msg }, 500);
|
|
1182
1490
|
}
|
|
1183
1491
|
}
|