@bitkyc08/opencodex 2.56.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +32 -1
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +7 -1
- package/src/bridge/sse.ts +19 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +157 -7
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/restore.ts +29 -2
- package/src/codex/inject.ts +9 -9
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/pending-teardown.ts +31 -0
- package/src/generated/compatibility-version.json +163 -135
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/registry/entries-core.ts +7 -0
- package/src/providers/registry/entries-extended.ts +9 -0
- package/src/providers/registry/model-seeds.ts +4 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +6 -2
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +103 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +19 -6
- package/src/server/responses/passthrough-dispatch.ts +28 -10
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +132 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +4 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
pickAlternateCodexAccount,
|
|
40
40
|
resolveCodexAccountForThreadDetailed,
|
|
41
41
|
type CodexAffinityDecision,
|
|
42
|
+
type CodexThreadResolution,
|
|
43
|
+
type TransientProbeGrant,
|
|
42
44
|
} from "./routing";
|
|
45
|
+
// The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from
|
|
46
|
+
// ./routing above -- different module, different domain, and a request never holds both.
|
|
47
|
+
import { releaseTransientProbe } from "../routing/probe-lease";
|
|
43
48
|
import {
|
|
44
49
|
codexConversationIdentity,
|
|
45
50
|
recordCodexThreadLineage,
|
|
@@ -47,6 +52,7 @@ import {
|
|
|
47
52
|
type CodexThreadLineage,
|
|
48
53
|
} from "./lineage";
|
|
49
54
|
import {
|
|
55
|
+
cachedDeniedCodexAccountIdsForModel,
|
|
50
56
|
entitledCodexAccountIdsForModel,
|
|
51
57
|
isDirectCallerEntitledToCodexModel,
|
|
52
58
|
resolveCodexModelEntitlements,
|
|
@@ -202,6 +208,12 @@ export type CodexAuthContext =
|
|
|
202
208
|
affinityDecision?: CodexAffinityDecision;
|
|
203
209
|
/** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
|
|
204
210
|
probeQuotaScope?: CodexQuotaScope;
|
|
211
|
+
/**
|
|
212
|
+
* Set when this request is the ONE dispatch admitted to test an account held under a
|
|
213
|
+
* transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled
|
|
214
|
+
* by the request that ran it, and release it on any path that never reaches upstream.
|
|
215
|
+
*/
|
|
216
|
+
transientProbe?: TransientProbeGrant;
|
|
205
217
|
}
|
|
206
218
|
| {
|
|
207
219
|
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
|
|
@@ -222,6 +234,8 @@ export type CodexAuthContext =
|
|
|
222
234
|
probeLeaseId?: string;
|
|
223
235
|
quotaScope?: CodexQuotaScope;
|
|
224
236
|
probeQuotaScope?: CodexQuotaScope;
|
|
237
|
+
/** See `pool.transientProbe`. */
|
|
238
|
+
transientProbe?: TransientProbeGrant;
|
|
225
239
|
};
|
|
226
240
|
|
|
227
241
|
/** Probe lease carried by this context, when it holds one. */
|
|
@@ -234,11 +248,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu
|
|
|
234
248
|
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
|
|
235
249
|
}
|
|
236
250
|
|
|
251
|
+
/** The transient-hold recovery probe carried by this context, when it holds one (#4701). */
|
|
252
|
+
export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined {
|
|
253
|
+
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
237
256
|
/**
|
|
238
257
|
* Hand back a probe lease for a request that will not reach upstream. Safe to
|
|
239
258
|
* call with a context that holds no lease.
|
|
259
|
+
*
|
|
260
|
+
* BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold
|
|
261
|
+
* probe, and every one of the ~30 call sites that already hands back the first is a path where
|
|
262
|
+
* the second would leak too. Releasing them together is what makes those sites correct for the
|
|
263
|
+
* new lease without re-deriving the discard set by hand -- the failure mode being avoided is a
|
|
264
|
+
* held account nobody may probe because the request that held the trial went away quietly.
|
|
240
265
|
*/
|
|
241
266
|
export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
|
|
267
|
+
const transientProbe = codexTransientProbeGrant(ctx);
|
|
268
|
+
if (transientProbe) releaseTransientProbe(transientProbe.lease);
|
|
242
269
|
const leaseId = codexProbeLeaseId(ctx);
|
|
243
270
|
if (!ctx || ctx.kind === "main" || !leaseId) return;
|
|
244
271
|
if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
|
|
@@ -424,6 +451,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE
|
|
|
424
451
|
}
|
|
425
452
|
}
|
|
426
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Every account bound to this conversation is held after upstream failures, the recovery
|
|
456
|
+
* budget for this window is spent, and there is no detour left -- so this request is refused
|
|
457
|
+
* BEFORE any upstream I/O (#4701).
|
|
458
|
+
*
|
|
459
|
+
* This is not a quota cooldown, and the message below says so. It subclasses
|
|
460
|
+
* {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly
|
|
461
|
+
* one representation in this codebase, and roughly a dozen transports already map it to a 429
|
|
462
|
+
* with `Retry-After` and treat it as an expected terminal answer rather than a credential
|
|
463
|
+
* fault. Introducing a parallel type would mean either re-deriving that handling in every one
|
|
464
|
+
* of them or silently falling through to a 500 in the ones that were missed.
|
|
465
|
+
*
|
|
466
|
+
* What must NOT be inherited is the quota wording -- "cooling down", `ocx account
|
|
467
|
+
* clear-cooldown` -- because none of it describes a 5xx hold and following it would do
|
|
468
|
+
* nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim,
|
|
469
|
+
* the same escape hatch {@link CodexMainAccountHardLockError} and
|
|
470
|
+
* {@link CodexReserveUnavailableError} already use.
|
|
471
|
+
*
|
|
472
|
+
* `cooldownUntil` carries the limiter's own change point, which is strictly in the future:
|
|
473
|
+
* either the moment the held account may next be probed or the moment the recovery window
|
|
474
|
+
* moves, whichever is later. A refusal that answered `now` would busy-loop the caller into
|
|
475
|
+
* the same load it just declined.
|
|
476
|
+
*/
|
|
477
|
+
export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
|
|
478
|
+
/** The sibling still remembered for this thread, when one exists but is itself unusable. */
|
|
479
|
+
readonly detourAccountId?: string;
|
|
480
|
+
|
|
481
|
+
constructor(accountId: string, retryAt: number, detourAccountId?: string) {
|
|
482
|
+
super(accountId, retryAt);
|
|
483
|
+
this.name = "CodexRecoveryWithheldError";
|
|
484
|
+
this.detourAccountId = detourAccountId;
|
|
485
|
+
this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream`
|
|
486
|
+
+ ` failures and the pool's recovery budget for this window is spent, so nothing was sent`
|
|
487
|
+
+ ` upstream. Retry after ${new Date(retryAt).toISOString()}.`
|
|
488
|
+
+ " This clears on its own as the account recovers; no cooldown to lift and no account to switch.";
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
427
492
|
export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
|
|
428
493
|
"codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
|
|
429
494
|
>>;
|
|
@@ -634,7 +699,12 @@ export function cooldownAccountLabel(accountId: string): string {
|
|
|
634
699
|
* injected `openai_base_url` in config.toml.
|
|
635
700
|
*/
|
|
636
701
|
export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
|
|
637
|
-
if (err instanceof CodexMainAccountHardLockError
|
|
702
|
+
if (err instanceof CodexMainAccountHardLockError
|
|
703
|
+
|| err instanceof CodexReserveUnavailableError
|
|
704
|
+
// A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
|
|
705
|
+
// one, and the quota recovery advice below would send the operator after a cooldown that
|
|
706
|
+
// does not exist (#4701).
|
|
707
|
+
|| err instanceof CodexRecoveryWithheldError) return err.message;
|
|
638
708
|
const until = new Date(err.cooldownUntil).toISOString();
|
|
639
709
|
const scopeLabels: Record<CodexQuotaScope, string> = {
|
|
640
710
|
shared: "shared native quota", reserve: "Reserve quota",
|
|
@@ -717,6 +787,11 @@ export interface ResolveCodexAuthContextOptions {
|
|
|
717
787
|
requestScopedMainCredential?: boolean;
|
|
718
788
|
/** Test seam for a Direct request's own forwarded ChatGPT credential. */
|
|
719
789
|
isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
|
|
790
|
+
/**
|
|
791
|
+
* This request's conversation carries live uploaded-file references (#4778). Retains the bound
|
|
792
|
+
* account across a VOLUNTARY quota move; involuntary release is untouched.
|
|
793
|
+
*/
|
|
794
|
+
retainAccountForUploadedFiles?: boolean;
|
|
720
795
|
}
|
|
721
796
|
|
|
722
797
|
export interface CodexAccountSelectionAdmission {
|
|
@@ -870,6 +945,15 @@ export async function resolveCodexAuthContext(
|
|
|
870
945
|
// Why this request is on this account, carried to the request log so a move reads as an event
|
|
871
946
|
// instead of something inferred from account labels across lines (#4546).
|
|
872
947
|
let affinityDecision: CodexAffinityDecision | undefined;
|
|
948
|
+
// The half-open trial this request was granted, if it is the one allowed to test a held
|
|
949
|
+
// account. Declared out here because the release paths below and the returned context are on
|
|
950
|
+
// opposite sides of several throws (#4701).
|
|
951
|
+
let transientProbe: TransientProbeGrant | undefined;
|
|
952
|
+
const releaseTransientProbeGrant = (): void => {
|
|
953
|
+
if (!transientProbe) return;
|
|
954
|
+
releaseTransientProbe(transientProbe.lease);
|
|
955
|
+
transientProbe = undefined;
|
|
956
|
+
};
|
|
873
957
|
// Retained startup recovery makes the physical main identity ineligible. Routing
|
|
874
958
|
// can still preserve service by selecting a healthy configured pool account. A
|
|
875
959
|
// request-owned bearer likewise cannot inspect or reconcile file-main state.
|
|
@@ -900,6 +984,23 @@ export async function resolveCodexAuthContext(
|
|
|
900
984
|
const modelEligibleAccountIds = entitledAccountIds
|
|
901
985
|
? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
|
|
902
986
|
: undefined;
|
|
987
|
+
// #4768: the flagships stay visible and never fail closed, so this is evidence routing may
|
|
988
|
+
// ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has
|
|
989
|
+
// already gathered -- no upstream fetch joins the request path for the most commonly
|
|
990
|
+
// requested models in the product -- and passed to selection as a preference that is dropped
|
|
991
|
+
// whenever honouring it would leave no candidate.
|
|
992
|
+
//
|
|
993
|
+
// Under the SAME exclusion the entitlement snapshot above uses. The reader validates each
|
|
994
|
+
// cached roster against the account's current credential, and for native main that is a
|
|
995
|
+
// synchronous read of the physical stored token -- exactly what this request is forbidden to
|
|
996
|
+
// touch while a profile switch drains it or while it is served by a request-owned credential.
|
|
997
|
+
// Excluding main here costs nothing: the preference is an ordering hint, so main becomes
|
|
998
|
+
// unknown rather than denied, and unknown leaves selection exactly as it was.
|
|
999
|
+
const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(
|
|
1000
|
+
options.modelId,
|
|
1001
|
+
undefined,
|
|
1002
|
+
{ excludeAccountIds },
|
|
1003
|
+
);
|
|
903
1004
|
const selectionOptions = {
|
|
904
1005
|
// Temporary switch drain keeps the candidate until the atomic claim rejects
|
|
905
1006
|
// it. Retained recovery makes main wholly ineligible so pool routing continues.
|
|
@@ -911,13 +1012,21 @@ export async function resolveCodexAuthContext(
|
|
|
911
1012
|
? () => preserveRequestOwnedMainPin
|
|
912
1013
|
: options.isMainAccountTokenLive,
|
|
913
1014
|
modelEligibleAccountIds,
|
|
1015
|
+
deniedModelAccountIds,
|
|
1016
|
+
// Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
|
|
1017
|
+
// conversation's attachments say nothing about where unrelated threads should be served.
|
|
1018
|
+
retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
|
|
914
1019
|
};
|
|
915
1020
|
// A pre-drain selector reserves the native identity while reconciliation and
|
|
916
1021
|
// routing inspect it. Selectors arriving after the fence skip reconciliation
|
|
917
1022
|
// and may still route to non-main pool accounts without touching switch state.
|
|
918
1023
|
if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
|
|
919
1024
|
if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
|
|
920
|
-
|
|
1025
|
+
// Annotated, not inferred. The two literals below carry neither `affinity` nor
|
|
1026
|
+
// `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to
|
|
1027
|
+
// `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to
|
|
1028
|
+
// the resolver's own union is what lets the reads below stay total.
|
|
1029
|
+
const resolution: CodexThreadResolution = fixedAccountId !== undefined
|
|
921
1030
|
? { status: "selected" as const, accountId: fixedAccountId }
|
|
922
1031
|
: options.excludeAccountId
|
|
923
1032
|
? (() => {
|
|
@@ -942,8 +1051,17 @@ export async function resolveCodexAuthContext(
|
|
|
942
1051
|
lineage,
|
|
943
1052
|
);
|
|
944
1053
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
1054
|
+
// THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is
|
|
1055
|
+
// left -- so this request must not reach upstream at all. Returning the held account here
|
|
1056
|
+
// is what #4701 is about: under a provider-wide 503 that is every bound request piling
|
|
1057
|
+
// onto an account already known to be failing. Thrown before any credential is read, so
|
|
1058
|
+
// nothing is sent and nothing is spent.
|
|
1059
|
+
if (resolution.status === "withheld") {
|
|
1060
|
+
throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId);
|
|
1061
|
+
}
|
|
945
1062
|
const selected = resolution.status === "selected" ? resolution.accountId : null;
|
|
946
|
-
affinityDecision =
|
|
1063
|
+
affinityDecision = resolution.affinity;
|
|
1064
|
+
transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
|
|
947
1065
|
if (!selected) {
|
|
948
1066
|
// A retry that excluded a failed Pool account may still use the validated caller-owned
|
|
949
1067
|
// main credential. Treating every exclusion as if main itself had failed strands a healthy
|
|
@@ -1025,12 +1143,25 @@ export async function resolveCodexAuthContext(
|
|
|
1025
1143
|
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
|
|
1026
1144
|
}
|
|
1027
1145
|
}
|
|
1146
|
+
} catch (cause) {
|
|
1147
|
+
// Selection granted a trial and then a later policy check refused the account. The trial
|
|
1148
|
+
// never runs, so hand it back instead of leaving the held account unprobeable until its
|
|
1149
|
+
// deadline lapses (#4701).
|
|
1150
|
+
releaseTransientProbeGrant();
|
|
1151
|
+
throw cause;
|
|
1028
1152
|
} finally {
|
|
1029
1153
|
selectionAdmission?.release();
|
|
1030
1154
|
}
|
|
1031
1155
|
// Legacy selectors may retain an unusable account for actionable errors. A
|
|
1032
1156
|
// deferred credential must never become request auth through that fallback.
|
|
1033
|
-
|
|
1157
|
+
try {
|
|
1158
|
+
assertCodexAccountValidationReady(accountId);
|
|
1159
|
+
} catch (cause) {
|
|
1160
|
+
// Nothing will reach upstream, so give the trial back instead of leaving the held account
|
|
1161
|
+
// unprobeable until the lease deadline lapses (#4701).
|
|
1162
|
+
releaseTransientProbeGrant();
|
|
1163
|
+
throw cause;
|
|
1164
|
+
}
|
|
1034
1165
|
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
1035
1166
|
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
1036
1167
|
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
@@ -1049,6 +1180,13 @@ export async function resolveCodexAuthContext(
|
|
|
1049
1180
|
// a literal Retry-After reads very differently to a user than a reset-derived guess.
|
|
1050
1181
|
const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
|
|
1051
1182
|
const cooldownUntil = cooldown?.cooldownUntil;
|
|
1183
|
+
// A transient-hold trial and a quota cooldown cannot both describe this account:
|
|
1184
|
+
// `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying
|
|
1185
|
+
// quota health, so the cooldown branch below is unreachable while a trial is held. That is
|
|
1186
|
+
// also why no request pays two recovery permits for one send. The release is defensive --
|
|
1187
|
+
// should that invariant ever move, the trial is handed back rather than stranded behind a
|
|
1188
|
+
// refusal that belongs to the other domain.
|
|
1189
|
+
if (cooldownUntil && transientProbe) releaseTransientProbeGrant();
|
|
1052
1190
|
// A cooled-down account never sends traffic, so upstream recovery can never be
|
|
1053
1191
|
// observed and the cooldown outlives the real limit. Admit one probe per
|
|
1054
1192
|
// interval; its outcome decides whether the cooldown ends (#433).
|
|
@@ -1089,6 +1227,7 @@ export async function resolveCodexAuthContext(
|
|
|
1089
1227
|
if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
|
|
1090
1228
|
assertMainAccountPolicy(policy);
|
|
1091
1229
|
} catch (cause) {
|
|
1230
|
+
releaseTransientProbeGrant();
|
|
1092
1231
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1093
1232
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1094
1233
|
if (cause instanceof CodexMainAccountHardLockError) throw cause;
|
|
@@ -1099,15 +1238,23 @@ export async function resolveCodexAuthContext(
|
|
|
1099
1238
|
}
|
|
1100
1239
|
if (!token) {
|
|
1101
1240
|
// Nothing will reach upstream, so give the probe back instead of burning it.
|
|
1241
|
+
releaseTransientProbeGrant();
|
|
1102
1242
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1103
1243
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1104
1244
|
throw new CodexPoolAuthenticationError(
|
|
1105
1245
|
fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
|
|
1106
1246
|
);
|
|
1107
1247
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1248
|
+
let reserveAuthorization: MainReserveAuthorization | undefined;
|
|
1249
|
+
try {
|
|
1250
|
+
reserveAuthorization = reserve
|
|
1251
|
+
? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
|
|
1252
|
+
: undefined;
|
|
1253
|
+
} catch (cause) {
|
|
1254
|
+
// A Reserve refusal ends the request here, so the trial it was holding never runs.
|
|
1255
|
+
releaseTransientProbeGrant();
|
|
1256
|
+
throw cause;
|
|
1257
|
+
}
|
|
1111
1258
|
return {
|
|
1112
1259
|
kind: "main-pool",
|
|
1113
1260
|
accountId,
|
|
@@ -1121,6 +1268,7 @@ export async function resolveCodexAuthContext(
|
|
|
1121
1268
|
...(quotaScope ? { quotaScope } : {}),
|
|
1122
1269
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1123
1270
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1271
|
+
...(transientProbe ? { transientProbe } : {}),
|
|
1124
1272
|
};
|
|
1125
1273
|
}
|
|
1126
1274
|
|
|
@@ -1141,8 +1289,10 @@ export async function resolveCodexAuthContext(
|
|
|
1141
1289
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1142
1290
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1143
1291
|
...(affinityDecision ? { affinityDecision } : {}),
|
|
1292
|
+
...(transientProbe ? { transientProbe } : {}),
|
|
1144
1293
|
};
|
|
1145
1294
|
} catch (cause) {
|
|
1295
|
+
releaseTransientProbeGrant();
|
|
1146
1296
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1147
1297
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1148
1298
|
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
@@ -34,7 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
import { catalogModelSlug } from "./parsing";
|
|
37
|
-
import type { CatalogModel } from "./parsing";
|
|
37
|
+
import type { CatalogModel, RawEntry } from "./parsing";
|
|
38
38
|
|
|
39
39
|
export const openAiApiCollisionWarnings = new Set<string>();
|
|
40
40
|
|
|
@@ -222,6 +222,85 @@ export function safeCatalogWarningLabel(value: string): string {
|
|
|
222
222
|
.slice(0, 200);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Keep the first row of each slug and drop the rest (#4730).
|
|
227
|
+
*
|
|
228
|
+
* First-win is the only answer that agrees with the ordering already decided upstream: the merge
|
|
229
|
+
* ranks rows, so its first occurrence is the row it chose. Distinct slugs are never touched — an
|
|
230
|
+
* alias row and the canonical routed row of the same provider model are two different public names
|
|
231
|
+
* and both survive — and a row without a string slug passes through untouched.
|
|
232
|
+
*/
|
|
233
|
+
export function dedupeCatalogEntriesBySlug(models: RawEntry[]): RawEntry[] {
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
const out: RawEntry[] = [];
|
|
236
|
+
for (const entry of models) {
|
|
237
|
+
if (typeof entry.slug !== "string") {
|
|
238
|
+
out.push(entry);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (seen.has(entry.slug)) continue;
|
|
242
|
+
seen.add(entry.slug);
|
|
243
|
+
out.push(entry);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every slug this proxy writes into the Codex catalog must appear exactly once (#4730).
|
|
250
|
+
*
|
|
251
|
+
* The cost of breaking it is the whole file: a slug-unique validating consumer refuses the catalog
|
|
252
|
+
* outright, so one duplicated row takes every model with it. A 2.56.0 report carried 507 rows for
|
|
253
|
+
* 72 unique slugs, every duplicate byte-identical, and Codex rejected the file as `source-invalid`.
|
|
254
|
+
*
|
|
255
|
+
* This is a write-boundary invariant rather than a repair of one producer, and that distinction is
|
|
256
|
+
* deliberate: the reported catalog is evidence that some emit path can double a row, but nothing in
|
|
257
|
+
* this tree has been shown to be that path, and a guard that only covered the producer someone
|
|
258
|
+
* guessed at would leave the file corruptible by the next one. Both writers that serialize a merged
|
|
259
|
+
* catalog call this as their LAST mutation — `writeRetainedCatalogSync` and the management
|
|
260
|
+
* convergence commit — so uniqueness holds for the exact bytes that land on disk.
|
|
261
|
+
*
|
|
262
|
+
* Ordering is load-bearing. Running the guard before the effort clamp would be unsound:
|
|
263
|
+
* `clampCatalogModelsToObservedCodexSupport` splices whole rows out when an exact-reserve ladder
|
|
264
|
+
* clamps empty, so dropping a later same-slug row first can leave the slug with no row at all once
|
|
265
|
+
* the surviving one is spliced.
|
|
266
|
+
*
|
|
267
|
+
* @param models - The finished row list, already clamped and finalized.
|
|
268
|
+
* @param warn - Whether to report on `console.warn`. The convergence path merges under
|
|
269
|
+
* `warningPolicy: "suppress"` and stays silent for the same reason.
|
|
270
|
+
* @returns The original array when it was already unique, so an unchanged catalog stays a no-op
|
|
271
|
+
* write; otherwise a first-win copy.
|
|
272
|
+
*/
|
|
273
|
+
export function enforceCatalogSlugUniqueness(models: RawEntry[], warn: boolean): RawEntry[] {
|
|
274
|
+
const deduped = dedupeCatalogEntriesBySlug(models);
|
|
275
|
+
if (deduped.length === models.length) return models;
|
|
276
|
+
if (warn) {
|
|
277
|
+
// A dropped row that differs from the kept one means two emit paths disagree about the same
|
|
278
|
+
// slug's content. First-win still stands, but the operator needs to see WHICH slugs diverged
|
|
279
|
+
// instead of silently losing data. The baseline is the row the dedupe actually keeps — the
|
|
280
|
+
// FIRST occurrence — so the reported divergence is measured against what lands on disk.
|
|
281
|
+
const keptBySlug = new Map<string, RawEntry>();
|
|
282
|
+
for (const entry of models) {
|
|
283
|
+
if (typeof entry.slug !== "string" || keptBySlug.has(entry.slug)) continue;
|
|
284
|
+
keptBySlug.set(entry.slug, entry);
|
|
285
|
+
}
|
|
286
|
+
const divergentSlugs = new Set<string>();
|
|
287
|
+
for (const entry of models) {
|
|
288
|
+
if (typeof entry.slug !== "string") continue;
|
|
289
|
+
const kept = keptBySlug.get(entry.slug);
|
|
290
|
+
if (kept && kept !== entry && JSON.stringify(kept) !== JSON.stringify(entry)) {
|
|
291
|
+
divergentSlugs.add(entry.slug);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const divergentNote = divergentSlugs.size > 0
|
|
295
|
+
? `; divergent content on: ${[...divergentSlugs].slice(0, 5).map(safeCatalogWarningLabel).join(", ")}${divergentSlugs.size > 5 ? ", …" : ""}`
|
|
296
|
+
: "";
|
|
297
|
+
console.warn(
|
|
298
|
+
`[opencodex] catalog sync dropped ${models.length - deduped.length} duplicate slug row(s), keeping the first occurrence of each slug (#4730)${divergentNote}.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return deduped;
|
|
302
|
+
}
|
|
303
|
+
|
|
225
304
|
export function comboCatalogWarningSignature(
|
|
226
305
|
combo: NormalizedComboConfig,
|
|
227
306
|
members: readonly CatalogModel[],
|
|
@@ -290,6 +290,7 @@ export function filterCatalogVisibleModels(
|
|
|
290
290
|
}
|
|
291
291
|
return models.filter(m => {
|
|
292
292
|
if (initialModelSelectionPending(config.providers[m.provider])) return false;
|
|
293
|
+
if (config.providers[m.provider]?.disabled === true) return false;
|
|
293
294
|
const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true;
|
|
294
295
|
// disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
|
|
295
296
|
for (const stored of disabled) {
|
|
@@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number {
|
|
|
113
113
|
? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */
|
|
117
|
+
function catalogRequestUsesBunHttpProxy(url: URL): boolean {
|
|
118
|
+
if (url.protocol !== "http:") return false;
|
|
119
|
+
const proxy = process.env.http_proxy || process.env.HTTP_PROXY;
|
|
120
|
+
if (!proxy || proxy === '""' || proxy === "''") return false;
|
|
121
|
+
const hostname = url.hostname.toLowerCase();
|
|
122
|
+
const host = url.host.toLowerCase();
|
|
123
|
+
// Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII
|
|
124
|
+
// whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization.
|
|
125
|
+
const bypasses = process.env.no_proxy || process.env.NO_PROXY || "";
|
|
126
|
+
for (let entry of bypasses.split(",")) {
|
|
127
|
+
entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "")
|
|
128
|
+
.replace(/[A-Z]/g, letter => letter.toLowerCase());
|
|
129
|
+
if (entry === "*") return false;
|
|
130
|
+
if (entry.startsWith(".")) entry = entry.slice(1);
|
|
131
|
+
if (!entry) continue;
|
|
132
|
+
const hasPort = entry.startsWith("[")
|
|
133
|
+
? entry.includes("]:")
|
|
134
|
+
: (entry.match(/:/g)?.length ?? 0) === 1;
|
|
135
|
+
if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
export async function fetchRemoteCatalog(
|
|
117
141
|
input: string,
|
|
118
142
|
options: Pick<PullRemoteCatalogOptions, "token" | "timeoutMs" | "maxBytes" | "fetchImpl"> = {},
|
|
119
143
|
): Promise<{ document: RemoteCatalogDocument; content: string }> {
|
|
120
144
|
const url = validateRemoteCatalogUrl(input);
|
|
121
145
|
const token = validateToken(options.token);
|
|
146
|
+
if (catalogRequestUsesBunHttpProxy(url)) {
|
|
147
|
+
throw new RemoteCatalogError(
|
|
148
|
+
"insecure_http_refused",
|
|
149
|
+
"Loopback HTTP catalog requests must bypass outbound HTTP proxy routing",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
122
152
|
const headers = new Headers({ Accept: "application/json" });
|
|
123
153
|
if (token !== undefined) headers.set("Authorization", `Bearer ${token}`);
|
|
124
154
|
let response: Response;
|
|
@@ -51,7 +51,7 @@ import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
|
|
|
51
51
|
import { isMultiAgentV2Enabled } from "../features";
|
|
52
52
|
import { clampCatalogModelsToCodexSupport } from "./effort";
|
|
53
53
|
import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
|
|
54
|
-
import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
|
|
54
|
+
import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
|
|
55
55
|
import {
|
|
56
56
|
withCatalogWriteSerialization,
|
|
57
57
|
type CatalogWritePermit,
|
|
@@ -522,6 +522,9 @@ function writeRetainedCatalogSync({
|
|
|
522
522
|
});
|
|
523
523
|
clampCatalogModelsToCodexSupport(catalog.models);
|
|
524
524
|
finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
|
|
525
|
+
// Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering
|
|
526
|
+
// against the effort clamp is load-bearing rather than cosmetic.
|
|
527
|
+
catalog.models = enforceCatalogSlugUniqueness(catalog.models, true);
|
|
525
528
|
|
|
526
529
|
const added = goEntries.length + accountBoundEntries.length;
|
|
527
530
|
const content = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
@@ -553,6 +556,11 @@ function writeRetainedCatalogSync({
|
|
|
553
556
|
};
|
|
554
557
|
}
|
|
555
558
|
|
|
559
|
+
// Re-exported so the #4730 unit regression keeps importing the guard from the sync module it
|
|
560
|
+
// guards; the implementation lives in ./aggregation because the management convergence commit
|
|
561
|
+
// is the second writer that has to apply the identical rule.
|
|
562
|
+
export { dedupeCatalogEntriesBySlug };
|
|
563
|
+
|
|
556
564
|
export async function syncCatalogModels(
|
|
557
565
|
config: OcxConfig,
|
|
558
566
|
options?: CodexCatalogSyncOptions,
|
|
@@ -420,6 +420,42 @@ async function gatherRoutedModelsUncached(
|
|
|
420
420
|
if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
|
|
421
421
|
}
|
|
422
422
|
}
|
|
423
|
+
// [Decision Log]
|
|
424
|
+
// - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the
|
|
425
|
+
// final Models inventory publishes. Previously customModels were materialized only after this
|
|
426
|
+
// map had already derived every combo, so one row could say image while its combo said text.
|
|
427
|
+
// - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and native
|
|
428
|
+
// OpenAI synthesis must run first so a sparse custom row cannot hide native hard limits.
|
|
429
|
+
// - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the
|
|
430
|
+
// explicit custom fields onto this private derivation map after provider/native inheritance.
|
|
431
|
+
// - 선택한 방식: use the scoped post-inheritance overlay; the existing final materializer stays
|
|
432
|
+
// the single owner of public custom-row construction and deduplication.
|
|
433
|
+
// - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public
|
|
434
|
+
// catalog production and warning behavior, while this map is already private to combo input.
|
|
435
|
+
// - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain
|
|
436
|
+
// their combos without widening unrelated rows; omitted fields retain provider/native limits.
|
|
437
|
+
for (const custom of config.customModels ?? []) {
|
|
438
|
+
const key = `${custom.provider}/${custom.modelId}`;
|
|
439
|
+
const inherited = memberByKey.get(key) ?? {
|
|
440
|
+
provider: custom.provider,
|
|
441
|
+
id: custom.modelId,
|
|
442
|
+
owned_by: custom.provider,
|
|
443
|
+
};
|
|
444
|
+
memberByKey.set(key, {
|
|
445
|
+
...inherited,
|
|
446
|
+
catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
|
|
447
|
+
...(typeof custom.contextWindow === "number" && custom.contextWindow > 0
|
|
448
|
+
? { contextWindow: custom.contextWindow }
|
|
449
|
+
: {}),
|
|
450
|
+
...(Array.isArray(custom.inputModalities)
|
|
451
|
+
? { inputModalities: [...custom.inputModalities] }
|
|
452
|
+
: {}),
|
|
453
|
+
...(Array.isArray(custom.reasoningEfforts)
|
|
454
|
+
? { reasoningEfforts: [...custom.reasoningEfforts] }
|
|
455
|
+
: {}),
|
|
456
|
+
...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
423
459
|
// Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
|
|
424
460
|
// custom-model vision-sidecar inheritance so both see the same merged registry view.
|
|
425
461
|
const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
|
|
@@ -480,7 +516,8 @@ async function gatherRoutedModelsUncached(
|
|
|
480
516
|
// with the same slug below, so that row's provider capability metadata is the inheritance source.
|
|
481
517
|
const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
|
|
482
518
|
const customModels = (config.customModels ?? []).map(cm => {
|
|
483
|
-
const rawProvider = config.providers[cm.provider]
|
|
519
|
+
const rawProvider = config.providers[cm.provider]?.disabled !== true
|
|
520
|
+
? config.providers[cm.provider] : undefined;
|
|
484
521
|
const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
|
|
485
522
|
// Registry routing backfills an omitted authMode on the built-in OpenAI provider to
|
|
486
523
|
// forward. Keep the catalog projection on the same contract while still failing closed
|
|
@@ -587,8 +587,14 @@ export async function inspectCodexCliInstall(
|
|
|
587
587
|
const platform = deps.platform ?? process.platform;
|
|
588
588
|
const candidate = observeCodexRuntimeCandidateReadOnly(deps);
|
|
589
589
|
if (!candidate) {
|
|
590
|
+
// This slice reads no candidate or configuration file on Windows, so an
|
|
591
|
+
// absent proof-captured environment candidate does not establish that no
|
|
592
|
+
// Codex CLI exists: a persisted selection is simply never consulted there.
|
|
593
|
+
// Report the deferral that actually happened instead of the stronger claim
|
|
594
|
+
// that the candidate is unavailable. POSIX retains its existing result
|
|
595
|
+
// when no candidate is observed.
|
|
590
596
|
return isWindowsPlatform(platform)
|
|
591
|
-
? unknownWindowsReport("
|
|
597
|
+
? unknownWindowsReport("windows_inspection_deferred")
|
|
592
598
|
: unknownReport("candidate_unavailable");
|
|
593
599
|
}
|
|
594
600
|
|
package/src/codex/convergence.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
orderForSubagents,
|
|
50
50
|
} from "./catalog/sync";
|
|
51
51
|
import { multiAgentV2EnabledFromConfigText } from "./features";
|
|
52
|
-
import { exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
52
|
+
import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
53
53
|
import {
|
|
54
54
|
isNativeAliasCatalogEntry,
|
|
55
55
|
accountBoundNativeOpenAiSlugs,
|
|
@@ -386,7 +386,12 @@ function prepareCatalog(
|
|
|
386
386
|
: null,
|
|
387
387
|
);
|
|
388
388
|
finalizeAutoReviewModelOverride(mergedModels, catalogModels, config);
|
|
389
|
-
|
|
389
|
+
// The second writer of this file. A dashboard model toggle, a combo edit, or a Codex account
|
|
390
|
+
// login reaches `convergeCodexCatalog` and commits through `fixedCommit`, never through
|
|
391
|
+
// `writeRetainedCatalogSync`, so the #4730 uniqueness guard has to stand here too or the same
|
|
392
|
+
// `source-invalid` rejection returns by a different route. Silent because this merge runs under
|
|
393
|
+
// `warningPolicy: "suppress"`.
|
|
394
|
+
catalog.models = enforceCatalogSlugUniqueness(mergedModels, false);
|
|
390
395
|
return catalog;
|
|
391
396
|
}
|
|
392
397
|
|
|
@@ -116,11 +116,20 @@ export interface DesktopAppAdapter {
|
|
|
116
116
|
*
|
|
117
117
|
* `root` is expected to be `realpath`-resolved by discovery already.
|
|
118
118
|
*/
|
|
119
|
+
function isMembershipSeparator(character: string): boolean {
|
|
120
|
+
// `/` separates on every platform this runs on, and Windows accepts it wherever it
|
|
121
|
+
// accepts `\`. `\` is only a separator where the host says so: it is a legal
|
|
122
|
+
// FILENAME character on POSIX, so admitting it there would reopen the sibling hole
|
|
123
|
+
// this function exists to close.
|
|
124
|
+
return character === "/" || (sep === "\\" && character === "\\");
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
export function isUnderRoot(executable: string, root: string): boolean {
|
|
120
128
|
if (!executable || !root) return false;
|
|
121
129
|
if (executable === root) return true;
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
if (!executable.startsWith(root)) return false;
|
|
131
|
+
if (isMembershipSeparator(root[root.length - 1]!)) return true;
|
|
132
|
+
return isMembershipSeparator(executable[root.length] ?? "");
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
/**
|
|
@@ -29,16 +29,16 @@ const SHELL_BASENAME = "chatgpt.exe";
|
|
|
29
29
|
const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* isUnderRoot
|
|
32
|
+
* isUnderRoot checks a lexical path boundary and is case-sensitive. Windows
|
|
33
33
|
* membership is case-insensitive, and this file is executed by Unix CI against
|
|
34
|
-
*
|
|
34
|
+
* mixed slash paths, so both slash forms are folded onto the host separator first.
|
|
35
35
|
* The boundary itself — sibling `OpenAI.Codex-evil` must not match root
|
|
36
36
|
* `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell
|
|
37
37
|
* StartsWith is only a cheap pre-filter.
|
|
38
38
|
*/
|
|
39
39
|
function toHostMembershipPath(windowsPath: string): string {
|
|
40
40
|
const lowered = windowsPath.toLowerCase();
|
|
41
|
-
return
|
|
41
|
+
return lowered.replace(/[\\/]/g, sep);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function isMemberExecutable(executable: string, root: string): boolean {
|
|
@@ -91,10 +91,10 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De
|
|
|
91
91
|
const literal = install.root.replace(/'/g, "''");
|
|
92
92
|
const script = [
|
|
93
93
|
"$ErrorActionPreference='SilentlyContinue'",
|
|
94
|
-
`$root = '${literal}'`,
|
|
94
|
+
`$root = '${literal}'.Replace('/', '\\')`,
|
|
95
95
|
"$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
|
|
96
96
|
"Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
|
|
97
|
-
" Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
|
|
97
|
+
" Where-Object { $_.ExecutablePath -and $_.ExecutablePath.Replace('/', '\\').StartsWith($root, 'OrdinalIgnoreCase') } |",
|
|
98
98
|
" ForEach-Object {",
|
|
99
99
|
" $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
|
|
100
100
|
" if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
|