@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830
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-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DPl4nBMA.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DNdRKXK9.js +0 -112
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
|
@@ -218,6 +218,97 @@ export function saveCodexAccountCredentialIfGeneration(
|
|
|
218
218
|
});
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Commit a refreshed credential to its owner AND to any record that is provably an untouched
|
|
223
|
+
* duplicate of the pre-refresh credential (#2892 gap 3).
|
|
224
|
+
*
|
|
225
|
+
* A refresh normally rotates the refresh token, and the owner CAS above changes only the owner's
|
|
226
|
+
* record. A second non-deleted record holding the same grant that is not participating in the
|
|
227
|
+
* flight therefore keeps a refresh token upstream has just rotated away. Its next refresh sends a
|
|
228
|
+
* dead grant, and `invalid_grant` classifies as `revoked` — retiring a healthy account because we
|
|
229
|
+
* rotated its grant and never told it.
|
|
230
|
+
*
|
|
231
|
+
* Eligibility is deliberately narrow, and each condition earns its place:
|
|
232
|
+
*
|
|
233
|
+
* - Same pre-refresh grant fingerprint, access token, AND expiry. Anything else means the alias was
|
|
234
|
+
* updated concurrently, and repairing only its grant while keeping its own access token would
|
|
235
|
+
* advance a generation without advancing the access-token JWT. `plan-from-token` reads a higher
|
|
236
|
+
* generation as proof of a newer JWT (that is how JWT plan claims supersede a WHAM observation),
|
|
237
|
+
* so that combination lets a stale JWT overwrite an authoritative plan. It would also hand a live
|
|
238
|
+
* forced-refresh joiner back its own 401-rejected bearer: flights are keyed by grant and do not
|
|
239
|
+
* record participants, so a scan cannot tell a dormant alias from a joiner, and the recursion's
|
|
240
|
+
* freshness shortcut does not re-compare against the rejected token.
|
|
241
|
+
* - Same `chatgptAccountId` as the owner. A fingerprint is `sha256` of the refresh token and
|
|
242
|
+
* carries no identity claim; no invariant here guarantees one grant cannot span two account ids,
|
|
243
|
+
* so identity is compared rather than assumed.
|
|
244
|
+
*
|
|
245
|
+
* The rotated access token, refresh token, and expiry move together, keeping a generation bump
|
|
246
|
+
* meaning what every fence already assumes. `replacedAt` and the validation metadata survive
|
|
247
|
+
* because the probe-lease settlement check accepts only an intact `G → G+1` lineage.
|
|
248
|
+
*
|
|
249
|
+
* One lock acquisition and one `persist` for the owner and every alias: `persist` writes the whole
|
|
250
|
+
* store, so a second pass would open a window in which some records hold the dead grant.
|
|
251
|
+
*/
|
|
252
|
+
export function commitRefreshedCodexCredentialWithAliases(
|
|
253
|
+
id: string,
|
|
254
|
+
generation: number,
|
|
255
|
+
cred: CodexAccountCredentials,
|
|
256
|
+
): { committed: boolean; propagatedAliases: { id: string; generation: number }[] } {
|
|
257
|
+
return withCredentialMutationLockSync(() => {
|
|
258
|
+
const store = loadCodexAccountRecordStore();
|
|
259
|
+
const current = store[id];
|
|
260
|
+
if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
|
|
261
|
+
return { committed: false, propagatedAliases: [] };
|
|
262
|
+
}
|
|
263
|
+
const priorCredential = current.credential;
|
|
264
|
+
const priorFingerprint = recordGrantFingerprint(current);
|
|
265
|
+
const refreshGrantFingerprint = priorCredential.refreshToken === cred.refreshToken
|
|
266
|
+
? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
|
|
267
|
+
: refreshGrantFingerprintForToken(cred.refreshToken);
|
|
268
|
+
store[id] = {
|
|
269
|
+
credential: cred,
|
|
270
|
+
generation: generation + 1,
|
|
271
|
+
refreshGrantFingerprint,
|
|
272
|
+
replacedAt: current.replacedAt,
|
|
273
|
+
...preservedValidationMetadata(current),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Each alias carries its OWN committed generation: aliases need not share one, and the plan
|
|
277
|
+
// reconciliation below is generation-fenced, so an id alone would be reconciled at the wrong fence.
|
|
278
|
+
const propagatedAliases: { id: string; generation: number }[] = [];
|
|
279
|
+
// Nothing to propagate when the grant did not actually rotate: the aliases already hold it.
|
|
280
|
+
// An absent owner identity fails closed: two empty strings compare equal but prove nothing about
|
|
281
|
+
// which upstream account either record was meant to use, and a matching bearer snapshot only
|
|
282
|
+
// shows they copied the same token once. Leave those dormant records alone.
|
|
283
|
+
if (
|
|
284
|
+
priorFingerprint !== undefined
|
|
285
|
+
&& priorCredential.refreshToken !== cred.refreshToken
|
|
286
|
+
&& !!priorCredential.chatgptAccountId
|
|
287
|
+
) {
|
|
288
|
+
for (const [aliasId, alias] of Object.entries(store)) {
|
|
289
|
+
if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
|
|
290
|
+
if (recordGrantFingerprint(alias) !== priorFingerprint) continue;
|
|
291
|
+
if (alias.credential.accessToken !== priorCredential.accessToken) continue;
|
|
292
|
+
if (alias.credential.expiresAt !== priorCredential.expiresAt) continue;
|
|
293
|
+
if (!alias.credential.chatgptAccountId) continue;
|
|
294
|
+
if (alias.credential.chatgptAccountId !== priorCredential.chatgptAccountId) continue;
|
|
295
|
+
const aliasGeneration = alias.generation + 1;
|
|
296
|
+
store[aliasId] = {
|
|
297
|
+
// The alias keeps its OWN chatgptAccountId value, which the guard above proved equal.
|
|
298
|
+
credential: { ...cred, chatgptAccountId: alias.credential.chatgptAccountId },
|
|
299
|
+
generation: aliasGeneration,
|
|
300
|
+
refreshGrantFingerprint,
|
|
301
|
+
replacedAt: alias.replacedAt,
|
|
302
|
+
...preservedValidationMetadata(alias),
|
|
303
|
+
};
|
|
304
|
+
propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
persist(store);
|
|
308
|
+
return { committed: true, propagatedAliases };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
221
312
|
export function tombstoneCodexAccount(id: string): number {
|
|
222
313
|
return withCredentialMutationLockSync(() => {
|
|
223
314
|
const store = loadCodexAccountRecordStore();
|
|
@@ -286,7 +377,31 @@ function withCredentialMutationLockSync<T>(fn: () => T): T {
|
|
|
286
377
|
}
|
|
287
378
|
|
|
288
379
|
type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
|
|
289
|
-
type CodexRefreshResult = CodexTokenResult & {
|
|
380
|
+
type CodexRefreshResult = CodexTokenResult & {
|
|
381
|
+
credential?: CodexAccountCredentials;
|
|
382
|
+
/**
|
|
383
|
+
* Records that adopted this refresh's rotated credential through same-grant propagation, each
|
|
384
|
+
* with its own committed generation (#2892 gap 3). Carried on the result so the flight settles
|
|
385
|
+
* every plan in one place rather than the commit doing its own (#2933).
|
|
386
|
+
*/
|
|
387
|
+
propagatedAliases?: { id: string; generation: number }[];
|
|
388
|
+
/**
|
|
389
|
+
* Grant the returned credential actually belongs to.
|
|
390
|
+
*
|
|
391
|
+
* Flights are keyed by refresh grant and shared across every account holding that
|
|
392
|
+
* grant, but a flight can resolve to a credential from a DIFFERENT grant: the
|
|
393
|
+
* owner's credential may be externally replaced while it waits for the file lock,
|
|
394
|
+
* and the grant-mismatch branch then hands back that replacement. A joiner that
|
|
395
|
+
* only checks its own current grant would CAS-write another account's access and
|
|
396
|
+
* refresh tokens onto itself. The result therefore carries its own provenance.
|
|
397
|
+
*/
|
|
398
|
+
resolvedGrantFingerprint?: string;
|
|
399
|
+
/**
|
|
400
|
+
* True when this call's own CAS write produced `generation` — the credential is a
|
|
401
|
+
* refresh of the one the caller was holding, not somebody else's replacement.
|
|
402
|
+
*/
|
|
403
|
+
selfRefreshed?: boolean;
|
|
404
|
+
};
|
|
290
405
|
const MAX_CODEX_REFRESH_FLIGHTS = 32;
|
|
291
406
|
const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
|
|
292
407
|
interface RefreshFlight {
|
|
@@ -331,7 +446,7 @@ function isRefreshLockStale(path: string): boolean {
|
|
|
331
446
|
}
|
|
332
447
|
}
|
|
333
448
|
|
|
334
|
-
async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
|
|
449
|
+
export async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
|
|
335
450
|
hardenConfigDir();
|
|
336
451
|
const dir = getConfigDir();
|
|
337
452
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -375,12 +490,25 @@ async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal,
|
|
|
375
490
|
function findFreshCredentialForGrant(
|
|
376
491
|
refreshGrantFingerprint: string,
|
|
377
492
|
excludeId: string,
|
|
493
|
+
rejectedAccessToken?: string,
|
|
494
|
+
expectedChatgptAccountId?: string,
|
|
378
495
|
): CodexAccountCredentials | null {
|
|
379
496
|
const now = Date.now();
|
|
380
497
|
const records = loadCodexAccountRecordStore();
|
|
498
|
+
// Adoption copies another record's access AND refresh tokens onto the caller, so the two records
|
|
499
|
+
// must be the same upstream identity. A grant fingerprint is `sha256` of the refresh token and
|
|
500
|
+
// carries no identity claim, and nothing here guarantees one grant cannot span two accounts, so
|
|
501
|
+
// require both ids to be present and exactly equal rather than inferring identity from the grant.
|
|
502
|
+
if (!expectedChatgptAccountId) return null;
|
|
381
503
|
for (const [candidateId, candidate] of Object.entries(records)) {
|
|
382
504
|
if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue;
|
|
383
505
|
if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
|
|
506
|
+
if (!candidate.credential.chatgptAccountId) continue;
|
|
507
|
+
if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue;
|
|
508
|
+
// A sibling alias can hold a still-unexpired copy of the exact token upstream
|
|
509
|
+
// just rejected. Reusing it would bump the generation and replay the identical
|
|
510
|
+
// bearer — a second 401 dressed up as recovery.
|
|
511
|
+
if (rejectedAccessToken !== undefined && candidate.credential.accessToken === rejectedAccessToken) continue;
|
|
384
512
|
if (candidate.credential.expiresAt > now + REFRESH_SKEW_MS) return candidate.credential;
|
|
385
513
|
}
|
|
386
514
|
return null;
|
|
@@ -399,14 +527,106 @@ async function notePlanFromRefreshedAccessToken(
|
|
|
399
527
|
}
|
|
400
528
|
}
|
|
401
529
|
|
|
530
|
+
/**
|
|
531
|
+
* A forced refresh raised by a rejected bearer. Carries the generation the 401 was
|
|
532
|
+
* observed under so a credential someone else already replaced is never refreshed
|
|
533
|
+
* again, and the rejected token so a sibling alias holding that same token cannot
|
|
534
|
+
* satisfy the refresh.
|
|
535
|
+
*/
|
|
536
|
+
type ForcedRefreshFence = { rejectedGeneration: number; rejectedAccessToken: string };
|
|
537
|
+
|
|
538
|
+
/** True once the stored credential has moved off the generation the 401 belongs to. */
|
|
539
|
+
function forcedFenceSuperseded(recordGeneration: number, forced: ForcedRefreshFence | undefined): boolean {
|
|
540
|
+
return forced !== undefined && recordGeneration !== forced.rejectedGeneration;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Wait for a SHARED promise while honoring only the calling request's cancellation.
|
|
545
|
+
*
|
|
546
|
+
* The awaited work is not the caller's to cancel — other requests are waiting on the
|
|
547
|
+
* same promise — so an aborted caller stops waiting and the work continues to
|
|
548
|
+
* completion for them (#2892 gap 2). The rejection handler prevents an unhandled
|
|
549
|
+
* rejection from the promise this caller walked away from.
|
|
550
|
+
*/
|
|
551
|
+
function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal): Promise<T> {
|
|
552
|
+
if (!callerSignal) return work;
|
|
553
|
+
if (callerSignal.aborted) {
|
|
554
|
+
work.catch(() => {});
|
|
555
|
+
return Promise.reject(callerSignal.reason);
|
|
556
|
+
}
|
|
557
|
+
return new Promise<T>((resolve, reject) => {
|
|
558
|
+
const onAbort = (): void => {
|
|
559
|
+
work.catch(() => {});
|
|
560
|
+
reject(callerSignal.reason);
|
|
561
|
+
};
|
|
562
|
+
callerSignal.addEventListener("abort", onAbort, { once: true });
|
|
563
|
+
work.then(
|
|
564
|
+
value => { callerSignal.removeEventListener("abort", onAbort); resolve(value); },
|
|
565
|
+
err => { callerSignal.removeEventListener("abort", onAbort); reject(err); },
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Refresh a stored pool credential that upstream rejected with a 401, even though its
|
|
572
|
+
* `expiresAt` still looks valid. Ordinary callers must keep using
|
|
573
|
+
* {@link getValidCodexToken}: only a proven rejection justifies spending a refresh.
|
|
574
|
+
*
|
|
575
|
+
* `rotated` is false when the resolved token is byte-identical to the rejected one,
|
|
576
|
+
* which means replaying would earn the same 401 and the caller must not try. That can
|
|
577
|
+
* happen even on a SUCCESSFUL token response: upstream may rotate the refresh grant
|
|
578
|
+
* while returning the same access token. The generation has moved by then, so
|
|
579
|
+
* `generation` reports where the credential actually is — a caller that quarantines
|
|
580
|
+
* on `rotated === false` must fence on the returned value, not on the one it rejected.
|
|
581
|
+
*/
|
|
582
|
+
export async function forceRefreshCodexPoolToken(
|
|
583
|
+
id: string,
|
|
584
|
+
options: { rejectedGeneration: number; rejectedAccessToken: string; signal?: AbortSignal },
|
|
585
|
+
): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean }> {
|
|
586
|
+
const result = await resolveCodexToken(
|
|
587
|
+
id,
|
|
588
|
+
{ rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
|
|
589
|
+
options.signal,
|
|
590
|
+
);
|
|
591
|
+
return {
|
|
592
|
+
accessToken: result.accessToken,
|
|
593
|
+
chatgptAccountId: result.chatgptAccountId,
|
|
594
|
+
generation: result.generation,
|
|
595
|
+
rotated: result.accessToken !== options.rejectedAccessToken,
|
|
596
|
+
// Only a CAS this call performed itself proves the new credential descends from the
|
|
597
|
+
// rejected one; anything else is somebody else's replacement and must not be treated
|
|
598
|
+
// as this request's own lineage.
|
|
599
|
+
selfRefreshed: result.selfRefreshed === true,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
402
603
|
export async function getValidCodexToken(id: string): Promise<CodexTokenResult> {
|
|
604
|
+
const result = await resolveCodexToken(id);
|
|
605
|
+
return {
|
|
606
|
+
accessToken: result.accessToken,
|
|
607
|
+
chatgptAccountId: result.chatgptAccountId,
|
|
608
|
+
generation: result.generation,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function resolveCodexToken(
|
|
613
|
+
id: string,
|
|
614
|
+
forced?: ForcedRefreshFence,
|
|
615
|
+
callerSignal?: AbortSignal,
|
|
616
|
+
): Promise<CodexRefreshResult> {
|
|
617
|
+
if (callerSignal?.aborted) throw callerSignal.reason;
|
|
403
618
|
const record = readCodexAccountRecord(id);
|
|
404
619
|
const cred = record?.deletedAt == null ? record?.credential : undefined;
|
|
405
620
|
if (!record || !cred) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
|
|
406
621
|
const refreshGrantFingerprint = recordGrantFingerprint(record);
|
|
407
622
|
if (!refreshGrantFingerprint) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
|
|
408
623
|
|
|
409
|
-
|
|
624
|
+
// The freshness shortcut is exactly what makes a 401 on a time-valid token
|
|
625
|
+
// unrecoverable, so a forced caller skips it — but only while the stored credential
|
|
626
|
+
// is still the one that was rejected. Once it has been replaced, the shortcut is
|
|
627
|
+
// correct again and refreshing would burn a rotation for nothing.
|
|
628
|
+
const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced);
|
|
629
|
+
if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) {
|
|
410
630
|
return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, generation: record.generation };
|
|
411
631
|
}
|
|
412
632
|
|
|
@@ -416,13 +636,70 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
|
|
|
416
636
|
existing.abort.abort(new CodexCredentialRefreshStaleError());
|
|
417
637
|
if (refreshLocks.get(refreshGrantFingerprint) === existing) refreshLocks.delete(refreshGrantFingerprint);
|
|
418
638
|
} else {
|
|
419
|
-
const refreshed = await existing.promise;
|
|
639
|
+
const refreshed = await awaitOwnCancellation(existing.promise, callerSignal);
|
|
420
640
|
const current = readCodexAccountRecord(id);
|
|
421
641
|
const currentCred = current?.deletedAt == null ? current?.credential : undefined;
|
|
642
|
+
// The flight owner already committed this credential, and it is the one stored
|
|
643
|
+
// for this account: adopt the stored state instead of CAS-writing the identical
|
|
644
|
+
// bytes, which would bump the generation a second time and invalidate the
|
|
645
|
+
// affinity handoff the owner performed against generation+1.
|
|
646
|
+
if (current && currentCred && refreshed.credential
|
|
647
|
+
&& currentCred.accessToken === refreshed.credential.accessToken
|
|
648
|
+
&& currentCred.refreshToken === refreshed.credential.refreshToken) {
|
|
649
|
+
// A forced caller must still not accept the bearer upstream rejected.
|
|
650
|
+
if (!(forced !== undefined && currentCred.accessToken === forced.rejectedAccessToken)) {
|
|
651
|
+
return {
|
|
652
|
+
accessToken: currentCred.accessToken,
|
|
653
|
+
chatgptAccountId: currentCred.chatgptAccountId,
|
|
654
|
+
generation: current.generation,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Flights are keyed by refresh grant, not by account or generation, so this
|
|
659
|
+
// credential may belong to a flight started for a different generation of the
|
|
660
|
+
// same grant. Writing it onto a replacement would undo that replacement.
|
|
661
|
+
//
|
|
662
|
+
// The rejected-token test comes FIRST: a joined flight that resolved back to the
|
|
663
|
+
// bearer upstream rejected proves nothing, and reporting the replacement as
|
|
664
|
+
// "superseded" would hand the caller a token it must not replay.
|
|
665
|
+
//
|
|
666
|
+
// Freshness is tested here too. Supersession says only that SOMEONE replaced the
|
|
667
|
+
// credential — not that what they wrote is usable. An expired G+1 satisfies the
|
|
668
|
+
// generation test and the rejected-bearer test while being certain to earn
|
|
669
|
+
// another 401, and because the caller treats this return as a successful
|
|
670
|
+
// recovery it spends its one replay on it (#2892 gap 1). A stale winner must
|
|
671
|
+
// fall through to a real refresh instead.
|
|
672
|
+
//
|
|
673
|
+
// Stated honestly: this guard is NOT covered by a red-proven test. Reaching this
|
|
674
|
+
// branch needs a live flight that RESOLVES, a stored credential differing from
|
|
675
|
+
// what the flight produced, and that stored credential expired — three attempted
|
|
676
|
+
// interleavings each landed elsewhere (own flight, first adopt-stored branch, or
|
|
677
|
+
// a CAS conflict that rejects for both callers). The guard is one comparison on a
|
|
678
|
+
// path that otherwise returns a known-dead token, and its only effect is to
|
|
679
|
+
// divert to the refresh the caller would have needed anyway.
|
|
680
|
+
if (
|
|
681
|
+
current && currentCred
|
|
682
|
+
&& forcedFenceSuperseded(current.generation, forced)
|
|
683
|
+
&& currentCred.expiresAt > Date.now() + REFRESH_SKEW_MS
|
|
684
|
+
&& !(forced !== undefined && currentCred.accessToken === forced.rejectedAccessToken)
|
|
685
|
+
) {
|
|
686
|
+
return {
|
|
687
|
+
accessToken: currentCred.accessToken,
|
|
688
|
+
chatgptAccountId: currentCred.chatgptAccountId,
|
|
689
|
+
generation: current.generation,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
422
692
|
if (
|
|
423
693
|
current &&
|
|
424
694
|
currentCred &&
|
|
425
695
|
refreshed.credential &&
|
|
696
|
+
// Provenance: a flight can resolve to a credential from a DIFFERENT grant when
|
|
697
|
+
// the owner's own credential was replaced while it waited for the lock. Adopting
|
|
698
|
+
// that would copy another account's access and refresh tokens onto this one.
|
|
699
|
+
refreshed.resolvedGrantFingerprint === refreshGrantFingerprint &&
|
|
700
|
+
// A joined flight that resolved to the rejected token proves nothing; fall
|
|
701
|
+
// through and open a real refresh instead of bumping the generation.
|
|
702
|
+
!(forced !== undefined && refreshed.credential.accessToken === forced.rejectedAccessToken) &&
|
|
426
703
|
recordGrantFingerprint(current) === refreshGrantFingerprint
|
|
427
704
|
) {
|
|
428
705
|
if (!saveCodexAccountCredentialIfGeneration(id, current.generation, refreshed.credential)) {
|
|
@@ -434,18 +711,39 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
|
|
|
434
711
|
accessToken: refreshed.credential.accessToken,
|
|
435
712
|
chatgptAccountId: refreshed.credential.chatgptAccountId,
|
|
436
713
|
generation,
|
|
714
|
+
// This joiner performed its own CAS onto its own record, so the resulting
|
|
715
|
+
// generation is its own lineage even though another caller drove the fetch.
|
|
716
|
+
selfRefreshed: true,
|
|
717
|
+
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
437
718
|
};
|
|
438
719
|
}
|
|
439
|
-
return
|
|
720
|
+
return resolveCodexToken(id, forced, callerSignal);
|
|
440
721
|
}
|
|
441
722
|
}
|
|
442
723
|
|
|
443
724
|
if (refreshLocks.size >= MAX_CODEX_REFRESH_FLIGHTS) throw new CodexCredentialRefreshBusyError();
|
|
444
725
|
|
|
726
|
+
/*
|
|
727
|
+
* The flight's lifetime belongs to the FLIGHT, not to whichever caller happened to
|
|
728
|
+
* open it (#2892 gap 2).
|
|
729
|
+
*
|
|
730
|
+
* Flights are shared: later callers on the same grant join `existing.promise` rather
|
|
731
|
+
* than starting their own. Folding `callerSignal` into the flight's signal therefore
|
|
732
|
+
* gave one arbitrary waiter the power to abort the token request out from under every
|
|
733
|
+
* other waiter — and the joiners have no way to distinguish that from a genuine
|
|
734
|
+
* upstream failure, so a cancelled Codex tab could retire a healthy account for a
|
|
735
|
+
* request that was still running.
|
|
736
|
+
*
|
|
737
|
+
* The initiating caller still gets cancellation: it is waiting on its own await, and
|
|
738
|
+
* `awaitOwnCancellation` below races its wait against its own signal. What it no
|
|
739
|
+
* longer gets is the ability to cancel work other callers depend on: the flight keeps
|
|
740
|
+
* running for the joiners, and its result is still committed. `abort` (stale-flight
|
|
741
|
+
* eviction) and the 30s ceiling remain, because those bound the flight itself.
|
|
742
|
+
*/
|
|
445
743
|
const abort = new AbortController();
|
|
446
744
|
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]);
|
|
447
745
|
let flight!: RefreshFlight;
|
|
448
|
-
const
|
|
746
|
+
const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
|
|
449
747
|
const current = readCodexAccountRecord(id);
|
|
450
748
|
const lockedRecord = readCodexAccountRecord(id);
|
|
451
749
|
const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
|
|
@@ -459,19 +757,35 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
|
|
|
459
757
|
chatgptAccountId: lockedCred.chatgptAccountId,
|
|
460
758
|
generation: startGeneration,
|
|
461
759
|
credential: lockedCred,
|
|
760
|
+
// This credential belongs to a DIFFERENT grant than the flight was opened
|
|
761
|
+
// for. Tagging it keeps a joiner from adopting it as its own.
|
|
762
|
+
...(lockedRefreshGrantFingerprint !== undefined
|
|
763
|
+
? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint }
|
|
764
|
+
: {}),
|
|
462
765
|
};
|
|
463
766
|
}
|
|
464
767
|
throw new CodexCredentialGenerationConflictError();
|
|
465
768
|
}
|
|
466
|
-
|
|
769
|
+
// Third fence point: waiting for the lock can take long enough for another
|
|
770
|
+
// writer to replace the credential. Under the lock the stored generation is
|
|
771
|
+
// authoritative, so a superseded forced refresh stops here rather than
|
|
772
|
+
// spending a rotation on a credential nobody rejected.
|
|
773
|
+
const forcedStillTargetsStored = forced !== undefined && !forcedFenceSuperseded(startGeneration, forced);
|
|
774
|
+
if (lockedCred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedStillTargetsStored) {
|
|
467
775
|
return {
|
|
468
776
|
accessToken: lockedCred.accessToken,
|
|
469
777
|
chatgptAccountId: lockedCred.chatgptAccountId,
|
|
470
778
|
generation: startGeneration,
|
|
471
779
|
credential: lockedCred,
|
|
780
|
+
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
472
781
|
};
|
|
473
782
|
}
|
|
474
|
-
const sameGrantFreshCredential = findFreshCredentialForGrant(
|
|
783
|
+
const sameGrantFreshCredential = findFreshCredentialForGrant(
|
|
784
|
+
refreshGrantFingerprint,
|
|
785
|
+
id,
|
|
786
|
+
forced?.rejectedAccessToken,
|
|
787
|
+
lockedCred.chatgptAccountId,
|
|
788
|
+
);
|
|
475
789
|
if (sameGrantFreshCredential) {
|
|
476
790
|
if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, sameGrantFreshCredential)) {
|
|
477
791
|
throw new CodexCredentialGenerationConflictError();
|
|
@@ -481,6 +795,8 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
|
|
|
481
795
|
chatgptAccountId: sameGrantFreshCredential.chatgptAccountId,
|
|
482
796
|
generation: startGeneration + 1,
|
|
483
797
|
credential: sameGrantFreshCredential,
|
|
798
|
+
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
799
|
+
selfRefreshed: true,
|
|
484
800
|
};
|
|
485
801
|
}
|
|
486
802
|
const res = await fetch(CHATGPT_TOKEN_URL, {
|
|
@@ -496,11 +812,22 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
|
|
|
496
812
|
if (!res.ok) {
|
|
497
813
|
const errText = await res.text().catch(() => "");
|
|
498
814
|
let errDesc: string;
|
|
815
|
+
let errCodeExact: string | undefined;
|
|
499
816
|
try {
|
|
500
817
|
const parsed = JSON.parse(errText) as { error?: string; error_description?: string };
|
|
818
|
+
errCodeExact = typeof parsed.error === "string" ? parsed.error.trim() : undefined;
|
|
501
819
|
errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${res.status}`;
|
|
502
820
|
} catch { errDesc = `HTTP ${res.status}`; }
|
|
503
|
-
|
|
821
|
+
// `invalid_grant` is the standard OAuth code for a refresh token that is no longer
|
|
822
|
+
// usable, and upstream sends it bare with no description. Without it here the dead
|
|
823
|
+
// grant is classified "unknown", which callers treat as transient — so the account
|
|
824
|
+
// is never retired and every request repeats the same doomed refresh (#2887).
|
|
825
|
+
//
|
|
826
|
+
// Matched on the exact `error` CODE, not anywhere in the combined text: a transient
|
|
827
|
+
// `server_error` whose description happens to mention invalid_grant would otherwise
|
|
828
|
+
// retire a healthy account, which is the failure this whole change exists to remove.
|
|
829
|
+
const reason = errCodeExact === "invalid_grant"
|
|
830
|
+
|| errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const
|
|
504
831
|
: errDesc.includes("expired") ? "expired" as const
|
|
505
832
|
: "unknown" as const;
|
|
506
833
|
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
|
|
@@ -524,21 +851,74 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
|
|
|
524
851
|
expiresAt: safeExpiresAt,
|
|
525
852
|
chatgptAccountId: lockedCred.chatgptAccountId,
|
|
526
853
|
};
|
|
527
|
-
|
|
854
|
+
// Commit to the owner and, in the same write, to any record that is still an untouched
|
|
855
|
+
// duplicate of the credential this flight started from (#2892 gap 3). Without this the rotated
|
|
856
|
+
// grant reaches only the owner and live joiners, and a dormant same-grant record is left
|
|
857
|
+
// holding a refresh token upstream has invalidated.
|
|
858
|
+
const commit = commitRefreshedCodexCredentialWithAliases(id, startGeneration, updated);
|
|
859
|
+
if (!commit.committed) {
|
|
528
860
|
throw new CodexCredentialGenerationConflictError();
|
|
529
861
|
}
|
|
530
|
-
|
|
862
|
+
if (commit.propagatedAliases.length > 0) {
|
|
863
|
+
console.warn(`[codex-auth] rotated refresh grant propagated to ${commit.propagatedAliases.length} dormant same-grant account record(s)`);
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
accessToken: updated.accessToken,
|
|
867
|
+
chatgptAccountId: updated.chatgptAccountId,
|
|
868
|
+
generation: startGeneration + 1,
|
|
869
|
+
credential: updated,
|
|
870
|
+
// Aliases that adopted this rotated credential travel on the result so the FLIGHT settles
|
|
871
|
+
// their plans in the same single place as the owner's (#2933). Each carries its own committed
|
|
872
|
+
// generation because the plan note is generation-fenced.
|
|
873
|
+
...(commit.propagatedAliases.length > 0 ? { propagatedAliases: commit.propagatedAliases } : {}),
|
|
874
|
+
// The grant this flight was OPENED for, not the rotated one it produced. Joiners
|
|
875
|
+
// are waiting on that key, and a successful refresh normally rotates the refresh
|
|
876
|
+
// token — tagging the new grant would make every legitimate joiner look foreign.
|
|
877
|
+
resolvedGrantFingerprint: refreshGrantFingerprint,
|
|
878
|
+
selfRefreshed: true,
|
|
879
|
+
};
|
|
880
|
+
});
|
|
881
|
+
/*
|
|
882
|
+
* Plan reconciliation belongs to the FLIGHT, not to whichever caller opened it.
|
|
883
|
+
*
|
|
884
|
+
* The flight outlives its initiating caller by design (gap 2): an aborted owner stops
|
|
885
|
+
* waiting while the shared work still runs and still commits the rotated credential.
|
|
886
|
+
* Reconciling the plan only after the owner's caller-scoped wait therefore dropped it
|
|
887
|
+
* whenever that owner walked away, and a same-account joiner returning through the
|
|
888
|
+
* adopt-stored branch does not reconcile either — so a changed `chatgpt_plan_type`
|
|
889
|
+
* stayed invisible in `codexAccounts[].plan` for the life of the process and skewed
|
|
890
|
+
* plan-selected quota projection. Attaching it to the flight runs it exactly once per
|
|
891
|
+
* committed result, for every waiter, including none.
|
|
892
|
+
*/
|
|
893
|
+
const refreshPromise = fetchPromise.then(async (result): Promise<CodexRefreshResult> => {
|
|
894
|
+
await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
|
|
895
|
+
// One settlement path for the whole flight: the refreshing account, then any dormant alias that
|
|
896
|
+
// adopted the same rotated JWT. An alias holds the identical access token, so a changed
|
|
897
|
+
// `chatgpt_plan_type` applies to it too, and its cached-token fast path would never reconcile it.
|
|
898
|
+
for (const alias of result.propagatedAliases ?? []) {
|
|
899
|
+
await notePlanFromRefreshedAccessToken(alias.id, result.accessToken, alias.generation);
|
|
900
|
+
}
|
|
901
|
+
return result;
|
|
531
902
|
}).finally(() => {
|
|
532
903
|
if (refreshLocks.get(refreshGrantFingerprint) === flight) refreshLocks.delete(refreshGrantFingerprint);
|
|
533
904
|
});
|
|
534
905
|
|
|
535
906
|
flight = { promise: refreshPromise, startedAt: Date.now(), abort };
|
|
536
907
|
refreshLocks.set(refreshGrantFingerprint, flight);
|
|
537
|
-
|
|
538
|
-
|
|
908
|
+
// The owner waits under its own cancellation too: the flight it opened is already
|
|
909
|
+
// registered, so a joiner that arrives after this caller walks away still receives
|
|
910
|
+
// the committed result.
|
|
911
|
+
const result = await awaitOwnCancellation(refreshPromise, callerSignal);
|
|
539
912
|
return {
|
|
540
913
|
accessToken: result.accessToken,
|
|
541
914
|
chatgptAccountId: result.chatgptAccountId,
|
|
542
915
|
generation: result.generation,
|
|
916
|
+
// Carry the flight's provenance out to the caller: the owner is the one whose CAS
|
|
917
|
+
// produced this generation, and a forced caller needs that to know whether the new
|
|
918
|
+
// credential descends from the one it was holding.
|
|
919
|
+
...(result.selfRefreshed !== undefined ? { selfRefreshed: result.selfRefreshed } : {}),
|
|
920
|
+
...(result.resolvedGrantFingerprint !== undefined
|
|
921
|
+
? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
|
|
922
|
+
: {}),
|
|
543
923
|
};
|
|
544
924
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { getCodexAccountCredential } from "./account-store";
|
|
2
2
|
import { isAccountNeedsReauth } from "./account-runtime-state";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
MAIN_CODEX_ACCOUNT_ID,
|
|
5
|
+
hasMainAccountRefreshGrant,
|
|
6
|
+
isMainAccountCredentialUsable,
|
|
7
|
+
isMainAccountTokenLive,
|
|
8
|
+
} from "./main-account";
|
|
4
9
|
import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
|
|
5
10
|
import type { OcxConfig } from "../types";
|
|
6
11
|
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
|
|
@@ -27,13 +32,15 @@ export function isCodexAccountUsable(
|
|
|
27
32
|
// A legacy pool row with the sentinel makes an active `__main__` ambiguous.
|
|
28
33
|
// Fail closed until the authenticated compatibility-delete path removes it.
|
|
29
34
|
if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false;
|
|
30
|
-
if (isAccountNeedsReauth(accountId)) return false;
|
|
35
|
+
if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return false;
|
|
31
36
|
// A selection-only caller owns the recovery/drain fence and will reject main
|
|
32
37
|
// before reservation or token materialization. Treat cached main as a routing
|
|
33
38
|
// candidate without touching the credential file so affinity is not rebound.
|
|
34
39
|
if (options.nativeMainSelectionOnly) return true;
|
|
35
|
-
// Main account:
|
|
36
|
-
return
|
|
40
|
+
// Main account: a refresh grant is enough to route; materialization refreshes before I/O.
|
|
41
|
+
return options.isMainAccountTokenLive
|
|
42
|
+
? options.isMainAccountTokenLive()
|
|
43
|
+
: isMainAccountCredentialUsable();
|
|
37
44
|
}
|
|
38
45
|
const exists = (config.codexAccounts ?? [])
|
|
39
46
|
.some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
|
|
@@ -44,9 +44,14 @@ const CODEX_TARGET_TRIPLE_BODY = "[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+
|
|
|
44
44
|
* `"C:\Program Files\...\codex.exe" app-server` still reach GetOwner.
|
|
45
45
|
* Also admits official target-triple basenames such as
|
|
46
46
|
* `codex-x86_64-pc-windows-msvc.exe`.
|
|
47
|
+
*
|
|
48
|
+
* The optional `.opencodex-real` sits where `backupPathFor` actually puts it — after
|
|
49
|
+
* the stem and BEFORE the extension — and deliberately not before the triple. Written
|
|
50
|
+
* the other way it admits `codex.opencodex-real-x86_64-pc-windows-msvc.exe`, a name
|
|
51
|
+
* nothing produces, and pays GetOwner for it.
|
|
47
52
|
*/
|
|
48
53
|
export const WINDOWS_CODEX_BASENAME_CANDIDATE_RE = new RegExp(
|
|
49
|
-
`(^|[/\\\\\\s'"=])codex(-${CODEX_TARGET_TRIPLE_BODY})?([.]exe|[.]cmd)?['"]?(\\s|$)`,
|
|
54
|
+
`(^|[/\\\\\\s'"=])codex(-${CODEX_TARGET_TRIPLE_BODY})?([.]opencodex-real)?([.]exe|[.]cmd|[.]ps1)?['"]?(\\s|$)`,
|
|
50
55
|
"i",
|
|
51
56
|
);
|
|
52
57
|
|
|
@@ -57,6 +62,38 @@ const CODEX_TARGET_TRIPLE_BASENAME_RE = new RegExp(
|
|
|
57
62
|
`^codex-${CODEX_TARGET_TRIPLE_BODY}(?:\\.exe|\\.cmd)?$`,
|
|
58
63
|
);
|
|
59
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Launcher basenames a Codex app-server can be started through, including the
|
|
67
|
+
* `.opencodex-real` backups the autostart shim creates.
|
|
68
|
+
*
|
|
69
|
+
* When the shim installs, `backupPathFor` (`src/codex/shim.ts`) renames the original
|
|
70
|
+
* launcher by inserting `.opencodex-real` before its extension, so a shimmed host runs
|
|
71
|
+
* `~/.local/bin/codex.opencodex-real app-server`. Reported by a contributor (#2884) with
|
|
72
|
+
* `ps` output from an affected host: `--restart-codex` matched nothing and left
|
|
73
|
+
* app-servers alive holding stale in-memory catalogs.
|
|
74
|
+
*
|
|
75
|
+
* An EXACT set, kept separate from the target-triple pattern above rather than folded
|
|
76
|
+
* into it by stripping the suffix first. That shortcut is unsafe: normalising
|
|
77
|
+
* `codex-report-generator-worker.opencodex-real` yields a syntactically valid triple
|
|
78
|
+
* and would make an unrelated process a kill target. A triple binary cannot be a shim
|
|
79
|
+
* target anyway — Unix discovery accepts only a PATH entry named `codex`, and Windows
|
|
80
|
+
* refuses a real `codex.exe` outright — so the combination is unreachable, not merely
|
|
81
|
+
* unlisted.
|
|
82
|
+
*
|
|
83
|
+
* `.ps1` and `.cmd` are here because `findWindowsCodexTargets` shims both, and the
|
|
84
|
+
* extensionless form because Unix discovery and the Git-Bash launcher use it. There is
|
|
85
|
+
* deliberately no `.opencodex-real.exe`: Windows installation REFUSES to rename a native
|
|
86
|
+
* `codex.exe`, so that backup cannot exist. Matching it looked like free breadth until a
|
|
87
|
+
* review round put it plainly — this set decides what receives SIGTERM, and a name no
|
|
88
|
+
* installation can produce only widens what a coincidence can hit.
|
|
89
|
+
*/
|
|
90
|
+
const CODEX_LAUNCHER_BASENAMES = new Set([
|
|
91
|
+
"codex", "codex.exe", "codex.cmd",
|
|
92
|
+
"codex.opencodex-real",
|
|
93
|
+
"codex.opencodex-real.cmd",
|
|
94
|
+
"codex.opencodex-real.ps1",
|
|
95
|
+
]);
|
|
96
|
+
|
|
60
97
|
/** True when a Windows CommandLine is worth paying GetOwner for (current-user scoped later). */
|
|
61
98
|
export function isWindowsCodexCandidateCommandLine(commandLine: string): boolean {
|
|
62
99
|
return WINDOWS_CODEX_BASENAME_CANDIDATE_RE.test(commandLine)
|
|
@@ -158,7 +195,7 @@ function tokenBasename(token: string): string {
|
|
|
158
195
|
|
|
159
196
|
function isCodexExecutableToken(token: string): boolean {
|
|
160
197
|
const base = tokenBasename(token);
|
|
161
|
-
return
|
|
198
|
+
return CODEX_LAUNCHER_BASENAMES.has(base)
|
|
162
199
|
|| CODEX_TARGET_TRIPLE_BASENAME_RE.test(base);
|
|
163
200
|
}
|
|
164
201
|
|
|
@@ -282,6 +319,10 @@ export function isCodexAppServerCommandLine(commandLine: string, executable?: st
|
|
|
282
319
|
let i = 1;
|
|
283
320
|
while (i < tokens.length) {
|
|
284
321
|
const token = tokens[i]!;
|
|
322
|
+
// `--` ends option parsing, so what follows is a prompt for the interactive TUI, not
|
|
323
|
+
// a subcommand. `codex -- app-server` starts a session whose first prompt word is
|
|
324
|
+
// "app-server"; treating it as a match sends SIGTERM to somebody's live session.
|
|
325
|
+
if (token === "--") return false;
|
|
285
326
|
if (token.startsWith("-")) {
|
|
286
327
|
i = advancePastCodexGlobalOption(tokens, i);
|
|
287
328
|
continue;
|
|
@@ -418,9 +459,9 @@ function windowsSnapshotPowerShellCommand(): string {
|
|
|
418
459
|
// Newlines keep -Command as a real script (space-joined statements need ';').
|
|
419
460
|
// Double-quoted format string so `t expands to a real tab.
|
|
420
461
|
// Codex candidates only: basename token codex / codex.exe / codex.cmd /
|
|
421
|
-
//
|
|
422
|
-
// basename), or code-mode-host —
|
|
423
|
-
// path with "opencodex".
|
|
462
|
+
// codex.ps1, their .opencodex-real shim backups, official target-triple
|
|
463
|
+
// binaries (optional closing quote after the basename), or code-mode-host —
|
|
464
|
+
// not incidental substrings like a repo path with "opencodex".
|
|
424
465
|
const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source);
|
|
425
466
|
const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source);
|
|
426
467
|
return [
|