@coseung2/opencodex 2.8.0-cs.12 → 2.8.0-cs.14
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/index-MUpaVatk.js +67 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -3
- package/packages/ocx-notch/README.md +3 -2
- package/src/adapters/cursor/discovery.ts +6 -2
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/google-antigravity-replay.ts +24 -0
- package/src/adapters/google.ts +16 -11
- package/src/chat/inbound.ts +5 -11
- package/src/cli/account-api.ts +9 -1
- package/src/cli/account-extended.ts +4 -1
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +12 -1
- package/src/codex/account-namespaces.ts +21 -0
- package/src/codex/account-priority.ts +49 -0
- package/src/codex/account-store.ts +73 -10
- package/src/codex/auth-api.ts +312 -45
- package/src/codex/auth-context.ts +61 -16
- package/src/codex/catalog/metadata.ts +34 -12
- package/src/codex/catalog/parsing.ts +8 -1
- package/src/codex/catalog/provider-fetch.ts +24 -6
- package/src/codex/catalog.ts +1 -1
- package/src/codex/pool-rotation.ts +51 -4
- package/src/codex/quota.ts +154 -35
- package/src/codex/routing.ts +133 -33
- package/src/codex/warmup.ts +204 -82
- package/src/config.ts +84 -1
- package/src/lib/bounded-body.ts +13 -6
- package/src/lib/bun-stream-caps.ts +5 -6
- package/src/lib/redact.ts +13 -0
- package/src/oauth/index.ts +79 -12
- package/src/oauth/log.ts +3 -1
- package/src/oauth/store.ts +31 -8
- package/src/oauth/token-guardian.ts +130 -9
- package/src/providers/antigravity-models.ts +53 -24
- package/src/providers/codex-capacity.ts +303 -0
- package/src/providers/model-rename-migration.ts +147 -0
- package/src/providers/model-rename-startup.ts +29 -0
- package/src/providers/quota.ts +126 -16
- package/src/providers/registry.ts +262 -38
- package/src/responses/parser.ts +19 -12
- package/src/responses/spill-store.ts +14 -1
- package/src/responses/state.ts +108 -14
- package/src/router.ts +6 -0
- package/src/server/index.ts +9 -1
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +8 -1
- package/src/server/relay.ts +10 -42
- package/src/server/request-log.ts +42 -1
- package/src/server/responses/compact.ts +16 -4
- package/src/server/responses/core.ts +217 -59
- package/src/server/responses/empty-completion-guard.ts +275 -0
- package/src/server/responses/encrypted-payload.ts +54 -39
- package/src/server/responses/fetch-helpers.ts +24 -3
- package/src/server/responses/ws-upstream.ts +318 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/ws-bridge.ts +17 -11
- package/src/types.ts +13 -1
- package/src/usage/log.ts +24 -0
- package/src/usage/summary.ts +152 -2
- package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
- package/gui/dist/assets/index-GgF1IpQa.js +0 -67
package/src/oauth/index.ts
CHANGED
|
@@ -249,12 +249,61 @@ export class UnsupportedOAuthProviderError extends Error {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
export class OAuthLoginRequiredError extends Error {
|
|
252
|
+
readonly provider: string;
|
|
253
|
+
|
|
252
254
|
constructor(provider: string) {
|
|
253
255
|
super(`Not logged in to ${provider}. Run: ocx login ${provider}`);
|
|
254
256
|
this.name = "OAuthLoginRequiredError";
|
|
257
|
+
this.provider = provider;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export class OAuthProviderPublicationError extends Error {
|
|
262
|
+
constructor() {
|
|
263
|
+
super("OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login.");
|
|
264
|
+
this.name = "OAuthProviderPublicationError";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class OAuthReauthIdentityMismatchError extends Error {
|
|
269
|
+
constructor() {
|
|
270
|
+
super("Signed-in account does not match the selected account. Sign in with the same account.");
|
|
271
|
+
this.name = "OAuthReauthIdentityMismatchError";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export class OAuthReauthIdentityUnverifiedError extends Error {
|
|
276
|
+
constructor() {
|
|
277
|
+
super("Could not verify signed-in account identity for reauth.");
|
|
278
|
+
this.name = "OAuthReauthIdentityUnverifiedError";
|
|
255
279
|
}
|
|
256
280
|
}
|
|
257
281
|
|
|
282
|
+
class OAuthLoginSupersededError extends Error {
|
|
283
|
+
constructor() {
|
|
284
|
+
super("OAuth login was superseded before credential persistence");
|
|
285
|
+
this.name = "OAuthLoginSupersededError";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */
|
|
290
|
+
export function publicOAuthAuthenticationErrorMessage(error: unknown): string {
|
|
291
|
+
if (error instanceof OAuthMutationBusyError) {
|
|
292
|
+
return error.message === "OAuth mutation queue wait timed out"
|
|
293
|
+
? "OAuth mutation queue wait timed out"
|
|
294
|
+
: "OAuth mutation queue is busy";
|
|
295
|
+
}
|
|
296
|
+
if (
|
|
297
|
+
(error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider))
|
|
298
|
+
|| error instanceof OAuthProviderPublicationError
|
|
299
|
+
|| error instanceof OAuthReauthIdentityMismatchError
|
|
300
|
+
|| error instanceof OAuthReauthIdentityUnverifiedError
|
|
301
|
+
|| error instanceof OAuthTokenRefreshBusyError
|
|
302
|
+
|| error instanceof OAuthTokenRefreshStaleError
|
|
303
|
+
) return error.message;
|
|
304
|
+
return "OAuth authentication failed. Check the OpenCodex account status and retry.";
|
|
305
|
+
}
|
|
306
|
+
|
|
258
307
|
function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
|
|
259
308
|
const storedKiroRouting = {
|
|
260
309
|
...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}),
|
|
@@ -817,6 +866,7 @@ interface RunLoginDeps {
|
|
|
817
866
|
settleKiroLoginTransaction?: typeof settleKiroLoginTransaction;
|
|
818
867
|
removeAccount?: typeof removeAccount;
|
|
819
868
|
setActiveAccount?: typeof setActiveAccount;
|
|
869
|
+
assertCurrentOwner?: () => void;
|
|
820
870
|
}
|
|
821
871
|
|
|
822
872
|
/** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */
|
|
@@ -866,6 +916,7 @@ export async function runLogin(
|
|
|
866
916
|
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
|
|
867
917
|
const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
|
|
868
918
|
try {
|
|
919
|
+
deps.assertCurrentOwner?.();
|
|
869
920
|
// Validate the provider row before credential persistence. A namespace claimed during the
|
|
870
921
|
// credential write is handled again below before the latest row is re-upserted.
|
|
871
922
|
if (provider !== "chatgpt") {
|
|
@@ -876,7 +927,7 @@ export async function runLogin(
|
|
|
876
927
|
const existing = getAccountCredential(provider, opts.reauthAccountId);
|
|
877
928
|
if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`);
|
|
878
929
|
if (!existing.accountId && !existing.email) {
|
|
879
|
-
throw new
|
|
930
|
+
throw new OAuthReauthIdentityUnverifiedError();
|
|
880
931
|
}
|
|
881
932
|
// Kiro social accounts share one device-scoped profile ARN, so reauth identity
|
|
882
933
|
// must compare the signed-in email first; otherwise reauthenticating a different
|
|
@@ -890,12 +941,15 @@ export async function runLogin(
|
|
|
890
941
|
? existing.email.toLowerCase() === cred.email.toLowerCase()
|
|
891
942
|
: false;
|
|
892
943
|
if (!identityMatches) {
|
|
893
|
-
throw new
|
|
944
|
+
throw new OAuthReauthIdentityMismatchError();
|
|
894
945
|
}
|
|
895
|
-
await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred
|
|
946
|
+
await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, {
|
|
947
|
+
assertBeforePersist: deps.assertCurrentOwner,
|
|
948
|
+
});
|
|
896
949
|
} else {
|
|
897
950
|
await (deps.saveCredential ?? saveCredential)(provider, cred, {
|
|
898
|
-
preserveIdentityless:
|
|
951
|
+
preserveIdentityless: opts?.forceLogin === true,
|
|
952
|
+
assertBeforePersist: deps.assertCurrentOwner,
|
|
899
953
|
});
|
|
900
954
|
}
|
|
901
955
|
if (provider !== "chatgpt") {
|
|
@@ -907,10 +961,7 @@ export async function runLogin(
|
|
|
907
961
|
provider,
|
|
908
962
|
);
|
|
909
963
|
if (lateCollision) {
|
|
910
|
-
throw new
|
|
911
|
-
`${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. `
|
|
912
|
-
+ "Rename the account selector, then re-run the login.",
|
|
913
|
-
);
|
|
964
|
+
throw new OAuthProviderPublicationError();
|
|
914
965
|
}
|
|
915
966
|
upsertOAuthProvider(latestConfig, provider);
|
|
916
967
|
saveLatestConfig(latestConfig);
|
|
@@ -961,6 +1012,7 @@ export async function runLogin(
|
|
|
961
1012
|
*/
|
|
962
1013
|
const loginState = new Map<string, { error?: string; done: boolean }>();
|
|
963
1014
|
const loginAbort = new Map<string, AbortController>();
|
|
1015
|
+
const kiroLoginSettling = new Set<string>();
|
|
964
1016
|
|
|
965
1017
|
/** Pending paste for a login in progress: either a waiter or a stashed early submission. */
|
|
966
1018
|
interface ManualCodeSlot {
|
|
@@ -1117,13 +1169,14 @@ export async function startLoginFlow(
|
|
|
1117
1169
|
const def = OAUTH_PROVIDERS[provider];
|
|
1118
1170
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
1119
1171
|
const existing = loginState.get(provider);
|
|
1120
|
-
if (existing && !existing.done) {
|
|
1172
|
+
if ((existing && !existing.done) || (provider === "kiro" && kiroLoginSettling.has(provider))) {
|
|
1121
1173
|
throw new Error(`A login for ${provider} is already in progress`);
|
|
1122
1174
|
}
|
|
1123
1175
|
clearManualCodeSlot(provider);
|
|
1124
1176
|
loginState.set(provider, { done: false });
|
|
1125
1177
|
const abort = new AbortController();
|
|
1126
1178
|
loginAbort.set(provider, abort);
|
|
1179
|
+
if (provider === "kiro") kiroLoginSettling.add(provider);
|
|
1127
1180
|
return new Promise((resolve, reject) => {
|
|
1128
1181
|
let urlResolved = false;
|
|
1129
1182
|
const ctrl: OAuthController = {
|
|
@@ -1136,7 +1189,14 @@ export async function startLoginFlow(
|
|
|
1136
1189
|
onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
|
|
1137
1190
|
signal: abort.signal,
|
|
1138
1191
|
};
|
|
1192
|
+
const abandonIfNotOwner = (error?: unknown): boolean => {
|
|
1193
|
+
if (loginAbort.get(provider) === abort) return false;
|
|
1194
|
+
if (!urlResolved) reject(error ?? new Error("OAuth login was superseded"));
|
|
1195
|
+
return true;
|
|
1196
|
+
};
|
|
1139
1197
|
const settle = async (error?: unknown): Promise<void> => {
|
|
1198
|
+
// A cancelled or superseded flow must not overwrite the newer owner's terminal state.
|
|
1199
|
+
if (abandonIfNotOwner(error)) return;
|
|
1140
1200
|
let finalError = error;
|
|
1141
1201
|
try {
|
|
1142
1202
|
await lifecycle?.onSettled?.();
|
|
@@ -1145,6 +1205,7 @@ export async function startLoginFlow(
|
|
|
1145
1205
|
// runtime config. For an already-failed login, keep the original recovery error.
|
|
1146
1206
|
if (finalError === undefined) finalError = settleError;
|
|
1147
1207
|
}
|
|
1208
|
+
if (abandonIfNotOwner(finalError)) return;
|
|
1148
1209
|
if (finalError === undefined) {
|
|
1149
1210
|
loginAbort.delete(provider);
|
|
1150
1211
|
clearManualCodeSlot(provider);
|
|
@@ -1158,22 +1219,28 @@ export async function startLoginFlow(
|
|
|
1158
1219
|
const e = finalError;
|
|
1159
1220
|
loginAbort.delete(provider);
|
|
1160
1221
|
clearManualCodeSlot(provider);
|
|
1161
|
-
const msg =
|
|
1222
|
+
const msg = publicOAuthAuthenticationErrorMessage(e);
|
|
1162
1223
|
loginState.set(provider, { done: true, error: msg });
|
|
1163
1224
|
if (!urlResolved) reject(e);
|
|
1164
1225
|
};
|
|
1165
1226
|
// Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
|
|
1166
1227
|
// lets a long-lived server config adopt that settled state before clients observe done=true.
|
|
1167
|
-
|
|
1228
|
+
const assertCurrentOwner = (): void => {
|
|
1229
|
+
if (loginAbort.get(provider) !== abort) throw new OAuthLoginSupersededError();
|
|
1230
|
+
};
|
|
1231
|
+
void runLogin(provider, ctrl, opts, { assertCurrentOwner }).then(
|
|
1168
1232
|
() => settle(),
|
|
1169
1233
|
(e: unknown) => settle(e),
|
|
1170
1234
|
).catch((e: unknown) => {
|
|
1171
1235
|
// settle catches lifecycle failures, so this is only a defensive promise-boundary guard.
|
|
1236
|
+
if (abandonIfNotOwner(e)) return;
|
|
1172
1237
|
loginAbort.delete(provider);
|
|
1173
1238
|
clearManualCodeSlot(provider);
|
|
1174
|
-
const msg =
|
|
1239
|
+
const msg = publicOAuthAuthenticationErrorMessage(e);
|
|
1175
1240
|
loginState.set(provider, { done: true, error: msg });
|
|
1176
1241
|
if (!urlResolved) reject(e);
|
|
1242
|
+
}).finally(() => {
|
|
1243
|
+
if (provider === "kiro") kiroLoginSettling.delete(provider);
|
|
1177
1244
|
});
|
|
1178
1245
|
});
|
|
1179
1246
|
}
|
package/src/oauth/log.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/oauth/log.ts
|
|
2
2
|
import { maskAccountId } from "../lib/privacy";
|
|
3
|
+
import { redactSecretString } from "../lib/redact";
|
|
3
4
|
|
|
4
5
|
/** Normalize camelCase / snake_case / kebab-case field names before secret checks. */
|
|
5
6
|
function normalizeFieldKey(key: string): string {
|
|
@@ -20,6 +21,7 @@ const FORBIDDEN_NORMALIZED = new Set([
|
|
|
20
21
|
"id_token",
|
|
21
22
|
"client_secret",
|
|
22
23
|
"oauth_code",
|
|
24
|
+
"code_verifier",
|
|
23
25
|
"clientsecret",
|
|
24
26
|
]);
|
|
25
27
|
|
|
@@ -44,5 +46,5 @@ export function logOAuthEvent(
|
|
|
44
46
|
if (value === undefined) continue;
|
|
45
47
|
parts.push(`${key}=${String(value)}`);
|
|
46
48
|
}
|
|
47
|
-
console.info(parts.join(" "));
|
|
49
|
+
console.info(redactSecretString(parts.join(" ")));
|
|
48
50
|
}
|
package/src/oauth/store.ts
CHANGED
|
@@ -272,6 +272,17 @@ function newAccountId(cred: OAuthCredentials): string {
|
|
|
272
272
|
return createHash("sha256").update(identity).digest("hex").slice(0, 8);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/** Allocate a persisted slot id without reusing any existing account's ownership key. */
|
|
276
|
+
function distinctAccountId(cred: OAuthCredentials, accounts: readonly ProviderAccount[]): string {
|
|
277
|
+
const base = newAccountId(cred);
|
|
278
|
+
const occupied = new Set(accounts.map(account => account.id));
|
|
279
|
+
if (!occupied.has(base)) return base;
|
|
280
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
281
|
+
const candidate = `${base}-${suffix}`;
|
|
282
|
+
if (!occupied.has(candidate)) return candidate;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
275
286
|
function normalizeAccount(value: unknown): ProviderAccount | null {
|
|
276
287
|
if (!value || typeof value !== "object") return null;
|
|
277
288
|
const candidate = value as Partial<ProviderAccount>;
|
|
@@ -425,10 +436,11 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
|
|
|
425
436
|
drainOAuthMutations();
|
|
426
437
|
return result;
|
|
427
438
|
}
|
|
428
|
-
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
439
|
+
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
429
440
|
const { store, hadLegacy } = loadAuthStoreInternal();
|
|
430
441
|
if (hadLegacy) backupLegacyOnce();
|
|
431
442
|
const result = await fn(store);
|
|
443
|
+
options?.assertBeforePersist?.();
|
|
432
444
|
persist(store);
|
|
433
445
|
return result;
|
|
434
446
|
}finally{guard.release();}}, retainedValues, options?.waitMs);
|
|
@@ -450,7 +462,7 @@ export function getCredential(provider: string): OAuthCredentials | null {
|
|
|
450
462
|
export async function saveCredential(
|
|
451
463
|
provider: string,
|
|
452
464
|
cred: OAuthCredentials,
|
|
453
|
-
opts: { preserveIdentityless?: boolean } = {},
|
|
465
|
+
opts: { preserveIdentityless?: boolean; assertBeforePersist?: () => void } = {},
|
|
454
466
|
): Promise<void> {
|
|
455
467
|
const safe = normalizeCredential(cred);
|
|
456
468
|
if (!safe) return;
|
|
@@ -503,22 +515,28 @@ export async function saveCredential(
|
|
|
503
515
|
delete active.needsReauth;
|
|
504
516
|
return;
|
|
505
517
|
}
|
|
506
|
-
const id =
|
|
518
|
+
const id = distinctAccountId(safe, set.accounts);
|
|
507
519
|
set.accounts.push({ id, credential: safe, addedAt: Date.now() });
|
|
508
520
|
set.activeAccountId = id;
|
|
509
521
|
return;
|
|
510
522
|
}
|
|
511
|
-
|
|
523
|
+
if (opts.preserveIdentityless) {
|
|
524
|
+
const id = distinctAccountId(safe, set.accounts);
|
|
525
|
+
set.accounts.push({ id, credential: safe, addedAt: Date.now() });
|
|
526
|
+
set.activeAccountId = id;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// No identity during a normal login: replace the active slot in place.
|
|
512
530
|
const active = set.accounts.find(a => a.id === set.activeAccountId);
|
|
513
531
|
if (active) {
|
|
514
532
|
active.credential = safe;
|
|
515
533
|
delete active.needsReauth;
|
|
516
534
|
} else {
|
|
517
|
-
const id =
|
|
535
|
+
const id = distinctAccountId(safe, set.accounts);
|
|
518
536
|
set.accounts.push({ id, credential: safe, addedAt: Date.now() });
|
|
519
537
|
set.activeAccountId = id;
|
|
520
538
|
}
|
|
521
|
-
}, [provider, safe]);
|
|
539
|
+
}, [provider, safe], { assertBeforePersist: opts.assertBeforePersist });
|
|
522
540
|
}
|
|
523
541
|
|
|
524
542
|
/** Remove the ACTIVE account; remaining accounts promote the first one. */
|
|
@@ -563,7 +581,12 @@ export function getAccountCredential(provider: string, accountId: string): OAuth
|
|
|
563
581
|
}
|
|
564
582
|
|
|
565
583
|
/** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */
|
|
566
|
-
export async function saveAccountCredential(
|
|
584
|
+
export async function saveAccountCredential(
|
|
585
|
+
provider: string,
|
|
586
|
+
accountId: string,
|
|
587
|
+
cred: OAuthCredentials,
|
|
588
|
+
opts: { assertBeforePersist?: () => void } = {},
|
|
589
|
+
): Promise<void> {
|
|
567
590
|
const safe = normalizeCredential(cred);
|
|
568
591
|
if (!safe) return;
|
|
569
592
|
await mutateStore(store => {
|
|
@@ -571,7 +594,7 @@ export async function saveAccountCredential(provider: string, accountId: string,
|
|
|
571
594
|
if (!account) return;
|
|
572
595
|
account.credential = safe;
|
|
573
596
|
delete account.needsReauth;
|
|
574
|
-
}, [provider, accountId, safe]);
|
|
597
|
+
}, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist });
|
|
575
598
|
}
|
|
576
599
|
|
|
577
600
|
export async function setActiveAccount(provider: string, accountId: string): Promise<boolean> {
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* "proactive", and the global switch (config.tokenGuardian.enabled) defaults OFF, so a default
|
|
12
12
|
* install adds zero ToS-detection surface. See devlog 260703_oauth-multi-account-refresh-and-tos.
|
|
13
13
|
*/
|
|
14
|
-
import { loadConfig } from "../config";
|
|
14
|
+
import { loadConfig, mutatePersistedConfig } from "../config";
|
|
15
15
|
import type { OcxConfig, OcxTokenGuardianConfig } from "../types";
|
|
16
16
|
import { listAccounts } from "./store";
|
|
17
17
|
import { getValidAccessTokenForAccount, listOAuthProviders, OAuthLoginRequiredError, resolveRefreshPolicy } from "./index";
|
|
18
18
|
import {
|
|
19
19
|
getValidCodexToken,
|
|
20
|
+
isCodexAccountGenerationLive,
|
|
20
21
|
listCodexAccountIds,
|
|
21
22
|
markCodexAccountValidated,
|
|
22
23
|
markCodexAccountValidationFailed,
|
|
@@ -25,11 +26,14 @@ import {
|
|
|
25
26
|
CodexCredentialRefreshBusyError,
|
|
26
27
|
CodexCredentialRefreshStaleError,
|
|
27
28
|
} from "../codex/account-store";
|
|
28
|
-
import { codexWarmupFailureReason, warmCodexAccount } from "../codex/warmup";
|
|
29
|
+
import { CodexWarmupError, codexWarmupFailureReason, isCodexWarmupQuotaFailure, warmCodexAccount } from "../codex/warmup";
|
|
29
30
|
import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
30
31
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
31
32
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
32
33
|
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
34
|
+
import { setCodexAccountPaused } from "../codex/account-pause";
|
|
35
|
+
import { reconcileLiveStateStores } from "../lib/state-store-registrations";
|
|
36
|
+
import { markAccountNeedsReauth } from "../codex/account-runtime-state";
|
|
33
37
|
|
|
34
38
|
export interface TokenGuardianHandle {
|
|
35
39
|
stop(): void;
|
|
@@ -63,12 +67,44 @@ interface BackoffEntry {
|
|
|
63
67
|
const backoff = new Map<string, BackoffEntry>();
|
|
64
68
|
let lastReconciledGeneration = 0;
|
|
65
69
|
let liveBackoffKeys = new Set<string>();
|
|
70
|
+
let wakeQuotaRetryLoop: (() => void) | null = null;
|
|
71
|
+
const MIN_QUOTA_RETRY_TIMER_MS = 1_000;
|
|
66
72
|
|
|
67
73
|
/** Test hook: clear backoff state between cases. */
|
|
68
74
|
export function __resetGuardianState(): void {
|
|
69
75
|
backoff.clear();
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
function quotaPendingSweepEligible(config: OcxConfig): boolean {
|
|
79
|
+
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
80
|
+
return openai?.disabled !== true
|
|
81
|
+
&& openai !== undefined
|
|
82
|
+
&& isCanonicalOpenAiForwardProvider(openai)
|
|
83
|
+
&& (providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) ?? "pool") === "pool";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function nextPendingQuotaRetryDelayMs(config: OcxConfig, nowMs = Date.now()): number | undefined {
|
|
87
|
+
if (!quotaPendingSweepEligible(config)) return undefined;
|
|
88
|
+
let earliest: number | undefined;
|
|
89
|
+
for (const id of listCodexAccountIds()) {
|
|
90
|
+
const record = readCodexAccountRecord(id);
|
|
91
|
+
if (record?.lastCodexValidationStatus !== "quota_pending" || !record.credential) continue;
|
|
92
|
+
const retryAt = Math.max(
|
|
93
|
+
record.codexQuotaRetryAt ?? nowMs,
|
|
94
|
+
backoff.get(`codex:${id}`)?.retryAfterMs ?? nowMs,
|
|
95
|
+
);
|
|
96
|
+
if (earliest === undefined || retryAt < earliest) earliest = retryAt;
|
|
97
|
+
}
|
|
98
|
+
return earliest === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: Math.max(MIN_QUOTA_RETRY_TIMER_MS, earliest - nowMs);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Wake the background loop after registration creates a new quota-pending account. */
|
|
104
|
+
export function requestCodexQuotaWarmupRetry(): void {
|
|
105
|
+
wakeQuotaRetryLoop?.();
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
function num(value: number | undefined, fallback: number, min: number): number {
|
|
73
109
|
return typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
|
|
74
110
|
}
|
|
@@ -133,12 +169,77 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
133
169
|
const config: OcxConfig = loadConfig();
|
|
134
170
|
const g = config.tokenGuardian;
|
|
135
171
|
const result: GuardianSweepResult = { enabled: !!g?.enabled, refreshed: [], warmed: [], failed: [], skippedBackoff: [] };
|
|
136
|
-
if (!g?.enabled) return result;
|
|
137
|
-
|
|
138
172
|
const opts = resolved(g);
|
|
139
173
|
const horizonMs = (opts.tickSeconds + opts.leadSeconds) * 1000;
|
|
140
174
|
const tasks: Array<() => Promise<void>> = [];
|
|
141
175
|
|
|
176
|
+
// Registration-time quota deferrals are a safety invariant, not an optional keep-alive
|
|
177
|
+
// feature. Retry them after their freshly observed WHAM reset even when Token Guardian's
|
|
178
|
+
// user-facing refresh/warmup switches are off.
|
|
179
|
+
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
180
|
+
if (quotaPendingSweepEligible(config)) {
|
|
181
|
+
for (const id of listCodexAccountIds()) {
|
|
182
|
+
const record = readCodexAccountRecord(id);
|
|
183
|
+
if (record?.lastCodexValidationStatus !== "quota_pending" || !record.credential) continue;
|
|
184
|
+
if (record.codexQuotaRetryAt !== undefined && record.codexQuotaRetryAt > nowMs) continue;
|
|
185
|
+
const key = `codex:${id}`;
|
|
186
|
+
if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
|
|
187
|
+
tasks.push(async () => {
|
|
188
|
+
let attemptedGeneration = record.generation;
|
|
189
|
+
try {
|
|
190
|
+
const token = await getValidCodexToken(id);
|
|
191
|
+
attemptedGeneration = token.generation;
|
|
192
|
+
await warmCodexAccount({
|
|
193
|
+
accessToken: token.accessToken,
|
|
194
|
+
chatgptAccountId: token.chatgptAccountId,
|
|
195
|
+
model: opts.codexWarmupModel,
|
|
196
|
+
});
|
|
197
|
+
if (!isCodexAccountGenerationLive(id, token.generation)) return;
|
|
198
|
+
if (readCodexAccountRecord(id)?.codexQuotaPauseOwned === true) {
|
|
199
|
+
const outcome = mutatePersistedConfig(persisted => {
|
|
200
|
+
const current = readCodexAccountRecord(id);
|
|
201
|
+
if (!current || current.generation !== token.generation || current.codexQuotaPauseOwned !== true) {
|
|
202
|
+
return { changed: false, value: false };
|
|
203
|
+
}
|
|
204
|
+
const wasPaused = persisted.pausedCodexAccountIds?.includes(id) ?? false;
|
|
205
|
+
setCodexAccountPaused(persisted, id, false);
|
|
206
|
+
return { changed: wasPaused, value: true };
|
|
207
|
+
});
|
|
208
|
+
if (outcome.status === "unavailable" || outcome.value !== true) {
|
|
209
|
+
throw new Error("Codex quota-deferred pause could not be released");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!markCodexAccountValidated(id, Date.now(), token.generation)) {
|
|
213
|
+
throw new Error("Codex account changed while quota validation completed");
|
|
214
|
+
}
|
|
215
|
+
reconcileLiveStateStores();
|
|
216
|
+
backoff.delete(key);
|
|
217
|
+
result.warmed.push(key);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const terminalWarmupFailure = err instanceof CodexWarmupError
|
|
220
|
+
&& err.code === "http_status"
|
|
221
|
+
&& (err.status === 401 || err.status === 403)
|
|
222
|
+
&& !isCodexWarmupQuotaFailure(err);
|
|
223
|
+
const terminalTokenFailure = err instanceof TokenRefreshError
|
|
224
|
+
&& (err.reason === "revoked" || err.reason === "expired");
|
|
225
|
+
if (terminalWarmupFailure || terminalTokenFailure) {
|
|
226
|
+
if (markCodexAccountValidationFailed(id, codexWarmupFailureReason(err), attemptedGeneration)) {
|
|
227
|
+
markAccountNeedsReauth(id, writerGeneration);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const permanent = terminalWarmupFailure || terminalTokenFailure;
|
|
231
|
+
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent, writerGeneration);
|
|
232
|
+
result.failed.push(key);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!g?.enabled) {
|
|
239
|
+
await runWithConcurrency(tasks, opts.concurrency);
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
142
243
|
// A) OAuth providers — every account in each provider's set (multiauth keep-alive),
|
|
143
244
|
// skipping accounts already marked needsReauth (terminal; only a re-login fixes them).
|
|
144
245
|
for (const provider of listOAuthProviders()) {
|
|
@@ -166,14 +267,13 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
166
267
|
|
|
167
268
|
// B) canonical OpenAI Codex login. Direct warms main only; pool additionally maintains every
|
|
168
269
|
// added account. The direct branch returns before the managed account store is enumerated.
|
|
169
|
-
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
170
270
|
if (
|
|
171
271
|
openai
|
|
172
272
|
&& openai.disabled !== true
|
|
173
273
|
&& isCanonicalOpenAiForwardProvider(openai)
|
|
174
274
|
&& resolveRefreshPolicy(OPENAI_CODEX_PROVIDER_ID, config) === "proactive"
|
|
175
275
|
) {
|
|
176
|
-
const
|
|
276
|
+
const configuredMode = providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) ?? "pool";
|
|
177
277
|
if (opts.codexWarmupEnabled) {
|
|
178
278
|
const mainToken = getMainAccountToken();
|
|
179
279
|
const key = `codex:${MAIN_CODEX_ACCOUNT_ID}`;
|
|
@@ -197,10 +297,11 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
197
297
|
}
|
|
198
298
|
}
|
|
199
299
|
|
|
200
|
-
const addedAccountIds =
|
|
300
|
+
const addedAccountIds = configuredMode === "pool" ? listCodexAccountIds() : [];
|
|
201
301
|
for (const id of addedAccountIds) {
|
|
202
302
|
const record = readCodexAccountRecord(id);
|
|
203
303
|
if (!record || record.deletedAt != null) continue;
|
|
304
|
+
if (record.lastCodexValidationStatus === "quota_pending") continue;
|
|
204
305
|
const cred = record.credential;
|
|
205
306
|
if (!cred) continue;
|
|
206
307
|
const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
|
|
@@ -273,16 +374,24 @@ export function reconcileGuardianBackoff(context: GenerationContext): number {
|
|
|
273
374
|
export function startTokenGuardian(): TokenGuardianHandle {
|
|
274
375
|
let stopped = false;
|
|
275
376
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
377
|
+
let sweepRunning = false;
|
|
276
378
|
|
|
277
379
|
const scheduleNext = () => {
|
|
278
380
|
if (stopped) return;
|
|
279
381
|
const opts = resolved(loadConfig().tokenGuardian);
|
|
280
|
-
const
|
|
382
|
+
const regularDelayMs = (opts.tickSeconds + Math.random() * opts.jitterSeconds) * 1000;
|
|
383
|
+
const pendingDelayMs = nextPendingQuotaRetryDelayMs(loadConfig());
|
|
384
|
+
const delayMs = pendingDelayMs === undefined
|
|
385
|
+
? regularDelayMs
|
|
386
|
+
: Math.min(regularDelayMs, pendingDelayMs);
|
|
281
387
|
timer = setTimeout(runSweep, delayMs);
|
|
282
388
|
if (typeof timer.unref === "function") timer.unref(); // never keep the process alive for a sweep
|
|
283
389
|
};
|
|
284
390
|
|
|
285
391
|
const runSweep = () => {
|
|
392
|
+
timer = undefined;
|
|
393
|
+
if (stopped || sweepRunning) return;
|
|
394
|
+
sweepRunning = true;
|
|
286
395
|
void guardianSweep()
|
|
287
396
|
.then(r => {
|
|
288
397
|
if (r.enabled && (r.refreshed.length || r.warmed.length || r.failed.length)) {
|
|
@@ -290,14 +399,26 @@ export function startTokenGuardian(): TokenGuardianHandle {
|
|
|
290
399
|
}
|
|
291
400
|
})
|
|
292
401
|
.catch(err => console.log(`token-guardian sweep error: ${err instanceof Error ? err.message : String(err)}`))
|
|
293
|
-
.finally(
|
|
402
|
+
.finally(() => {
|
|
403
|
+
sweepRunning = false;
|
|
404
|
+
scheduleNext();
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const wake = () => {
|
|
409
|
+
if (stopped || sweepRunning) return;
|
|
410
|
+
if (timer) clearTimeout(timer);
|
|
411
|
+
timer = setTimeout(runSweep, 0);
|
|
412
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
294
413
|
};
|
|
295
414
|
|
|
415
|
+
wakeQuotaRetryLoop = wake;
|
|
296
416
|
scheduleNext();
|
|
297
417
|
return {
|
|
298
418
|
stop() {
|
|
299
419
|
stopped = true;
|
|
300
420
|
if (timer) clearTimeout(timer);
|
|
421
|
+
if (wakeQuotaRetryLoop === wake) wakeQuotaRetryLoop = null;
|
|
301
422
|
},
|
|
302
423
|
};
|
|
303
424
|
}
|