@bitkyc08/opencodex 2.89.0 → 2.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -97,6 +97,8 @@ import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
|
97
97
|
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
|
|
98
98
|
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
|
|
99
99
|
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
|
|
100
|
+
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity";
|
|
101
|
+
import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme";
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
104
|
* A request-owned bearer cannot inspect the physical main credential for its plan, but cached
|
|
@@ -324,6 +326,8 @@ export type CodexAuthContext =
|
|
|
324
326
|
writerGeneration: number;
|
|
325
327
|
/** Captured before async credential work; never reconstructed after the upstream response. */
|
|
326
328
|
mainQuotaWriter?: MainQuotaWriter;
|
|
329
|
+
/** Immutable credential proof captured while materializing this physical attempt's headers. */
|
|
330
|
+
mainQuotaDispatch?: MainQuotaDispatch;
|
|
327
331
|
reserveAuthorization?: MainReserveAuthorization;
|
|
328
332
|
accessToken: string;
|
|
329
333
|
chatgptAccountId: string;
|
|
@@ -698,7 +702,7 @@ async function authorizeReserveCredential(
|
|
|
698
702
|
|
|
699
703
|
function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } {
|
|
700
704
|
return {
|
|
701
|
-
accessToken: headers
|
|
705
|
+
accessToken: authorizationCredential(headers) ?? "",
|
|
702
706
|
chatgptAccountId: headers.get("chatgpt-account-id") ?? "",
|
|
703
707
|
};
|
|
704
708
|
}
|
|
@@ -713,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc
|
|
|
713
717
|
export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined {
|
|
714
718
|
const explicit = headers.get("chatgpt-account-id");
|
|
715
719
|
if (explicit) return explicit;
|
|
716
|
-
const bearer = headers
|
|
720
|
+
const bearer = authorizationCredential(headers);
|
|
717
721
|
return bearer ? extractAccountId(undefined, bearer) : undefined;
|
|
718
722
|
}
|
|
719
723
|
|
|
@@ -804,7 +808,7 @@ function assertMainAccountPolicy(
|
|
|
804
808
|
|
|
805
809
|
/** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
|
|
806
810
|
function callerMatchesObservedMain(headers: Headers): boolean {
|
|
807
|
-
const bearer = headers
|
|
811
|
+
const bearer = authorizationCredential(headers);
|
|
808
812
|
if (!bearer) return false;
|
|
809
813
|
const effectiveAccountId = headers.get("chatgpt-account-id")
|
|
810
814
|
?? extractAccountId(undefined, bearer);
|
|
@@ -828,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean {
|
|
|
828
832
|
* cooldown — the safe direction.
|
|
829
833
|
*/
|
|
830
834
|
function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
|
|
831
|
-
const bearer = headers
|
|
835
|
+
const bearer = authorizationCredential(headers);
|
|
832
836
|
if (!bearer) return true;
|
|
833
837
|
const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
|
|
834
838
|
if (callerAccountId === undefined) return true;
|
|
@@ -1080,6 +1084,33 @@ async function awaitMainAccountPolicyBindingSettled(
|
|
|
1080
1084
|
if (!settled) throw new CodexMainProfileDrainingError();
|
|
1081
1085
|
}
|
|
1082
1086
|
|
|
1087
|
+
/** Memory-only ownership check shared by Direct admission and pre-auth hard-lock routing. */
|
|
1088
|
+
export async function mainAccountHardLockAppliesToCaller(
|
|
1089
|
+
headers: Headers,
|
|
1090
|
+
policy: CodexAuthPolicyConfig,
|
|
1091
|
+
options: Pick<ResolveCodexAuthContextOptions,
|
|
1092
|
+
"substituteMainCredentialForDirect" | "signal" | "mainAccountPolicyBindingWaitMs"> = {},
|
|
1093
|
+
): Promise<boolean> {
|
|
1094
|
+
if (options.substituteMainCredentialForDirect === true) return true;
|
|
1095
|
+
if (!hasCallerCodexBearer(headers)) return false;
|
|
1096
|
+
if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
|
|
1097
|
+
return callerMatchesObservedMain(headers);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Resolve ownership once, then hand back a synchronous proof. Main identity or its observed bearer
|
|
1102
|
+
* can change across the await; the hard-lock reroute rechecks the proof with no await between it
|
|
1103
|
+
* and the body rewrite, so a replacement main's lock never reroutes the original caller.
|
|
1104
|
+
*/
|
|
1105
|
+
export async function mainAccountHardLockCallerProof(
|
|
1106
|
+
headers: Headers,
|
|
1107
|
+
policy: CodexAuthPolicyConfig,
|
|
1108
|
+
options: Parameters<typeof mainAccountHardLockAppliesToCaller>[2] = {},
|
|
1109
|
+
): Promise<(() => boolean) | null> {
|
|
1110
|
+
if (!await mainAccountHardLockAppliesToCaller(headers, policy, options)) return null;
|
|
1111
|
+
return options.substituteMainCredentialForDirect === true ? () => true : () => callerMatchesObservedMain(headers);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1083
1114
|
/**
|
|
1084
1115
|
* Resolve the credential for one Codex turn. A main hard-lock reached through unpinned pool
|
|
1085
1116
|
* selection is annotated with the earliest known pool recovery, so the refusal can name a retry
|
|
@@ -1172,8 +1203,7 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1172
1203
|
// Trusted substitution still has to claim and validate stored main below.
|
|
1173
1204
|
if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
1174
1205
|
if (!substituteStoredMain) {
|
|
1175
|
-
if (
|
|
1176
|
-
if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
|
|
1206
|
+
if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy);
|
|
1177
1207
|
assertCallerOwnedMainPoolNotCooled();
|
|
1178
1208
|
if (reserve) {
|
|
1179
1209
|
const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
|
|
@@ -1422,7 +1452,9 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1422
1452
|
// cool down. Hand back any trial first -- no Pool account is being sent to.
|
|
1423
1453
|
if (selected === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential) {
|
|
1424
1454
|
releaseTransientProbeGrant();
|
|
1425
|
-
|
|
1455
|
+
const context = await resolveCallerOwnedMainContext();
|
|
1456
|
+
console.info("[codex] Pool selected caller-owned main credential.");
|
|
1457
|
+
return context;
|
|
1426
1458
|
}
|
|
1427
1459
|
if (!selected) {
|
|
1428
1460
|
// A retry that excluded a failed Pool account may still use the validated caller-owned
|
|
@@ -1432,9 +1464,12 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1432
1464
|
if (
|
|
1433
1465
|
requestScopedMainCredential
|
|
1434
1466
|
&& fixedAccountId === undefined
|
|
1467
|
+
&& !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
|
|
1435
1468
|
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
|
|
1436
1469
|
) {
|
|
1437
|
-
|
|
1470
|
+
const context = await resolveCallerOwnedMainContext();
|
|
1471
|
+
console.info("[codex] Pool used caller-owned main credential: no selectable stored account.");
|
|
1472
|
+
return context;
|
|
1438
1473
|
}
|
|
1439
1474
|
if (fixedAccountId !== undefined) {
|
|
1440
1475
|
if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) {
|
|
@@ -1533,6 +1568,7 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1533
1568
|
// deferred credential must never become request auth through that fallback.
|
|
1534
1569
|
try {
|
|
1535
1570
|
assertCodexAccountValidationReady(accountId);
|
|
1571
|
+
if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
|
|
1536
1572
|
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
|
|
1537
1573
|
} catch (cause) {
|
|
1538
1574
|
// Nothing will reach upstream, so give the trial back instead of leaving the held account
|
|
@@ -1590,9 +1626,12 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1590
1626
|
// alternate is eligible. A validated caller may still serve this request,
|
|
1591
1627
|
// just as it can after an upstream rejection, without changing Pool state.
|
|
1592
1628
|
if (requestScopedMainCredential && fixedAccountId === undefined
|
|
1629
|
+
&& !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
|
|
1593
1630
|
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
|
|
1594
1631
|
&& !callerIsCooledPoolAccount(headers, config, accountId)) {
|
|
1595
|
-
|
|
1632
|
+
const context = await resolveCallerOwnedMainContext();
|
|
1633
|
+
console.info("[codex] Pool used caller-owned main credential: stored account cooldown.");
|
|
1634
|
+
return context;
|
|
1596
1635
|
}
|
|
1597
1636
|
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
|
|
1598
1637
|
}
|
|
@@ -1660,6 +1699,7 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1660
1699
|
try {
|
|
1661
1700
|
const token = await getValidCodexToken(accountId, { signal: options.signal });
|
|
1662
1701
|
assertCodexAccountValidationReady(accountId);
|
|
1702
|
+
if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
|
|
1663
1703
|
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
|
|
1664
1704
|
return bindPoolCreditPolicy({
|
|
1665
1705
|
kind: "pool",
|
|
@@ -1681,7 +1721,8 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1681
1721
|
releaseTransientProbeGrant();
|
|
1682
1722
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1683
1723
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1684
|
-
|
|
1724
|
+
// Post-refresh eligibility refusals do not invalidate a healthy credential.
|
|
1725
|
+
if (cause instanceof CodexPoolAccountCreditsOffError || cause instanceof CodexPoolAuthenticationError) throw cause;
|
|
1685
1726
|
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
1686
1727
|
markAccountNeedsReauth(accountId, writerGeneration);
|
|
1687
1728
|
}
|
|
@@ -1727,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error {
|
|
|
1727
1768
|
|
|
1728
1769
|
/** Dispatch identity comes only from an owned observation of the selected credential. */
|
|
1729
1770
|
function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined {
|
|
1730
|
-
const bearer = selected
|
|
1771
|
+
const bearer = authorizationCredential(selected);
|
|
1731
1772
|
if (!bearer) return undefined;
|
|
1732
1773
|
const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
|
|
1733
1774
|
return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration());
|
|
@@ -1752,6 +1793,8 @@ export function materializeCodexUpstreamAuth(
|
|
|
1752
1793
|
ctx: CodexAuthContext,
|
|
1753
1794
|
options: CodexAuthMaterializationOptions = {},
|
|
1754
1795
|
): Headers {
|
|
1796
|
+
// assertion_only must never be answered with a pool, stored-main, or other substituted Bearer.
|
|
1797
|
+
if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
|
|
1755
1798
|
if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
|
|
1756
1799
|
const selected = new Headers();
|
|
1757
1800
|
for (const name of FORWARD_HEADERS) {
|
|
@@ -1766,13 +1809,15 @@ export function materializeCodexUpstreamAuth(
|
|
|
1766
1809
|
if (ctx.kind === "main-pool") {
|
|
1767
1810
|
ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter);
|
|
1768
1811
|
assertMainAccountPolicy(options.config);
|
|
1812
|
+
ctx.mainQuotaDispatch = ctx.mainQuotaWriter && isMainQuotaWriterLive(ctx.mainQuotaWriter)
|
|
1813
|
+
? selectedMainQuotaDispatch(selected) : undefined;
|
|
1769
1814
|
}
|
|
1770
1815
|
assertMaterializedReserve(selected, ctx, options);
|
|
1771
1816
|
return selected;
|
|
1772
1817
|
}
|
|
1773
1818
|
if (ctx.kind === "main" && options.substituteMainCredential !== true
|
|
1774
1819
|
&& !selected.has("chatgpt-account-id")) {
|
|
1775
|
-
const bearer = selected
|
|
1820
|
+
const bearer = authorizationCredential(selected);
|
|
1776
1821
|
const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
|
|
1777
1822
|
if (accountId) selected.set("chatgpt-account-id", accountId);
|
|
1778
1823
|
}
|
|
@@ -1850,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync(
|
|
|
1850
1895
|
ctx: CodexAuthContext,
|
|
1851
1896
|
options: CodexAuthMaterializationOptions = {},
|
|
1852
1897
|
): Promise<Headers> {
|
|
1898
|
+
if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
|
|
1853
1899
|
if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
|
|
1854
1900
|
if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) {
|
|
1855
1901
|
return materializeReserveUpstreamAuth(headers, ctx, options);
|
|
@@ -809,6 +809,10 @@ export function normalizeRoutedCatalogEntry(
|
|
|
809
809
|
delete entry.multi_agent_version;
|
|
810
810
|
delete entry.multi_agent_reasoning_effort;
|
|
811
811
|
delete entry.use_responses_lite;
|
|
812
|
+
// Codex 0.164 sends positional `configuration_update` effort items only for rows declaring this
|
|
813
|
+
// and freezes the request-level effort for the context window. A routed provider never
|
|
814
|
+
// negotiated that contract, so a row cloned from a GPT-6 template must not inherit it.
|
|
815
|
+
delete entry.supports_reasoning_effort_updates;
|
|
812
816
|
delete entry.supports_websockets;
|
|
813
817
|
// Routed rows cloned from native templates must not inherit OpenAI-only experimental context
|
|
814
818
|
// delivery. Codex reads the flag as "this model accepts experimental context history" and drives
|
|
@@ -213,9 +213,15 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget
|
|
|
213
213
|
* reverse: they are output paths, so ambient values would write Node artifacts to
|
|
214
214
|
* arbitrary external directories on exit. ELF/Darwin loader controls (LD_* and
|
|
215
215
|
* DYLD_*) are also removed: they can load code or redirect loader output before
|
|
216
|
-
* npm runs.
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
* npm runs. OpenSSL's startup inputs are removed with them: OPENSSL_CONF names
|
|
217
|
+
* a configuration file the crypto library reads at startup, OPENSSL_CONF_INCLUDE
|
|
218
|
+
* resolves relative includes inside that configuration, and OPENSSL_MODULES /
|
|
219
|
+
* OPENSSL_ENGINES name the directories its provider and engine search loads
|
|
220
|
+
* shared modules from. None of these is input the updater child needs, so
|
|
221
|
+
* ambient values stay out of the spawned npm. This is a denylist for
|
|
222
|
+
* process-startup/output channels, not a claim that every ambient variable is
|
|
223
|
+
* allowlisted. Ordinary OS environment, proxy settings and the supported
|
|
224
|
+
* NODE_EXTRA_CA_CERTS extension remain available.
|
|
219
225
|
*/
|
|
220
226
|
const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
|
|
221
227
|
"node_options",
|
|
@@ -227,6 +233,15 @@ const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
|
|
|
227
233
|
// escaping the owned temp root regardless of the isolated --cache.
|
|
228
234
|
"node_v8_coverage",
|
|
229
235
|
"node_redirect_warnings",
|
|
236
|
+
// OpenSSL startup inputs: OPENSSL_CONF points the library at an ambient
|
|
237
|
+
// startup configuration file (OPENSSL_CONF_INCLUDE resolves that config's
|
|
238
|
+
// relative includes); OPENSSL_MODULES and OPENSSL_ENGINES point its
|
|
239
|
+
// provider/engine search at ambient directories that supply loadable
|
|
240
|
+
// shared modules.
|
|
241
|
+
"openssl_conf",
|
|
242
|
+
"openssl_conf_include",
|
|
243
|
+
"openssl_modules",
|
|
244
|
+
"openssl_engines",
|
|
230
245
|
]);
|
|
231
246
|
|
|
232
247
|
function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt";
|
|
2
|
+
import type { CodexAccount, OcxConfig } from "../types";
|
|
3
|
+
import { getCodexAccountCredential } from "./account-store";
|
|
4
|
+
import type { CodexTokens } from "./auth-collision";
|
|
5
|
+
import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache";
|
|
6
|
+
import { codexPlanKey, extractChatgptPlanType } from "./plan";
|
|
7
|
+
|
|
8
|
+
export interface CodexCollisionIdentity {
|
|
9
|
+
accountId: string;
|
|
10
|
+
email?: string | null;
|
|
11
|
+
plan?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizedEmail(email: string | null | undefined): string | undefined {
|
|
15
|
+
return email?.trim().toLowerCase() || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function workspacePlan(plan: unknown): boolean {
|
|
19
|
+
return /team|business|enterprise|workspace|edu/.test(codexPlanKey(plan) ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Missing member evidence cannot establish a distinct subscription within one plan bucket. */
|
|
23
|
+
export function sameCodexCollisionIdentity(a: CodexCollisionIdentity, b: CodexCollisionIdentity): boolean {
|
|
24
|
+
if (!a.accountId || a.accountId !== b.accountId || workspacePlan(a.plan) !== workspacePlan(b.plan)) return false;
|
|
25
|
+
const aEmail = normalizedEmail(a.email);
|
|
26
|
+
const bEmail = normalizedEmail(b.email);
|
|
27
|
+
return !aEmail || !bEmail || aEmail === bEmail;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Ambient scope comparison is memory-only, including when the default-home resolver would probe WSL.
|
|
31
|
+
function homeScope(): string {
|
|
32
|
+
return JSON.stringify([process.env.CODEX_HOME, process.env.HOME, process.env.USERPROFILE, process.env.OPENCODEX_HOME]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined;
|
|
36
|
+
|
|
37
|
+
/** Only existing owned lifecycle observations may publish; never call with a request bearer. */
|
|
38
|
+
export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void {
|
|
39
|
+
if (!tokens) { observed = undefined; return; }
|
|
40
|
+
if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
|
|
41
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return;
|
|
42
|
+
const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
|
|
43
|
+
if (claims.some(claim => claim.conflict)) return;
|
|
44
|
+
const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId;
|
|
45
|
+
if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return;
|
|
46
|
+
const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
|
|
47
|
+
.flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : []));
|
|
48
|
+
if (emails.size > 1) return;
|
|
49
|
+
observed = { identity: { accountId, email: [...emails][0],
|
|
50
|
+
plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) },
|
|
51
|
+
homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Compare known identity only. External logins become visible at the next owned observation. */
|
|
55
|
+
export function getObservedMainCodexCollisionIdentity(): Readonly<CodexCollisionIdentity> | undefined {
|
|
56
|
+
return observed?.homeScope === homeScope() && isMainAccountIdentityGenerationLive(observed.generation)
|
|
57
|
+
? observed.identity : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isStoredCodexMainDuplicate(account: CodexAccount): boolean {
|
|
61
|
+
const main = getObservedMainCodexCollisionIdentity();
|
|
62
|
+
if (account.isMain || !main) return false;
|
|
63
|
+
const credential = getCodexAccountCredential(account.id);
|
|
64
|
+
if (!credential?.chatgptAccountId) return false;
|
|
65
|
+
const storedEmail = normalizedEmail(account.email);
|
|
66
|
+
const email = storedEmail && storedEmail !== normalizedEmail(account.id)
|
|
67
|
+
? storedEmail : extractEmail(undefined, credential.accessToken);
|
|
68
|
+
return sameCodexCollisionIdentity({ accountId: credential.chatgptAccountId, email,
|
|
69
|
+
plan: codexPlanKey(account.plan) ? account.plan : extractChatgptPlanType(undefined, credential.accessToken) }, main);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const CODEX_MAIN_DUPLICATE_ACTION =
|
|
73
|
+
"Use the native Codex main account, or sign native Codex into a different account to use this stored Pool entry.";
|
|
74
|
+
|
|
75
|
+
export function configuredCodexMainDuplicate(config: Pick<OcxConfig, "codexAccounts">, accountId: string): boolean {
|
|
76
|
+
const account = config.codexAccounts?.find(row => row.id === accountId);
|
|
77
|
+
return !!account && isStoredCodexMainDuplicate(account);
|
|
78
|
+
}
|
|
@@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE);
|
|
|
10
10
|
// Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load
|
|
11
11
|
// System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343).
|
|
12
12
|
const DPAPI_TIMEOUT_MS = 20_000;
|
|
13
|
+
// Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json)
|
|
14
|
+
// sends it through command discovery and module auto-loading, which without a module-analysis cache
|
|
15
|
+
// for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile
|
|
16
|
+
// and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on.
|
|
17
|
+
// Assembly loading, string splitting and base64 are plain .NET calls outside command discovery.
|
|
18
|
+
// The request is "<operation>\n<base64 data>" on stdin.
|
|
13
19
|
const SCRIPT = [
|
|
14
20
|
"$ErrorActionPreference='Stop'",
|
|
15
|
-
"
|
|
16
|
-
"$r=[Console]::In.ReadToEnd()
|
|
17
|
-
"
|
|
21
|
+
"[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')",
|
|
22
|
+
"$r=[Console]::In.ReadToEnd().Split([char]10)",
|
|
23
|
+
"if($r.Length -ne 2){throw 'Invalid request'}",
|
|
24
|
+
"$data=[Convert]::FromBase64String($r[1])",
|
|
18
25
|
`if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
|
|
19
26
|
`$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
|
|
20
27
|
"$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
21
|
-
"if($r
|
|
28
|
+
"if($r[0] -ceq 'protect'){",
|
|
22
29
|
" $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
|
|
23
|
-
"}elseif($r
|
|
30
|
+
"}elseif($r[0] -ceq 'unprotect'){",
|
|
24
31
|
" $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
|
|
25
32
|
"}else{throw 'Invalid operation'}",
|
|
26
33
|
"[Console]::Out.Write([Convert]::ToBase64String($out))",
|
|
@@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs =
|
|
|
126
133
|
resolve(bytes);
|
|
127
134
|
});
|
|
128
135
|
child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
|
|
129
|
-
child.stdin?.end(
|
|
136
|
+
child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`);
|
|
130
137
|
});
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut
|
|
|
163
170
|
export function usesNativeDpapiForTests(): boolean {
|
|
164
171
|
return process.platform === "win32" && loadNativeDpapi() !== null;
|
|
165
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */
|
|
175
|
+
export function powerShellDpapiScriptForTests(): string {
|
|
176
|
+
return SCRIPT;
|
|
177
|
+
}
|
|
@@ -121,3 +121,17 @@ export function getMainAccountHardLockStatus(
|
|
|
121
121
|
export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
|
|
122
122
|
return getMainAccountHardLockStatus(config, now).state === "blocked";
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Scheduling evidence only: the latest blocking reset even after it has passed, so recovery can
|
|
127
|
+
* notice a revised reset that expired between sweeps. Never an admission or unlock signal.
|
|
128
|
+
*/
|
|
129
|
+
export function latestMainBlockingResetAt(config: PolicyConfig): number | undefined {
|
|
130
|
+
if (!isMainAccountHardLockEnabled(config)) return undefined;
|
|
131
|
+
const quota = getMainPolicyQuota();
|
|
132
|
+
if (!quota) return undefined;
|
|
133
|
+
const thresholds = resolveMainAccountHardLockThresholds(config);
|
|
134
|
+
const resets = governingWindows(quota)
|
|
135
|
+
.filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]).map(w => resetTimestamp(w.resetAt));
|
|
136
|
+
return resets.length > 0 && resets.every(r => r !== undefined) ? Math.max(...(resets as number[])) : undefined;
|
|
137
|
+
}
|
|
@@ -92,6 +92,13 @@ export class CodexQuotaHistory {
|
|
|
92
92
|
return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */
|
|
96
|
+
peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined {
|
|
97
|
+
const bucket = this.accounts.get(accountId);
|
|
98
|
+
if (!identity || !bucket || bucket.identity !== identity) return undefined;
|
|
99
|
+
return bucket.samples.at(-1)?.source;
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
clear(accountId?: string): number {
|
|
96
103
|
if (accountId === undefined) {
|
|
97
104
|
const count = this.accounts.size;
|
package/src/codex/quota.ts
CHANGED
|
@@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_
|
|
|
1099
1099
|
return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
|
|
1100
1100
|
truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
|
|
1101
1101
|
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Where the newest stored reading for a pool account came from (a WHAM usage body or upstream
|
|
1105
|
+
* response headers), or undefined when no current history exists. Read-only: unlike
|
|
1106
|
+
* `getAccountQuotaHistory` it never prunes or clears a mismatched bucket.
|
|
1107
|
+
*/
|
|
1108
|
+
export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined {
|
|
1109
|
+
hydrateAccountQuotasFromDisk();
|
|
1110
|
+
return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId));
|
|
1111
|
+
}
|
|
@@ -6,6 +6,7 @@ import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
|
6
6
|
import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
|
|
7
7
|
import type { CodexQuotaScope } from "./health-store";
|
|
8
8
|
import type { TransientProbeGrant } from "./thread-affinity";
|
|
9
|
+
import type { MainQuotaDispatch } from "../main-account-cache";
|
|
9
10
|
|
|
10
11
|
export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
|
|
11
12
|
|
|
@@ -99,6 +100,10 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
99
100
|
* means the caller cannot supply lineage and the historical unfenced handling stands.
|
|
100
101
|
*/
|
|
101
102
|
credentialGeneration?: number;
|
|
103
|
+
/** Main credential proof captured before dispatch; null means no owned proof was available. */
|
|
104
|
+
mainFailureWindowDispatch?: MainQuotaDispatch | null;
|
|
105
|
+
/** Pool sample provenance retained by a terminal callback even if its context is reused. */
|
|
106
|
+
failureWindowCredentialGeneration?: number;
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
export function computeCodexUsageScore(quota: {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { pinnedCodexAccountId } from "../account-priority";
|
|
2
2
|
import type { OcxConfig } from "../../types";
|
|
3
|
+
import { beginCodexAccountGenerationLiveCheck, isCodexAccountGenerationLive } from "../account-store";
|
|
4
|
+
import { isMainQuotaDispatchLive } from "../main-account-cache";
|
|
5
|
+
import type { CodexAuthContext } from "../auth-context";
|
|
6
|
+
import type { CodexUpstreamOutcomeMeta } from "./cooldown-math";
|
|
3
7
|
import {
|
|
4
8
|
deleteAccountHealth,
|
|
5
9
|
setAccountHealth,
|
|
@@ -14,12 +18,31 @@ export const CODEX_SLIDING_FAILURE_RECOVER_RATIO = 0.1;
|
|
|
14
18
|
export const CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS = 30_000;
|
|
15
19
|
const MAX_SAMPLES = 512;
|
|
16
20
|
|
|
17
|
-
type Sample = { at: number; failed: boolean };
|
|
18
|
-
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number };
|
|
21
|
+
type Sample = { at: number; failed: boolean; credentialGeneration?: number };
|
|
22
|
+
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number; credentialGeneration?: number };
|
|
19
23
|
|
|
20
24
|
const windows = new Map<string, WindowState>();
|
|
21
25
|
const pinnedDegradeWarned = new Set<string>();
|
|
22
26
|
|
|
27
|
+
/** Read only dispatch-time provenance retained by auth materialization, never recapture at arrival. */
|
|
28
|
+
export function codexFailureWindowOutcomeMeta(
|
|
29
|
+
context: CodexAuthContext,
|
|
30
|
+
): Pick<CodexUpstreamOutcomeMeta, "failureWindowCredentialGeneration" | "mainFailureWindowDispatch"> {
|
|
31
|
+
if (context.kind === "pool") return { failureWindowCredentialGeneration: context.generation };
|
|
32
|
+
return context.kind === "main-pool" ? { mainFailureWindowDispatch: context.mainQuotaDispatch ?? null } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fence window samples only; other health and recovery-lease semantics stay with their owners. */
|
|
36
|
+
export function noteCodexFailureWindowOutcome(
|
|
37
|
+
config: OcxConfig, accountId: string, failed: boolean, now: number, meta: CodexUpstreamOutcomeMeta,
|
|
38
|
+
): void {
|
|
39
|
+
if (meta.mainFailureWindowDispatch !== undefined
|
|
40
|
+
&& (!meta.mainFailureWindowDispatch || !isMainQuotaDispatchLive(meta.mainFailureWindowDispatch))) return;
|
|
41
|
+
const generation = meta.failureWindowCredentialGeneration ?? meta.credentialGeneration;
|
|
42
|
+
if (generation !== undefined && !isCodexAccountGenerationLive(accountId, generation)) return;
|
|
43
|
+
appendSample(config, accountId, failed, now, generation);
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
export function codexFailureWindowEnabled(config: OcxConfig): boolean {
|
|
24
47
|
return config.codexFailureWindow !== false;
|
|
25
48
|
}
|
|
@@ -33,11 +56,27 @@ function stateFor(accountId: string): WindowState {
|
|
|
33
56
|
return state;
|
|
34
57
|
}
|
|
35
58
|
|
|
36
|
-
function recompute(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
function recompute(
|
|
60
|
+
accountId: string, state: WindowState, now: number,
|
|
61
|
+
generationLive?: ReturnType<typeof beginCodexAccountGenerationLiveCheck>,
|
|
62
|
+
): void {
|
|
63
|
+
// One store snapshot per insertion/read, never one file load per retained sample. The
|
|
64
|
+
// unlocked insertion precheck can race another process; provenance must survive it.
|
|
65
|
+
const tagged = state.credentialGeneration !== undefined
|
|
66
|
+
|| state.samples.some(sample => sample.credentialGeneration !== undefined);
|
|
67
|
+
const live = generationLive ?? (tagged ? beginCodexAccountGenerationLiveCheck() : undefined);
|
|
68
|
+
// Unknown evidence is not a credential removal: preserve samples and all derived state.
|
|
69
|
+
if (live?.snapshotStatus === "failed") return;
|
|
70
|
+
if (state.credentialGeneration !== undefined && !live?.(accountId, state.credentialGeneration)) {
|
|
71
|
+
state.degraded = false;
|
|
72
|
+
state.healthySince = undefined;
|
|
73
|
+
state.credentialGeneration = undefined;
|
|
74
|
+
pinnedDegradeWarned.delete(accountId);
|
|
40
75
|
}
|
|
76
|
+
const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
|
|
77
|
+
state.samples = state.samples.filter(sample => sample.at > cutoff
|
|
78
|
+
&& (sample.credentialGeneration === undefined || live?.(accountId, sample.credentialGeneration)));
|
|
79
|
+
state.credentialGeneration ??= state.samples.find(sample => sample.credentialGeneration !== undefined)?.credentialGeneration;
|
|
41
80
|
const samples = state.samples.length;
|
|
42
81
|
const failed = state.samples.reduce((count, sample) => count + (sample.failed ? 1 : 0), 0);
|
|
43
82
|
const degraded = samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
|
|
@@ -71,19 +110,32 @@ export function noteCodexFailureWindowSample(
|
|
|
71
110
|
accountId: string,
|
|
72
111
|
failed: boolean,
|
|
73
112
|
now: number,
|
|
113
|
+
): void {
|
|
114
|
+
appendSample(config, accountId, failed, now);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function appendSample(
|
|
118
|
+
config: OcxConfig, accountId: string, failed: boolean, now: number, credentialGeneration?: number,
|
|
74
119
|
): void {
|
|
75
120
|
if (!codexFailureWindowEnabled(config)) return;
|
|
121
|
+
const existing = windows.get(accountId);
|
|
122
|
+
const tagged = credentialGeneration !== undefined || existing?.credentialGeneration !== undefined
|
|
123
|
+
|| existing?.samples.some(sample => sample.credentialGeneration !== undefined);
|
|
124
|
+
const live = tagged ? beginCodexAccountGenerationLiveCheck() : undefined;
|
|
125
|
+
if (live?.snapshotStatus === "failed") return;
|
|
126
|
+
// A stale arrival is not an ownership transition and must not delete a live replacement's state.
|
|
127
|
+
if (credentialGeneration !== undefined && !live?.(accountId, credentialGeneration)) return;
|
|
76
128
|
const state = stateFor(accountId);
|
|
77
|
-
state.samples.push({ at: now, failed });
|
|
129
|
+
state.samples.push({ at: now, failed, ...(credentialGeneration === undefined ? {} : { credentialGeneration }) });
|
|
78
130
|
if (state.samples.length > MAX_SAMPLES) state.samples.splice(0, state.samples.length - MAX_SAMPLES);
|
|
79
|
-
recompute(state, now);
|
|
131
|
+
recompute(accountId, state, now, live);
|
|
80
132
|
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
|
|
81
133
|
}
|
|
82
134
|
|
|
83
135
|
export function isCodexFailureWindowDegraded(accountId: string, now = Date.now()): boolean {
|
|
84
136
|
const state = windows.get(accountId);
|
|
85
137
|
if (!state) return false;
|
|
86
|
-
recompute(state, now);
|
|
138
|
+
recompute(accountId, state, now);
|
|
87
139
|
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
|
|
88
140
|
return state.degraded;
|
|
89
141
|
}
|
package/src/codex/routing.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
failureWindowSteersNewThreads,
|
|
24
24
|
forgetCodexFailureWindow,
|
|
25
25
|
forgetCodexFailureWindows,
|
|
26
|
-
|
|
26
|
+
noteCodexFailureWindowOutcome,
|
|
27
27
|
settleCodexSuccessStreak,
|
|
28
28
|
} from "./routing/failure-window";
|
|
29
29
|
|
|
@@ -220,6 +220,7 @@ export {
|
|
|
220
220
|
isEffectiveCodexAccountPinned,
|
|
221
221
|
} from "./routing/active-account";
|
|
222
222
|
export { codexAccountPinDrainReason } from "./routing/pin-drain";
|
|
223
|
+
export { codexFailureWindowOutcomeMeta } from "./routing/failure-window";
|
|
223
224
|
export type { CodexPinDrainReason } from "./routing/pin-drain";
|
|
224
225
|
|
|
225
226
|
// A shared refresh can outlive the request that opened it. Register the affinity
|
|
@@ -1305,7 +1306,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1305
1306
|
*/
|
|
1306
1307
|
dropSpentCredentialFailure(accountId);
|
|
1307
1308
|
if (outcomeClass === "success") {
|
|
1308
|
-
|
|
1309
|
+
noteCodexFailureWindowOutcome(config, accountId, false, now, meta);
|
|
1309
1310
|
// The operator's one-shot is spent by a dispatch that actually worked, and only by that.
|
|
1310
1311
|
// A failed lookup leaves it unspent so the intent survives the failure.
|
|
1311
1312
|
consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
|
|
@@ -1555,7 +1556,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1555
1556
|
// A transient failure concludes an owning probe; an unrelated 5xx must not
|
|
1556
1557
|
// consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
|
|
1557
1558
|
const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
|
|
1558
|
-
|
|
1559
|
+
noteCodexFailureWindowOutcome(config, accountId, true, now, meta);
|
|
1559
1560
|
const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
|
|
1560
1561
|
const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
|
|
1561
1562
|
// Soft avoid + affinity clears are part of failover. When threshold is 0, leave
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
-
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
2
|
+
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
3
3
|
import { readStateResult, stateFiles, statePath } from "./shim-state-file";
|
|
4
4
|
import { overlayDiagnostic } from "./shim-overlay";
|
|
5
5
|
|
|
@@ -16,6 +16,7 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
|
|
|
16
16
|
const content = readFileSync(path, "utf8");
|
|
17
17
|
if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
|
|
18
18
|
if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
|
|
19
|
+
if (platform === "win32" && !content.includes(WINDOWS_SHIM_REVISION_MARKER) && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
|
|
19
20
|
if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
|
|
20
21
|
return true;
|
|
21
22
|
} catch {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
unlinkSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { posix, win32 } from "node:path";
|
|
16
|
-
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
16
|
+
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
17
17
|
import type { ShimFileState } from "./shim-state-file";
|
|
18
18
|
|
|
19
19
|
const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
|
|
@@ -188,6 +188,15 @@ function isCurrentUnixShimProbe(probe: StableShimPathProbe): boolean {
|
|
|
188
188
|
return probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/** A Windows wrapper is current when it carries its flavor's revision (CMD/PowerShell, or Git-Bash's Unix one). */
|
|
192
|
+
function isCurrentWindowsShimProbe(probe: StableShimPathProbe): boolean {
|
|
193
|
+
return probe.prefix.includes(WINDOWS_SHIM_REVISION_MARKER) || probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isCurrentShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean {
|
|
197
|
+
return platform === "win32" ? isCurrentWindowsShimProbe(probe) : isCurrentUnixShimProbe(probe);
|
|
198
|
+
}
|
|
199
|
+
|
|
191
200
|
function hasUsableBackingPath(file: ShimFileState): boolean {
|
|
192
201
|
return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath]
|
|
193
202
|
.some(path => {
|
|
@@ -225,4 +234,4 @@ export function isVersionManagerOwnedCodexPath(
|
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
export type { ShimPathFingerprint, StableShimPathProbe };
|
|
228
|
-
export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, hasUsableBackingPath };
|
|
237
|
+
export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, isCurrentShimProbe, hasUsableBackingPath };
|