@bitkyc08/opencodex 2.89.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -136,25 +136,37 @@ export function peekCodexAccountRecordStore(): Record<string, CodexAccountCreden
|
|
|
136
136
|
|
|
137
137
|
/** Read the Codex account record store from disk with hardened permissions, and hand it to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
|
|
138
138
|
function loadCodexAccountRecordStore(): CodexAccountStore {
|
|
139
|
+
const result = loadCodexAccountRecordStoreResult();
|
|
140
|
+
return result.status === "ok" ? result.records : {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type CodexAccountRecordStoreResult = { status: "ok"; records: CodexAccountStore } | { status: "failed" };
|
|
144
|
+
|
|
145
|
+
/** Retain read/parse failure separately for consumers that must preserve existing evidence. */
|
|
146
|
+
function loadCodexAccountRecordStoreResult(): CodexAccountRecordStoreResult {
|
|
139
147
|
const path = codexAccountsPath();
|
|
140
148
|
hardenConfigDir();
|
|
141
149
|
hardenExistingSecret(path);
|
|
142
|
-
if (!existsSync(path)) { notifyCodexStoreLoaded({}); return {}; }
|
|
143
150
|
const normalized: CodexAccountStore = {};
|
|
144
151
|
try {
|
|
145
152
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
|
|
153
|
+
if (!isObject(raw)) throw new Error("invalid Codex account store shape");
|
|
146
154
|
for (const [id, value] of Object.entries(raw)) {
|
|
147
155
|
const record = normalizeRecord(value);
|
|
148
156
|
if (record) normalized[id] = record;
|
|
149
157
|
}
|
|
150
|
-
} catch {
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
160
|
+
notifyCodexStoreLoaded({});
|
|
161
|
+
return { status: "ok", records: {} };
|
|
162
|
+
}
|
|
151
163
|
backupInvalidConfig(path);
|
|
152
164
|
notifyCodexStoreLoaded({});
|
|
153
|
-
return {};
|
|
165
|
+
return { status: "failed" };
|
|
154
166
|
}
|
|
155
167
|
// Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
|
|
156
168
|
notifyCodexStoreLoaded(normalized);
|
|
157
|
-
return normalized;
|
|
169
|
+
return { status: "ok", records: normalized };
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
/** Atomically write the Codex account store, then hand it to the opt-in decision-quota slot so active advisory evidence tracks the persisted credentials. */
|
|
@@ -400,6 +412,10 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
|
|
|
400
412
|
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
401
413
|
}
|
|
402
414
|
|
|
415
|
+
/** Callable for existing liveness consumers; stateful consumers can distinguish unknown snapshots. */
|
|
416
|
+
export type CodexAccountGenerationLiveCheck = ((id: string, generation: number) => boolean)
|
|
417
|
+
& { readonly snapshotStatus: "ok" | "failed" };
|
|
418
|
+
|
|
403
419
|
/**
|
|
404
420
|
* The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
|
|
405
421
|
*
|
|
@@ -409,12 +425,15 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
|
|
|
409
425
|
* several accounts in one synchronous pass on the request path, so it opens one checker and
|
|
410
426
|
* closes over the snapshot instead.
|
|
411
427
|
*/
|
|
412
|
-
export function beginCodexAccountGenerationLiveCheck():
|
|
413
|
-
|
|
414
|
-
|
|
428
|
+
export function beginCodexAccountGenerationLiveCheck(): CodexAccountGenerationLiveCheck {
|
|
429
|
+
let result: CodexAccountRecordStoreResult;
|
|
430
|
+
try { result = loadCodexAccountRecordStoreResult(); }
|
|
431
|
+
catch { result = { status: "failed" }; }
|
|
432
|
+
const snapshot = result.status === "ok" ? result.records : {};
|
|
433
|
+
return Object.assign((id: string, generation: number) => {
|
|
415
434
|
const record = snapshot[id];
|
|
416
435
|
return !!record?.credential && record.deletedAt == null && record.generation === generation;
|
|
417
|
-
};
|
|
436
|
+
}, { snapshotStatus: result.status });
|
|
418
437
|
}
|
|
419
438
|
|
|
420
439
|
export function saveCodexAccountCredentialIfGeneration(
|
|
@@ -9,6 +9,7 @@ import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./a
|
|
|
9
9
|
import type { OcxConfig } from "../types";
|
|
10
10
|
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
|
|
11
11
|
import { isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
12
|
+
import { configuredCodexMainDuplicate } from "./collision-identity";
|
|
12
13
|
|
|
13
14
|
export interface CodexAccountUsabilityOptions {
|
|
14
15
|
/** Route using cached runtime state only; the caller must reject selected main before auth. */
|
|
@@ -64,7 +65,8 @@ export type CodexAccountUnusableReason =
|
|
|
64
65
|
| "not_in_pool"
|
|
65
66
|
| "missing_credential"
|
|
66
67
|
| "deleted"
|
|
67
|
-
| "validation_pending"
|
|
68
|
+
| "validation_pending"
|
|
69
|
+
| "duplicate_main";
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
|
|
@@ -110,6 +112,7 @@ export function codexAccountUnusableReason(
|
|
|
110
112
|
if (!record?.credential) return "missing_credential";
|
|
111
113
|
if (record.deletedAt != null) return "deleted";
|
|
112
114
|
if (record.codexValidationPending) return "validation_pending";
|
|
115
|
+
if (configuredCodexMainDuplicate(config, accountId)) return "duplicate_main";
|
|
113
116
|
return undefined;
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
|
|
2
2
|
import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
|
|
3
3
|
import type { CodexCredits } from "../credits";
|
|
4
|
-
import { getMainChatgptAccountId } from "../auth-collision";
|
|
5
4
|
import { codexAccountLogLabel } from "../account-label";
|
|
6
5
|
import { poolQuotaHistoryIdentity, getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
|
|
7
6
|
import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
|
|
@@ -35,6 +34,7 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC
|
|
|
35
34
|
import type { PoolQuotaResult } from "./pool-quota-probe";
|
|
36
35
|
import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
|
|
37
36
|
import { isPassiveRead } from "../../lib/passive-read";
|
|
37
|
+
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity";
|
|
38
38
|
|
|
39
39
|
export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
40
40
|
quota: T,
|
|
@@ -133,6 +133,7 @@ export function poolAccountDto(
|
|
|
133
133
|
// health projection. Emitting only the boolean is what left #4212's reporter guessing which
|
|
134
134
|
// account took their model away and why, so name the cause they actually have to act on.
|
|
135
135
|
const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
|
|
136
|
+
const duplicateMain = configuredCodexMainDuplicate(config, account.id);
|
|
136
137
|
return {
|
|
137
138
|
id: account.id,
|
|
138
139
|
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
@@ -150,7 +151,10 @@ export function poolAccountDto(
|
|
|
150
151
|
...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
|
|
151
152
|
needsReauth: needsReauth || health.status === "reauth_required",
|
|
152
153
|
...(reauthReason !== undefined ? { reauthReason } : {}),
|
|
153
|
-
...(
|
|
154
|
+
...(duplicateMain ? {
|
|
155
|
+
selectionExcludedReason: "duplicate_main" as const,
|
|
156
|
+
selectionExcludedAction: CODEX_MAIN_DUPLICATE_ACTION,
|
|
157
|
+
} : isCodexAccountPlanExcluded(config, account.id) ? {
|
|
154
158
|
selectionExcludedReason: "plan_excluded" as const,
|
|
155
159
|
selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
|
|
156
160
|
} : {}),
|
|
@@ -184,8 +188,9 @@ export interface CodexAuthAccountDto {
|
|
|
184
188
|
* needs the operator; `/api/oauth/accounts` already carries the same field name.
|
|
185
189
|
*/
|
|
186
190
|
reauthReason?: CodexAccountReauthReason;
|
|
187
|
-
/**
|
|
188
|
-
selectionExcludedReason?: "plan_excluded";
|
|
191
|
+
/** Plan policy is automatic-only; duplicate-main exclusions also apply to explicit routes. */
|
|
192
|
+
selectionExcludedReason?: "plan_excluded" | "duplicate_main";
|
|
193
|
+
selectionExcludedAction?: string;
|
|
189
194
|
selectionExcludedPlan?: string;
|
|
190
195
|
hasCredential: boolean;
|
|
191
196
|
health: OAuthAccountHealth;
|
|
@@ -418,7 +423,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
418
423
|
quota: mainInfo.quota
|
|
419
424
|
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
|
|
420
425
|
: null,
|
|
421
|
-
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID,
|
|
426
|
+
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getObservedMainCodexCollisionIdentity()?.accountId ?? null),
|
|
422
427
|
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
423
428
|
};
|
|
424
429
|
return {
|
|
@@ -1,35 +1,15 @@
|
|
|
1
1
|
import { observeSignedInCodexIdentity } from "../signed-in-identity";
|
|
2
2
|
import { lstatSync } from "node:fs";
|
|
3
3
|
import type { OcxConfig } from "../../types";
|
|
4
|
-
import {
|
|
5
|
-
import type { CodexTokenReadResult } from "../auth-collision";
|
|
4
|
+
import { readMainAuth } from "../auth-collision";
|
|
6
5
|
import { linkedCodexAccountIds } from "../account-identity";
|
|
7
6
|
import { resolveCodexHomeDir } from "../home";
|
|
8
7
|
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
9
8
|
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
10
|
-
import {
|
|
9
|
+
import { resolveNativeProfileContext } from "../native-profile-store";
|
|
11
10
|
import { NativeProfileError } from "../native-profile-types";
|
|
12
11
|
import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
|
|
13
12
|
|
|
14
|
-
type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
15
|
-
|
|
16
|
-
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
17
|
-
function readMainAuth(authPath: string): MainAuth {
|
|
18
|
-
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
19
|
-
if (main.status !== "invalid") return main;
|
|
20
|
-
try {
|
|
21
|
-
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
22
|
-
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
23
|
-
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
24
|
-
const apiKeyOnly = envelope.tokens == null
|
|
25
|
-
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
26
|
-
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
27
|
-
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
28
|
-
} catch {
|
|
29
|
-
return main;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
13
|
/** Hold admission throughout; claim the physical main whenever its home exists. */
|
|
34
14
|
export async function withCodexAccountPauseGroup(
|
|
35
15
|
config: OcxConfig,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { extractAccountId, extractAccountIdClaims, extractEmail } from "../../oauth/chatgpt";
|
|
3
|
+
import { checkAccountIdCollision, readMainAuth } from "../auth-collision";
|
|
4
|
+
import { resolveCodexHomeDir } from "../home";
|
|
5
|
+
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
6
|
+
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
7
|
+
import { resolveNativeProfileContext } from "../native-profile-store";
|
|
8
|
+
import { NativeProfileError } from "../native-profile-types";
|
|
9
|
+
import { extractChatgptPlanType } from "../plan";
|
|
10
|
+
import { isNativeMainClaimUnavailable } from "./http";
|
|
11
|
+
|
|
12
|
+
export class CodexCollisionReadBusyError extends Error {
|
|
13
|
+
constructor() { super("Native Codex login is busy or unreadable; retry login shortly."); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only admission owns physical reads; synchronous collision consumers use observed identity. */
|
|
17
|
+
export async function checkAccountIdCollisionWithNativeMain(
|
|
18
|
+
accountId: string, email?: string | null, plan?: unknown, excludeAccountId?: string | null,
|
|
19
|
+
): Promise<ReturnType<typeof checkAccountIdCollision>> {
|
|
20
|
+
const lease = tryAcquireNativeMainProfileClaim();
|
|
21
|
+
if (!lease) throw new CodexCollisionReadBusyError();
|
|
22
|
+
try {
|
|
23
|
+
const codexHome = resolveCodexHomeDir();
|
|
24
|
+
try { lstatSync(codexHome); } catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
26
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
|
|
27
|
+
}
|
|
28
|
+
throw new CodexCollisionReadBusyError();
|
|
29
|
+
}
|
|
30
|
+
const context = resolveNativeProfileContext({ codexHome });
|
|
31
|
+
return await withNativeMainSharedClaim(context, async () => {
|
|
32
|
+
const main = readMainAuth(context.authPath);
|
|
33
|
+
if (main.status === "missing" || main.status === "api-key-only") {
|
|
34
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
|
|
35
|
+
}
|
|
36
|
+
if (main.status !== "ok") throw new CodexCollisionReadBusyError();
|
|
37
|
+
const tokens = main.tokens;
|
|
38
|
+
const nativeId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
|
|
39
|
+
const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
|
|
40
|
+
const emails = [extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)].filter(Boolean);
|
|
41
|
+
if (!nativeId || typeof tokens.account_id !== "string" || typeof tokens.access_token !== "string"
|
|
42
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")
|
|
43
|
+
|| claims.some(claim => claim.conflict || (claim.accountId && claim.accountId !== nativeId))
|
|
44
|
+
|| (tokens.account_id && tokens.account_id !== nativeId) || new Set(emails).size > 1) {
|
|
45
|
+
throw new CodexCollisionReadBusyError();
|
|
46
|
+
}
|
|
47
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, {
|
|
48
|
+
accountId: nativeId, email: extractEmail(tokens.id_token, tokens.access_token),
|
|
49
|
+
plan: extractChatgptPlanType(tokens.id_token, tokens.access_token),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (isNativeMainClaimUnavailable(error)
|
|
54
|
+
|| (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
|
|
55
|
+
throw new CodexCollisionReadBusyError();
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
} finally { lease.release(); }
|
|
59
|
+
}
|
|
@@ -5,7 +5,8 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
|
|
|
5
5
|
import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
|
|
6
6
|
import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
|
|
7
7
|
import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status";
|
|
8
|
-
import { checkAccountIdCollision } from "
|
|
8
|
+
import { checkAccountIdCollisionWithNativeMain as checkAccountIdCollision, CodexCollisionReadBusyError } from "./login-collision";
|
|
9
|
+
import { extractChatgptPlanType } from "../plan";
|
|
9
10
|
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
|
|
10
11
|
import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
|
|
11
12
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
@@ -262,8 +263,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
262
263
|
break;
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
let email = cred.email ||
|
|
266
|
-
let plan
|
|
266
|
+
let email = cred.email?.trim() || "";
|
|
267
|
+
let plan = extractChatgptPlanType(undefined, cred.access);
|
|
267
268
|
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
268
269
|
let policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
269
270
|
try {
|
|
@@ -274,8 +275,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
274
275
|
});
|
|
275
276
|
if (resp.ok) {
|
|
276
277
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
277
|
-
email = data.email
|
|
278
|
-
plan = nonEmptyPlan(data.plan_type) ??
|
|
278
|
+
email = data.email?.trim() || email;
|
|
279
|
+
plan = nonEmptyPlan(data.plan_type) ?? plan;
|
|
279
280
|
quota = parseUsageQuota(data);
|
|
280
281
|
policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
|
|
281
282
|
}
|
|
@@ -322,7 +323,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
322
323
|
}
|
|
323
324
|
|
|
324
325
|
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
325
|
-
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
326
|
+
const collision = await checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
326
327
|
if (collision.collision) {
|
|
327
328
|
setCodexLoginState(flowId, {
|
|
328
329
|
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
@@ -331,6 +332,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
331
332
|
break;
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
email ||= accountId;
|
|
336
|
+
|
|
334
337
|
// A successful authenticated WHAM read can prove quota is exhausted without
|
|
335
338
|
// spending an inference request. Store the account, but defer inference validation
|
|
336
339
|
// and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
|
|
@@ -470,7 +473,9 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
470
473
|
});
|
|
471
474
|
}
|
|
472
475
|
} catch (error) {
|
|
473
|
-
const message = error instanceof
|
|
476
|
+
const message = error instanceof CodexCollisionReadBusyError
|
|
477
|
+
? "Native Codex login is busy or unreadable; retry login shortly."
|
|
478
|
+
: error instanceof ConfigMutationLockError
|
|
474
479
|
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
475
480
|
? "Configuration is busy; retry login shortly."
|
|
476
481
|
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|
|
@@ -13,7 +13,7 @@ import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
|
13
13
|
import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
|
|
14
14
|
import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
|
|
15
15
|
import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
|
|
16
|
-
import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
|
|
16
|
+
import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock";
|
|
17
17
|
import type { OcxConfig } from "../../types";
|
|
18
18
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
19
19
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
@@ -78,8 +78,55 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
|
|
|
78
78
|
|
|
79
79
|
const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;
|
|
80
80
|
|
|
81
|
+
/** Traffic headers renew usage, never the independent credit observation (#7377). */
|
|
82
|
+
function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean {
|
|
83
|
+
const account = configuredPoolAccount(config, accountId);
|
|
84
|
+
if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId)
|
|
85
|
+
|| isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
|
|
86
|
+
const quota = getAccountQuota(accountId);
|
|
87
|
+
if (codexUsageLimitResetAt(quota, account.plan, now) === undefined) return false;
|
|
88
|
+
const observedAt = quota?.credits?.observedAt;
|
|
89
|
+
if (observedAt !== undefined && Number.isFinite(observedAt)
|
|
90
|
+
&& now - observedAt >= 0 && now - observedAt < MAIN_CREDIT_RENEWAL_AGE_MS) return false;
|
|
91
|
+
const attempt = poolQuotaPrimeAttemptedAt.get(accountId);
|
|
92
|
+
return !attempt || attempt.generation !== readCodexAccountRecord(accountId)?.generation
|
|
93
|
+
|| now - attempt.at >= MAIN_CREDIT_RENEWAL_AGE_MS;
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
let mainHardLockRecoveryInFlight: Promise<void> | null = null;
|
|
82
|
-
|
|
97
|
+
/** `floor` and `resetBound` apply only while a predicted reset is (or just was) holding the lock. */
|
|
98
|
+
type MainRecoveryAttempt = { identity: number; credential: number; after: number; delay: number; floor?: number; resetBound?: number };
|
|
99
|
+
let mainHardLockRecoveryAttempt: MainRecoveryAttempt | undefined;
|
|
100
|
+
/** The identity/credential that already waited out its first hold interval. */
|
|
101
|
+
let mainHardLockHoldSeen: { identity: number; credential: number } | undefined;
|
|
102
|
+
const MAIN_HOLD_MIN_INTERVAL_MS = nextQuotaQueryDelay();
|
|
103
|
+
|
|
104
|
+
/** The predicted reset of a blocked main, or undefined when no future reset is known. */
|
|
105
|
+
function predictedMainResetHold(status: ReturnType<typeof getMainAccountHardLockStatus>): number | undefined {
|
|
106
|
+
return status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()
|
|
107
|
+
? status.resetAt : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A hold read is due at its backoff deadline, pulled in to the latest known predicted reset (even
|
|
112
|
+
* one that moved earlier or just passed) but never sooner than one base interval after the last read.
|
|
113
|
+
*/
|
|
114
|
+
function mainRecoveryDueAt(attempt: MainRecoveryAttempt, holdResetAt: number | undefined, config: OcxConfig): number {
|
|
115
|
+
// Once a hold elapses the status omits its reset; current evidence may still name a revised one.
|
|
116
|
+
const bound = holdResetAt ?? (attempt.resetBound === undefined ? undefined
|
|
117
|
+
: Math.min(attempt.resetBound, latestMainBlockingResetAt(config) ?? Infinity));
|
|
118
|
+
return bound === undefined ? attempt.after : Math.max(attempt.floor ?? 0, Math.min(attempt.after, bound));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** First sight of a hold for this credential waits one base interval; transient retries do not re-arm it. */
|
|
122
|
+
function armMainResetHold(identity: number, credential: number, holdResetAt: number): void {
|
|
123
|
+
const at = Date.now() + MAIN_HOLD_MIN_INTERVAL_MS;
|
|
124
|
+
mainHardLockHoldSeen = { identity, credential };
|
|
125
|
+
mainHardLockRecoveryAttempt = { identity, credential, delay: MAIN_HOLD_MIN_INTERVAL_MS, after: at, floor: at, resetBound: holdResetAt };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const mainHoldSeenFor = (identity: number, credential: number): boolean =>
|
|
129
|
+
mainHardLockHoldSeen?.identity === identity && mainHardLockHoldSeen.credential === credential;
|
|
83
130
|
|
|
84
131
|
/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
|
|
85
132
|
export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
|
|
@@ -98,10 +145,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
98
145
|
|| (observedAt !== undefined && now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS));
|
|
99
146
|
};
|
|
100
147
|
const status = getMainAccountHardLockStatus(config);
|
|
148
|
+
if (status.state !== "blocked") mainHardLockHoldSeen = undefined;
|
|
101
149
|
if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; }
|
|
102
150
|
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
103
|
-
// A predicted reset is a scheduling hint, never proof that the hard lock can be lifted.
|
|
104
|
-
|
|
151
|
+
// A predicted reset is a scheduling hint, never proof that the hard lock can be lifted. The
|
|
152
|
+
// hold is rechecked on the capped WHAM schedule so an early reset redeemed elsewhere is
|
|
153
|
+
// learned from metadata (#7312); a paused main receives no hold rechecks.
|
|
154
|
+
const holdResetAt = predictedMainResetHold(status);
|
|
155
|
+
if (holdResetAt !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)) return;
|
|
105
156
|
// Reconcile the owned physical credential before skipping work for an older one.
|
|
106
157
|
reconcileMainCodexAccountRuntimeState();
|
|
107
158
|
const physical = readCodexTokens();
|
|
@@ -109,9 +160,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
109
160
|
const identity = captureMainAccountIdentityGeneration();
|
|
110
161
|
const credential = getMainQuotaCredentialGeneration();
|
|
111
162
|
const previous = mainHardLockRecoveryAttempt;
|
|
163
|
+
if (holdResetAt !== undefined && !mainHoldSeenFor(identity, credential)) {
|
|
164
|
+
armMainResetHold(identity, credential, holdResetAt);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const sameAttempt = previous?.identity === identity && previous.credential === credential;
|
|
168
|
+
if (sameAttempt && holdResetAt !== undefined) previous.resetBound = holdResetAt;
|
|
112
169
|
const queryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
|
|
113
|
-
if ((previous
|
|
114
|
-
&& previous.after > Date.now()) || queryAfter > Date.now()) return;
|
|
170
|
+
if ((sameAttempt && mainRecoveryDueAt(previous, holdResetAt, config) > Date.now()) || queryAfter > Date.now()) return;
|
|
115
171
|
const lease = tryAcquireNativeMainProfileClaim();
|
|
116
172
|
if (!lease) return;
|
|
117
173
|
mainHardLockRecoveryInFlight = (async () => {
|
|
@@ -130,16 +186,20 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
130
186
|
// Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
|
|
131
187
|
return;
|
|
132
188
|
}
|
|
133
|
-
const
|
|
189
|
+
const currentHold = predictedMainResetHold(getMainAccountHardLockStatus(config));
|
|
134
190
|
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|
|
135
191
|
|| !isMainAccountIdentityGenerationLive(identityGeneration)
|
|
136
192
|
|| (passive && !creditRecoveryNeeded())
|
|
137
|
-
|| (
|
|
138
|
-
&& currentStatus.resetAt > Date.now())) return;
|
|
193
|
+
|| (currentHold !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID))) return;
|
|
139
194
|
const credential = getMainQuotaCredentialGeneration();
|
|
195
|
+
// A hold that appeared during preparation is first seen now and waits its base interval.
|
|
196
|
+
if (currentHold !== undefined && !mainHoldSeenFor(identityGeneration, credential)) {
|
|
197
|
+
armMainResetHold(identityGeneration, credential, currentHold);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
140
200
|
// Reconcile the physical bearer before consulting the previous credential's delay.
|
|
141
201
|
if (previous?.identity === identityGeneration && previous.credential === credential
|
|
142
|
-
&& previous
|
|
202
|
+
&& mainRecoveryDueAt(previous, currentHold, config) > Date.now()) return;
|
|
143
203
|
const currentQueryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
|
|
144
204
|
if (currentQueryAfter > Date.now()) {
|
|
145
205
|
mainHardLockRecoveryAttempt = { identity: identityGeneration, credential,
|
|
@@ -162,8 +222,10 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
162
222
|
if (transientAuth) mainHardLockRecoveryAttempt = undefined;
|
|
163
223
|
else {
|
|
164
224
|
const delay = nextQuotaQueryDelay(previousDelay || undefined);
|
|
225
|
+
const hold = predictedMainResetHold(getMainAccountHardLockStatus(config));
|
|
165
226
|
mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded()
|
|
166
|
-
? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter)
|
|
227
|
+
? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter),
|
|
228
|
+
...(hold !== undefined ? { floor: Date.now() + MAIN_HOLD_MIN_INTERVAL_MS, resetBound: hold } : {}) }
|
|
167
229
|
: undefined;
|
|
168
230
|
}
|
|
169
231
|
} else if (queryAfter > Date.now()) {
|
|
@@ -186,6 +248,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
|
|
|
186
248
|
afterTick: () => {
|
|
187
249
|
void runCodexCooldownRecoveryProbes(config);
|
|
188
250
|
void runMainAccountHardLockRecovery(config);
|
|
251
|
+
void primeCodexPoolQuotas(config, "credit-renewal");
|
|
189
252
|
},
|
|
190
253
|
});
|
|
191
254
|
}
|
|
@@ -265,6 +328,7 @@ export async function primeCodexPoolQuotas(
|
|
|
265
328
|
primeInFlight = trackBackgroundWork(`codex-pool-quota-prime:${reason}`, (async () => {
|
|
266
329
|
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
267
330
|
const stale = pool.filter(a => {
|
|
331
|
+
if (reason === "credit-renewal") return poolCreditRenewalDue(runtimeConfig, a.id);
|
|
268
332
|
const q = getAccountQuota(a.id);
|
|
269
333
|
const observationStale = reason === "priority-failback" && q
|
|
270
334
|
&& !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
|
|
@@ -278,7 +342,10 @@ export async function primeCodexPoolQuotas(
|
|
|
278
342
|
if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
|
|
279
343
|
return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
|
|
280
344
|
});
|
|
345
|
+
if (reason === "credit-renewal" && stale.length === 0) return;
|
|
281
346
|
const primeMain = async () => {
|
|
347
|
+
// Main renewal owns its physical-identity claims and passive probe separately.
|
|
348
|
+
if (reason === "credit-renewal") return;
|
|
282
349
|
const mainLease = tryAcquireNativeMainPrimeLease();
|
|
283
350
|
if (!mainLease) return;
|
|
284
351
|
try {
|
|
@@ -311,11 +378,12 @@ export async function primeCodexPoolQuotas(
|
|
|
311
378
|
primeMain(),
|
|
312
379
|
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
313
380
|
if (!getCodexAccountCredential(a.id)) return;
|
|
381
|
+
if (reason === "credit-renewal" && !poolCreditRenewalDue(getRuntimeConfig(config), a.id)) return;
|
|
314
382
|
let result: PoolQuotaResult;
|
|
315
383
|
try {
|
|
316
384
|
const quota = getAccountQuota(a.id);
|
|
317
|
-
const bypassCachedQuota = reason === "priority-failback" && quota !== null
|
|
318
|
-
&& !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
|
|
385
|
+
const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null
|
|
386
|
+
&& !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS));
|
|
319
387
|
result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
|
|
320
388
|
} catch (error) {
|
|
321
389
|
// Local quota-flight saturation proves no WHAM request existed for this account.
|
|
@@ -4,8 +4,9 @@ import { getCodexAccountCredential } from "./account-store";
|
|
|
4
4
|
import { loadConfig } from "../config";
|
|
5
5
|
import { resolveCodexHomeDir } from "./home";
|
|
6
6
|
import { extractAccountId } from "../oauth/chatgpt";
|
|
7
|
-
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
7
|
+
import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
8
8
|
import { codexPlanKey } from "./plan";
|
|
9
|
+
import { getObservedMainCodexCollisionIdentity, sameCodexCollisionIdentity, type CodexCollisionIdentity } from "./collision-identity";
|
|
9
10
|
import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
|
|
10
11
|
|
|
11
12
|
export interface CodexTokens {
|
|
@@ -94,17 +95,23 @@ function isWorkspacePlan(plan: unknown): boolean {
|
|
|
94
95
|
return !!key && /team|business|enterprise|workspace|edu/.test(key);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
// Main login and
|
|
98
|
-
//
|
|
99
|
-
// Within each pool bucket, keep the original ChatGPT account id + email collision guard.
|
|
98
|
+
// Main login and pool accounts share the account id + email collision guard.
|
|
99
|
+
// Personal and workspace subscriptions remain separate buckets.
|
|
100
100
|
export function checkAccountIdCollision(
|
|
101
101
|
chatgptAccountId: string,
|
|
102
102
|
email?: string | null,
|
|
103
103
|
plan?: unknown,
|
|
104
104
|
excludeAccountId?: string | null,
|
|
105
|
+
main: CodexCollisionIdentity | null = getObservedMainCodexCollisionIdentity() ?? null,
|
|
105
106
|
): { collision: true; reason: string } | { collision: false } {
|
|
106
107
|
const candidateEmail = normalizedEmail(email);
|
|
107
108
|
const candidateWorkspace = isWorkspacePlan(plan);
|
|
109
|
+
|
|
110
|
+
if (excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && main
|
|
111
|
+
&& sameCodexCollisionIdentity({ accountId: chatgptAccountId, email, plan }, main)) {
|
|
112
|
+
return { collision: true, reason: "Account is already configured as the native Codex main account." };
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
for (const account of loadConfig().codexAccounts ?? []) {
|
|
109
116
|
if (excludeAccountId && account.id === excludeAccountId) continue;
|
|
110
117
|
if (!isSelectableCodexPoolAccount(account)) continue;
|
|
@@ -117,3 +124,23 @@ export function checkAccountIdCollision(
|
|
|
117
124
|
}
|
|
118
125
|
return { collision: false };
|
|
119
126
|
}
|
|
127
|
+
|
|
128
|
+
export type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
129
|
+
|
|
130
|
+
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
131
|
+
export function readMainAuth(authPath = join(resolveCodexHomeDir(), "auth.json")): MainAuth {
|
|
132
|
+
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
133
|
+
if (main.status !== "invalid") return main;
|
|
134
|
+
try {
|
|
135
|
+
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
136
|
+
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
137
|
+
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
138
|
+
const apiKeyOnly = envelope.tokens == null
|
|
139
|
+
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
140
|
+
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
141
|
+
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
142
|
+
} catch {
|
|
143
|
+
return main;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|