@bitkyc08/opencodex 2.90.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.
Files changed (132) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
  3. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
  5. package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  10. package/src/adapters/openai-responses/passthrough.ts +2 -1
  11. package/src/adapters/openai-responses/request-strips.ts +32 -0
  12. package/src/adapters/openai-responses/transport.ts +6 -0
  13. package/src/chat/inbound.ts +20 -2
  14. package/src/chat/outbound.ts +36 -4
  15. package/src/chat/reasoning-details.ts +137 -0
  16. package/src/cli/capabilities-agents-routing.ts +50 -0
  17. package/src/cli/capabilities-base.ts +15 -2
  18. package/src/cli/capabilities-observe-system.ts +4 -4
  19. package/src/cli/codex-shim-autorestore.ts +5 -0
  20. package/src/cli/dispatch.ts +30 -0
  21. package/src/cli/fallback.ts +127 -0
  22. package/src/cli/help.ts +1 -0
  23. package/src/cli/index.ts +1 -2
  24. package/src/cli/link-apply-restart.ts +72 -0
  25. package/src/cli/link-child.ts +6 -2
  26. package/src/cli/link-cleanup.ts +95 -0
  27. package/src/cli/link-enrollment.ts +4 -0
  28. package/src/cli/link.ts +84 -9
  29. package/src/cli/provider.ts +44 -2
  30. package/src/cli/registry.ts +13 -1
  31. package/src/cli/restart-failure.ts +4 -0
  32. package/src/cli/runtime-api.ts +1 -1
  33. package/src/cli/status-codex-settings.ts +56 -0
  34. package/src/cli/status-fallback.ts +29 -0
  35. package/src/cli/status-install.ts +52 -0
  36. package/src/cli/status.ts +20 -1
  37. package/src/cli/system-restart-client.ts +32 -1
  38. package/src/cli/update-restart-child.ts +4 -3
  39. package/src/cli/update-restart-home.ts +20 -8
  40. package/src/client/connect.ts +82 -6
  41. package/src/client/home-unreachable.ts +16 -0
  42. package/src/client/hub-relay.ts +8 -2
  43. package/src/client/link-home.ts +119 -6
  44. package/src/client/link-parity-reporter.ts +107 -0
  45. package/src/client/link-relay-transport.ts +8 -1
  46. package/src/client/link-relay.ts +27 -3
  47. package/src/client/machine-listener.ts +83 -1
  48. package/src/client/runtime-restart.ts +127 -0
  49. package/src/client/runtime.ts +137 -25
  50. package/src/codex/auth-context.ts +10 -6
  51. package/src/codex/catalog/parsing.ts +4 -0
  52. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  53. package/src/codex/quota-history.ts +7 -0
  54. package/src/codex/quota.ts +10 -0
  55. package/src/codex/subagent-model-fallback.ts +24 -1
  56. package/src/combos/index.ts +1 -0
  57. package/src/combos/resolve.ts +12 -0
  58. package/src/config/diagnostics.ts +5 -0
  59. package/src/config/load-degrade.ts +2 -0
  60. package/src/config/schema/config-schema.ts +3 -0
  61. package/src/config/schema/fallback-policy.ts +40 -0
  62. package/src/generated/compatibility-version.json +205 -97
  63. package/src/lib/authorization-scheme.ts +95 -0
  64. package/src/lib/provider-client-headers.ts +1 -0
  65. package/src/lib/redact.ts +2 -2
  66. package/src/lib/system-restart-contract.ts +24 -0
  67. package/src/lib/test-home-guard.ts +78 -11
  68. package/src/link/child-parity.ts +124 -0
  69. package/src/link/codex-settings.ts +37 -0
  70. package/src/link/compatibility.ts +119 -0
  71. package/src/link/management-routes.ts +2 -0
  72. package/src/link/peer-report.ts +100 -0
  73. package/src/link/remote-platform.ts +78 -0
  74. package/src/link/ssh-argv.ts +19 -0
  75. package/src/link/status-projection.ts +24 -2
  76. package/src/oauth/index.ts +1 -1
  77. package/src/oauth/kimi.ts +2 -0
  78. package/src/oauth/nous.ts +4 -1
  79. package/src/oauth/store.ts +2 -1
  80. package/src/protocols/codecs/chat.ts +3 -3
  81. package/src/protocols/encoders/adapter-events.ts +26 -2
  82. package/src/protocols/encoders/chat.ts +55 -3
  83. package/src/providers/mistral-transport.ts +37 -4
  84. package/src/providers/quota/antigravity.ts +2 -8
  85. package/src/quota-pool/assemble.ts +326 -0
  86. package/src/quota-pool/contract.ts +58 -0
  87. package/src/quota-pool/identity.ts +203 -0
  88. package/src/remote/features.ts +74 -0
  89. package/src/remote/protocol.ts +2 -0
  90. package/src/remote/served-by.ts +57 -0
  91. package/src/responses/parser.ts +7 -0
  92. package/src/responses/reasoning-envelope.ts +12 -0
  93. package/src/routing/fallback-policy.ts +290 -0
  94. package/src/routing/fallback-state.ts +179 -0
  95. package/src/server/audio-client.ts +2 -1
  96. package/src/server/audio-dictation.ts +2 -1
  97. package/src/server/audio-upstream.ts +2 -1
  98. package/src/server/auth-cors.ts +6 -5
  99. package/src/server/background-lifecycle.ts +4 -0
  100. package/src/server/chat-completions.ts +8 -1
  101. package/src/server/gui-session.ts +2 -1
  102. package/src/server/index/link-listener.ts +11 -1
  103. package/src/server/index/link-management.ts +47 -0
  104. package/src/server/index/link-relay-sessions.ts +11 -2
  105. package/src/server/index/optional-listeners.ts +7 -2
  106. package/src/server/index/serve-options.ts +19 -2
  107. package/src/server/live.ts +5 -0
  108. package/src/server/management/context.ts +1 -1
  109. package/src/server/management/fallback-routes.ts +75 -0
  110. package/src/server/management/link-enrollment.ts +21 -3
  111. package/src/server/management/link-route-state.ts +2 -0
  112. package/src/server/management/link-routes.ts +12 -1
  113. package/src/server/management/quota-pool-routes.ts +24 -0
  114. package/src/server/management/route-registry.ts +3 -0
  115. package/src/server/management-api.ts +16 -0
  116. package/src/server/management-auth.ts +2 -1
  117. package/src/server/responses/agent-task-recovery.ts +1 -1
  118. package/src/server/responses/compact.ts +2 -2
  119. package/src/server/responses/core-auth.ts +11 -1
  120. package/src/server/responses/core-combo.ts +72 -1
  121. package/src/server/responses/encrypted-payload.ts +3 -3
  122. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  123. package/src/server/responses/policy-fallback.ts +44 -1
  124. package/src/server/responses/request-prepare.ts +9 -1
  125. package/src/server/restart-replacement.ts +50 -2
  126. package/src/server/ws-bridge.ts +5 -0
  127. package/src/service/cli.ts +25 -2
  128. package/src/service/diagnostics.ts +20 -6
  129. package/src/service/status-json.ts +116 -0
  130. package/src/types/config.ts +7 -0
  131. package/src/types/fallback.ts +38 -0
  132. package/gui/dist/assets/App-dnkJ85te.css +0 -1
@@ -73,6 +73,7 @@ import {
73
73
  } from "./gui-session";
74
74
  import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth";
75
75
  import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth";
76
+ import { authorizationCredential } from "../lib/authorization-scheme";
76
77
  export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session";
77
78
 
78
79
  const LOCAL_READ_REPLAY_LIMIT = 256;
@@ -545,7 +546,7 @@ function hasGuiPairCapability(
545
546
 
546
547
  function requestManagementCredential(req: Request): string | null {
547
548
  return req.headers.get("x-opencodex-api-key")?.trim()
548
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
549
+ || authorizationCredential(req.headers)
549
550
  || null;
550
551
  }
551
552
 
@@ -104,7 +104,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
104
104
  const type = input[itemIndex] && typeof input[itemIndex] === "object"
105
105
  ? (input[itemIndex] as { type?: unknown }).type
106
106
  : undefined;
107
- if (type !== "compaction_trigger" && type !== "additional_tools") break;
107
+ if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
108
108
  itemIndex -= 1;
109
109
  }
110
110
 
@@ -135,7 +135,7 @@ import type { DataPlaneAdmission } from "../auth-cors";
135
135
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
136
136
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
137
137
  import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
138
- import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
138
+ import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
139
139
  import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning";
140
140
  import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough";
141
141
  import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination";
@@ -1155,7 +1155,7 @@ export async function handleResponsesCompact(
1155
1155
  replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody;
1156
1156
  }
1157
1157
  return isOpenAiOperatedResponsesDestination(sendProvider)
1158
- ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody);
1158
+ ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody));
1159
1159
  };
1160
1160
  const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout(
1161
1161
  compactUrl,
@@ -46,6 +46,7 @@ import { forceRefreshMainAccountToken } from "../../codex/main-account";
46
46
  import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy";
47
47
  import { safeCallerSessionId } from "../caller-session-identity";
48
48
  import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
49
+ import { AgentAssertionUnsupportedError, agentAssertionUnsupportedResponse, authorizationCredential, carryAgentAssertionForCallerBearer, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
49
50
 
50
51
  /** Normalize caller aliases only on native egress; preserve ingress and fallback identity. */
51
52
  export function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers {
@@ -205,6 +206,9 @@ export async function resolveResponsesCodexAuth(
205
206
  retainAccountForUploadedFiles = false,
206
207
  ): Promise<ResponsesAuthResolution> {
207
208
  let authCtx: CodexAuthContext | undefined;
209
+ // Ingress already refuses assertion_only; a later internal hop must not reach a pool or
210
+ // routed credential with it either, because that would be a silent credential swap.
211
+ if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() };
208
212
  try {
209
213
  let authInputHeaders = codexRouteCredentialDomainHeaders(
210
214
  req,
@@ -332,7 +336,7 @@ export async function resolveResponsesCodexAuth(
332
336
  } else {
333
337
  // Even adapters that ignore caller auth must not retain a proxy secret for
334
338
  // a later internal hop or a future transport change.
335
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
339
+ const bearer = authorizationCredential(headers);
336
340
  if (bearer && isProxyAdmissionSecret(bearer, config)) {
337
341
  headers.delete("authorization");
338
342
  headers.delete("chatgpt-account-id");
@@ -343,6 +347,11 @@ export async function resolveResponsesCodexAuth(
343
347
  if (options.compactionRecoveryKind === "compaction-v1") {
344
348
  copyResponsesClientMetadata(headers, authInputHeaders, route.provider);
345
349
  }
350
+ // dual_header: the assertion is first-party only and travels only beside the caller's own
351
+ // unchanged Bearer, compared with the ORIGINAL request (injected credentials do not count).
352
+ if (isCanonicalOpenAiForwardProvider(route.provider) && authCtx?.kind === "main" && !substituteMainCredential) {
353
+ carryAgentAssertionForCallerBearer(headers, req.headers);
354
+ }
346
355
  return {
347
356
  ok: true,
348
357
  authCtx,
@@ -364,6 +373,7 @@ export async function resolveResponsesCodexAuth(
364
373
  if (err instanceof ForwardAdmissionCredentialError) {
365
374
  return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
366
375
  }
376
+ if (err instanceof AgentAssertionUnsupportedError) return { ok: false, response: agentAssertionUnsupportedResponse() };
367
377
  const response = mapCodexAuthContextErrorToResponse(err, {
368
378
  accountSelector: route.codexAccountNamespace,
369
379
  now: Date.now(),
@@ -1,6 +1,7 @@
1
1
  import { effectiveAzureOutputBudget } from "../../adapters/azure";
2
2
  import { resolveWireProtocolOverride } from "../adapter-resolve";
3
- import type { CodexAccountModelRefusal } from "../../combos/failover";
3
+ import { comboCooldownRetryAfterSeconds, type CodexAccountModelRefusal } from "../../combos/failover";
4
+ import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
4
5
  import {
5
6
  isCodexReasoningEffort,
6
7
  isDeclaredReasoningEffort,
@@ -32,6 +33,7 @@ import {
32
33
  isComboTargetInCooldown,
33
34
  snapshotComboQuotaCooldowns,
34
35
  comboExclusionReasons,
36
+ comboTargetSkipTrigger,
35
37
  noteComboSuccess,
36
38
  comboFailureDecision,
37
39
  advanceComboAfterFailure,
@@ -124,6 +126,20 @@ import { clientWireOf } from "../inference/client-wire";
124
126
  */
125
127
  export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance;
126
128
 
129
+ /**
130
+ * OCX-11: where a combo target physically dispatches, after provider aliases and
131
+ * blockedModelRedirects. A target that does not resolve keeps its configured selector; its
132
+ * dispatch fails on its own.
133
+ */
134
+ function resolvedComboDestination(config: OcxConfig, target: { provider: string; model: string }): { provider: string; model: string } {
135
+ try {
136
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
137
+ return { provider: route.providerName, model: route.modelId };
138
+ } catch {
139
+ return { provider: target.provider, model: target.model };
140
+ }
141
+ }
142
+
127
143
  function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId: string, label: string | undefined): string | undefined {
128
144
  if (!label) return undefined;
129
145
  const instance = configuredAnthropicInstance(config, providerName);
@@ -638,6 +654,30 @@ export async function executeComboResponses(
638
654
  ? clientCancelledResponse()
639
655
  : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
640
656
  }
657
+ // OCX-11: an ordered failover combo that starts on a later target than its first one, for any
658
+ // reason (cooling down, provider disabled, cached quota spent, ineligible for this request),
659
+ // makes a proactive hop, so the fallback policy decides before anything is sent. Both ends are
660
+ // the resolved destinations, after provider aliases and blockedModelRedirects.
661
+ const primaryTarget = combo.targets[0];
662
+ // An allowed hop is recorded only once its dispatch is admitted below.
663
+ let pendingHopCommit: (() => void) | undefined;
664
+ if (!toolLoopPin && (combo.strategy ?? "failover") === "failover" && primaryTarget
665
+ && targetKey(primaryTarget) !== targetKey(pick.target)) {
666
+ const primaryCooling = isComboTargetInCooldown(comboId, primaryTarget, initialNow);
667
+ const proactive = guardFallbackHop(config, {
668
+ site: "combo",
669
+ route: `combo/${comboId}`,
670
+ aliases: [requestedModel],
671
+ from: resolvedComboDestination(config, primaryTarget),
672
+ to: resolvedComboDestination(config, pick.target),
673
+ trigger: primaryCooling ? "cooldown" : comboTargetSkipTrigger(config, primaryTarget, initialNow),
674
+ admission: options.admission,
675
+ retryAfter: primaryCooling ? comboCooldownRetryAfterSeconds(comboId, initialNow) ?? null : null,
676
+ now: initialNow,
677
+ });
678
+ if (!proactive.ok) return proactive.response;
679
+ pendingHopCommit = proactive.commit;
680
+ }
641
681
  let jevDecision: JevDecision | undefined;
642
682
  if (combo.strategy === "jev" && !toolLoopPin) {
643
683
  const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
@@ -760,6 +800,9 @@ export async function executeComboResponses(
760
800
  // no-more-targets branch; a budget refusal ends the ladder one iteration later, where that
761
801
  // iteration's own `childLog` is already out of scope.
762
802
  let lastFailedChildLog: RequestLogContext | undefined;
803
+ // The target behind `lastFailure`, so the next dispatch can tell a hop from a same-target retry.
804
+ let lastFailedTarget: { provider: string; model: string } | undefined;
805
+ let lastFailedRetryAfter: string | undefined;
763
806
  // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
764
807
  // is gone, so carry the loop's own classification decision instead of re-deriving a
765
808
  // weaker one from the status alone (#4149).
@@ -812,7 +855,29 @@ export async function executeComboResponses(
812
855
  while (pick) {
813
856
  if (options.abortSignal?.aborted) return clientCancelledResponse();
814
857
  const firstComboTarget = comboTargetsDispatched === 0;
858
+ // The physical destination after provider aliases and blockedModelRedirects: hops are
859
+ // classified by where the request really goes, not by the selector the combo names.
815
860
  const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
861
+ // OCX-11: every later dispatch after a failure is a hop when the target changed. One check
862
+ // here covers the ordinary failover pick and the recovery picks alike.
863
+ if (!firstComboTarget && lastFailure && lastFailedTarget) {
864
+ const hop = guardFallbackHop(config, {
865
+ site: "combo",
866
+ route: `combo/${comboId}`,
867
+ aliases: [requestedModel],
868
+ from: lastFailedTarget,
869
+ to: { provider: targetRoute.providerName, model: targetRoute.modelId },
870
+ trigger: fallbackTriggerForStatus(lastFailure.status),
871
+ admission: options.admission,
872
+ failureStatus: lastFailure.status,
873
+ retryAfter: lastFailedRetryAfter ?? null,
874
+ });
875
+ if (!hop.ok) {
876
+ if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
877
+ return hop.response;
878
+ }
879
+ pendingHopCommit = hop.commit;
880
+ }
816
881
  // The inherited spend tracker observes this parent log, before the child has its own label.
817
882
  logCtx.spendPoolId = targetRoute.providerName;
818
883
  // Combo prepays before its child normalizes the route; the parent tracker must
@@ -851,6 +916,9 @@ export async function executeComboResponses(
851
916
  // an earlier one already recorded one.
852
917
  return exhaustedFailure();
853
918
  }
919
+ // Admitted: only now is a guarded hop to this target recorded as taken.
920
+ pendingHopCommit?.();
921
+ pendingHopCommit = undefined;
854
922
  const initialSend = hopDecision?.allowed ? { permit: hopDecision.permit, producerOwned: false } : undefined;
855
923
  try {
856
924
  comboTargetsDispatched += 1;
@@ -1147,6 +1215,9 @@ export async function executeComboResponses(
1147
1215
  quotaFailedChildLog = childLog;
1148
1216
  }
1149
1217
  lastFailedChildLog = childLog;
1218
+ // The failed child's resolved destination, so the next hop compares physical targets.
1219
+ lastFailedTarget = { provider: targetRoute.providerName, model: targetRoute.modelId };
1220
+ lastFailedRetryAfter = failure.retryAfter;
1150
1221
  // A replacement that answers 200 is unmarked, and its zero-output failure only exists once
1151
1222
  // preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
1152
1223
  // client would resend becomes the refusal, and anything else reaches the client as it is.
@@ -265,13 +265,13 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
265
265
  // codex-rs appends one agent_message (any of the four codex-rs message types) at the
266
266
  // current input tail. Historical
267
267
  // agent messages may be adjacent in full-history bodies; they must not poison the
268
- // later task. compaction_trigger/additional_tools are trailing metadata rather than
269
- // a newer user turn.
268
+ // later task. compaction_trigger/additional_tools/configuration_update are trailing
269
+ // metadata rather than a newer user turn; Codex may record an effort update after the task.
270
270
  let index = input.length - 1;
271
271
  while (index >= 0) {
272
272
  const item = input[index];
273
273
  const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
274
- if (type !== "compaction_trigger" && type !== "additional_tools") break;
274
+ if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
275
275
  index -= 1;
276
276
  }
277
277
  const item = input[index];
@@ -1,8 +1,8 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { resolveComboId } from "../../combos";
3
- import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router";
3
+ import { isBareOpenAiFamilyModel, previewRouteModel, routeConcreteModel } from "../../router";
4
4
  import { resolveChosenTarget } from "./shadow-target-availability";
5
- import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile";
5
+ import { getRoutingProfile, resolvePolicyProfileId, type NormalizedRoutingProfile } from "../../routing/profile";
6
6
  import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
7
7
  import {
8
8
  getMainAccountHardLockStatus,
@@ -12,6 +12,7 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
12
12
  import { getEligiblePoolAccounts } from "../../codex/routing/selection";
13
13
  import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
14
14
  import { providerCodexAccountMode } from "../../providers/registry";
15
+ import { FallbackRefusedError, guardFallbackHop, type FallbackAdmission, type FallbackTarget } from "../../routing/fallback-policy";
15
16
  /** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
16
17
  export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
17
18
 
@@ -81,6 +82,7 @@ export async function applyMainHardLockFallbackOverride(
81
82
  config: OcxConfig,
82
83
  now?: number,
83
84
  callerCheck?: MainHardLockCallerCheck,
85
+ admission?: FallbackAdmission,
84
86
  ): Promise<MainHardLockFallbackOverride | null> {
85
87
  const parsed = rawModelOf(body);
86
88
  if (!parsed) return null;
@@ -120,6 +122,21 @@ export async function applyMainHardLockFallbackOverride(
120
122
  }
121
123
  }
122
124
 
125
+ // OCX-11: the reroute is a proactive model hop; refused, the request is answered with the
126
+ // fallback refusal and the body is left untouched.
127
+ const hop = guardFallbackHop(config, {
128
+ site: "hard-lock",
129
+ route: parsed.model,
130
+ from: { provider: OPENAI_CODEX_PROVIDER_ID, model: parsed.model },
131
+ to: hardLockTargetOf(config, parsed.model, fallback.model),
132
+ trigger: "hard_lock",
133
+ admission,
134
+ ...(now !== undefined ? { now } : {}),
135
+ });
136
+ if (!hop.ok) throw new FallbackRefusedError(hop.response);
137
+ // The rewrite is this site's admission: the body now names the fallback model.
138
+ hop.commit();
139
+
123
140
  parsed.raw.model = fallback.model;
124
141
  if (fallback.reasoningEffort !== undefined) {
125
142
  const reasoning = parsed.raw.reasoning;
@@ -132,3 +149,38 @@ export async function applyMainHardLockFallbackOverride(
132
149
  }
133
150
  return { sourceModel: parsed.model, targetModel: fallback.model };
134
151
  }
152
+
153
+ /**
154
+ * The hop's destination for the fallback policy, read from config only. A routing profile picks
155
+ * its candidate from request-history evidence, and evaluating it here would open that index under
156
+ * OPENCODEX_HOME just to classify the hop (on Windows the open handle then outlives the request's
157
+ * home); the profile's own dispatch still evaluates it. Its declared candidates are classified
158
+ * instead, as the hop the profile could take: a candidate on another provider makes it a provider
159
+ * hop. Combos, account namespaces and concrete targets resolve from config alone.
160
+ */
161
+ function hardLockTargetOf(config: OcxConfig, sourceModel: string, model: string): FallbackTarget {
162
+ const policyId = resolvePolicyProfileId(config, model);
163
+ const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
164
+ if (profile) return profileHopTarget(config, sourceModel, profile);
165
+ try {
166
+ const route = previewRouteModel(config, model);
167
+ return { provider: route.providerName, model: route.modelId };
168
+ } catch {
169
+ const slash = model.indexOf("/");
170
+ return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
171
+ }
172
+ }
173
+
174
+ function profileHopTarget(config: OcxConfig, sourceModel: string, profile: NormalizedRoutingProfile): FallbackTarget {
175
+ const targets = profile.candidates.map((candidate): FallbackTarget => {
176
+ try {
177
+ const route = routeConcreteModel(config, candidate.provider + "/" + candidate.model);
178
+ return { provider: route.providerName, model: route.modelId };
179
+ } catch {
180
+ return { provider: candidate.provider, model: candidate.model };
181
+ }
182
+ });
183
+ return targets.find(target => target.provider !== OPENAI_CODEX_PROVIDER_ID)
184
+ ?? targets.find(target => target.model !== sourceModel)
185
+ ?? { provider: OPENAI_CODEX_PROVIDER_ID, model: sourceModel };
186
+ }
@@ -13,6 +13,7 @@ import { resolvePolicyProfileId } from "../../routing/profile";
13
13
  import { parseSyntheticRowId } from "../fast-row";
14
14
  import { routeConcreteModel } from "../../router";
15
15
  import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope";
16
+ import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
16
17
 
17
18
  type CoreHandler = typeof handleResponsesCore;
18
19
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -25,6 +26,23 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
25
26
  return `${candidate.provider}\u0000${candidate.model}`;
26
27
  }
27
28
 
29
+ /**
30
+ * OCX-11: the destination a candidate physically dispatches to, after provider aliases and
31
+ * blockedModelRedirects, so the fallback guard compares where requests really go. A candidate
32
+ * that does not resolve keeps its own selector.
33
+ */
34
+ function resolvedPolicyTarget(
35
+ config: OcxConfig,
36
+ candidate: Pick<RouteCandidateTrace, "provider" | "model">,
37
+ ): { provider: string; model: string } {
38
+ try {
39
+ const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`);
40
+ return { provider: routed.providerName, model: routed.modelId };
41
+ } catch {
42
+ return { provider: candidate.provider, model: candidate.model };
43
+ }
44
+ }
45
+
28
46
  function staysWithinPolicyAfterRedirect(
29
47
  config: OcxConfig,
30
48
  policyEligibility: ReadonlySet<string>,
@@ -219,12 +237,32 @@ export async function handleResponsesWithPolicyFallback(
219
237
  policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({
220
238
  providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model,
221
239
  }));
240
+ let current = resolvedPolicyTarget(config, initialTrace.selected);
241
+ const policyRoute = `policy/${initialTrace.profile!.id}`;
222
242
 
223
243
  while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
224
244
  if (req.signal.aborted) return response;
225
245
  const next = rankPolicyFallbackCandidates(initialTrace, tried)
226
246
  .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations));
227
247
  if (!next) return response;
248
+ // OCX-11: the next candidate is a hop; the fallback policy decides before it is sent, on the
249
+ // redirected destination the guard above already validated.
250
+ const nextTarget = resolvedPolicyTarget(config, next);
251
+ const hop = guardFallbackHop(config, {
252
+ site: "routing-profile",
253
+ route: policyRoute,
254
+ aliases: [initialRequestedModel ?? initialTrace.requestedModel],
255
+ from: current,
256
+ to: nextTarget,
257
+ trigger: fallbackTriggerForStatus(response.status),
258
+ admission: options.admission,
259
+ failureStatus: response.status,
260
+ retryAfter: response.headers.get("retry-after"),
261
+ });
262
+ if (!hop.ok) {
263
+ try { await response.body?.cancel(); } catch { /* the refusal replaces this body */ }
264
+ return hop.response;
265
+ }
228
266
  tried.add(candidateKey(next));
229
267
 
230
268
  // Retain the failed response's log owner until a candidate actually replaces it. This is
@@ -243,7 +281,12 @@ export async function handleResponsesWithPolicyFallback(
243
281
  if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key);
244
282
  }
245
283
  Object.assign(logCtx, failureLog);
246
- } else response = nextResponse;
284
+ } else {
285
+ // Dispatched: only a candidate that was not locally skipped counts as a taken hop.
286
+ hop.commit();
287
+ response = nextResponse;
288
+ current = nextTarget;
289
+ }
247
290
  if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination);
248
291
  } catch (error) {
249
292
  const overload = requestPacingOverloadResponse(error);
@@ -160,6 +160,7 @@ import {
160
160
  import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
161
161
  import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
162
162
  import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing";
163
+ import { FallbackRefusedError } from "../../routing/fallback-policy";
163
164
  import { codexAuthContextLogLabel } from "../../codex/account-label";
164
165
  import {
165
166
  conversationStateBindingFromAuth,
@@ -367,10 +368,11 @@ export async function prepareResponsesRequest(
367
368
  && !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
368
369
  try {
369
370
  options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(
370
- body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options),
371
+ body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), options.admission,
371
372
  );
372
373
  } catch (error) {
373
374
  if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
375
+ if (error instanceof FallbackRefusedError) return error.response;
374
376
  const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() });
375
377
  if (mapped) return mapped;
376
378
  throw error;
@@ -1005,7 +1007,9 @@ export async function prepareResponsesRequest(
1005
1007
  subagentFallbackModelEligibleAccountIdsForModel,
1006
1008
  fallbackChain,
1007
1009
  candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire),
1010
+ options.admission,
1008
1011
  );
1012
+ if (fallback?.refusal) return fallback.refusal;
1009
1013
  if (fallback) {
1010
1014
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1011
1015
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -1219,11 +1223,15 @@ export async function prepareResponsesRequest(
1219
1223
  recoverySelectionOptions,
1220
1224
  subagentFallbackAccountPreview,
1221
1225
  subagentFallbackModelEligibleAccountIdsForModel,
1226
+ undefined,
1227
+ undefined,
1228
+ options.admission,
1222
1229
  );
1223
1230
  } finally {
1224
1231
  recoverySelectionAdmission?.release();
1225
1232
  }
1226
1233
  })();
1234
+ if (fallback?.refusal) return fallback.refusal;
1227
1235
  if (fallback) {
1228
1236
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1229
1237
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -214,6 +214,9 @@ export interface ReplacementStartIo extends ReplacementReadinessIo {
214
214
  logPath?: string | null;
215
215
  parentPid?: number;
216
216
  retryDelayMs?: number;
217
+ /** Exit waits for a terminated not-ready replacement; tests shorten them. */
218
+ termWaitMs?: number;
219
+ killWaitMs?: number;
217
220
  }
218
221
 
219
222
  export interface ReplacementStartRequest {
@@ -227,10 +230,19 @@ export interface ReplacementStartRequest {
227
230
  waitForHealth: boolean;
228
231
  /** The replacement's environment, already prepared by the caller; the parent marker is added here. */
229
232
  env: NodeJS.ProcessEnv;
233
+ /**
234
+ * What a replacement still unready at the deadline means. `handoff` (the default, every
235
+ * standalone restart) hands off to it anyway, because the parent is about to exit and release
236
+ * what it may be waiting for. `terminate` is for a parent that keeps serving when the
237
+ * replacement fails (the client runtime): the replacement is stopped, its exit is observed, and
238
+ * the call rejects with `not_ready`, so nothing that may still bind the port survives.
239
+ */
240
+ notReady?: "handoff" | "terminate";
230
241
  }
231
242
 
232
243
  type AttemptOutcome =
233
- | { kind: "spawned" | "ready" | "not-ready"; pid: number | undefined }
244
+ | { kind: "spawned" | "ready"; pid: number | undefined }
245
+ | { kind: "not-ready"; pid: number | undefined; terminate: () => Promise<boolean> }
234
246
  | { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null };
235
247
 
236
248
  function pinnedPort(port: number | undefined): number | undefined {
@@ -314,7 +326,11 @@ function runReplacementAttempt(
314
326
  void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then(
315
327
  // Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the
316
328
  // final resource release the child may still be waiting for.
317
- ready => { settle({ kind: ready ? "ready" : "not-ready", pid: child.pid }); },
329
+ ready => {
330
+ settle(ready
331
+ ? { kind: "ready", pid: child.pid }
332
+ : { kind: "not-ready", pid: child.pid, terminate: () => terminateReplacement(child, io) });
333
+ },
318
334
  err => { settle({ error: err }); },
319
335
  );
320
336
  };
@@ -369,6 +385,11 @@ export async function spawnReplacementStart(
369
385
  continue;
370
386
  }
371
387
  if (outcome.kind === "not-ready") {
388
+ if (request.notReady === "terminate") {
389
+ const exited = await outcome.terminate();
390
+ log?.note(`replacement pid ${pid} did not answer within the readiness window; ${exited ? "stopped it" : "it did not exit after SIGKILL"}`);
391
+ throw handoffError(exited ? "not_ready" : "not_ready_unstopped");
392
+ }
372
393
  console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway");
373
394
  log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`);
374
395
  } else {
@@ -380,3 +401,30 @@ export async function spawnReplacementStart(
380
401
  log?.close();
381
402
  }
382
403
  }
404
+
405
+ /** Exit-observation budgets for {@link terminateReplacement}. */
406
+ export const REPLACEMENT_TERM_WAIT_MS = 3_000;
407
+ export const REPLACEMENT_KILL_WAIT_MS = 2_000;
408
+
409
+ function waitForChildExit(child: ChildProcess, ms: number, sleep: (ms: number) => Promise<void>): Promise<boolean> {
410
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
411
+ return new Promise<boolean>(resolve => {
412
+ let done = false;
413
+ const finish = (exited: boolean) => { if (!done) { done = true; child.off("exit", onExit); resolve(exited); } };
414
+ const onExit = () => finish(true);
415
+ child.once("exit", onExit);
416
+ void sleep(ms).then(() => finish(child.exitCode !== null || child.signalCode !== null));
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Stop a replacement that never answered: SIGTERM, wait for its exit, then SIGKILL and wait again.
422
+ * True only once the exit was observed; a kill call alone is not proof the port is free.
423
+ */
424
+ async function terminateReplacement(child: ChildProcess, io: ReplacementStartIo): Promise<boolean> {
425
+ const sleep = io.sleep ?? Bun.sleep;
426
+ try { child.kill("SIGTERM"); } catch { /* exited between the check and the signal */ }
427
+ if (await waitForChildExit(child, io.termWaitMs ?? REPLACEMENT_TERM_WAIT_MS, sleep)) return true;
428
+ try { child.kill("SIGKILL"); } catch { /* exited between the check and the signal */ }
429
+ return waitForChildExit(child, io.killWaitMs ?? REPLACEMENT_KILL_WAIT_MS, sleep);
430
+ }
@@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite";
12
12
  import { safeResponseHeaders } from "./safe-response-headers";
13
13
  import type { AudioSocketTarget } from "./audio-dictation";
14
14
  import type { CodexWebSocketActivity } from "../codex/websocket-activity";
15
+ import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme";
15
16
 
16
17
  export { safeResponseHeaders } from "./safe-response-headers";
17
18
 
@@ -131,6 +132,10 @@ export function selectForwardHeaders(
131
132
  if (codexOverride) {
132
133
  selected.set("authorization", `Bearer ${codexOverride.accessToken}`);
133
134
  selected.set("chatgpt-account-id", codexOverride.chatgptAccountId);
135
+ } else {
136
+ // Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct.
137
+ const assertion = headers.get(AGENT_ASSERTION_HEADER);
138
+ if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion);
134
139
  }
135
140
  return selected;
136
141
  }
@@ -11,6 +11,7 @@ import { statusWinswRaw, type WinswStatus } from "../lib/winsw";
11
11
  import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock";
12
12
  import { maybeShowStarPrompt } from "../cli/star-prompt";
13
13
  import { serviceStatusReport } from "./diagnostics";
14
+ import { collectServiceStatusJson } from "./status-json";
14
15
  import { assertServiceAuthEnvironment } from "./auth-preflight";
15
16
  import { assertServiceEnvironmentMatchesInstall } from "./guards";
16
17
  import { resolveServiceListenPort } from "./definition";
@@ -47,10 +48,14 @@ export function normalizeServiceSubcommand(sub?: string): string {
47
48
  return sub ?? "install";
48
49
  }
49
50
 
51
+ const SERVICE_JSON_STATUS_ONLY = "--json applies to `ocx service status` only.";
52
+
50
53
  export interface ParsedServiceArgs {
51
54
  sub: string;
52
55
  backend: ServiceBackend | null;
53
56
  invalid: string[];
57
+ /** `--json`: accepted for `status` only; planServiceCommand rejects it elsewhere. */
58
+ json: boolean;
54
59
  }
55
60
 
56
61
  export type ServiceInstallationState = "installed" | "absent" | "unknown";
@@ -140,6 +145,9 @@ export function planServiceCommand(
140
145
  if (parsed.invalid.length > 0) {
141
146
  return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
142
147
  }
148
+ if (parsed.json && parsed.sub !== "status") {
149
+ return { ok: false, message: SERVICE_JSON_STATUS_ONLY };
150
+ }
143
151
  if (parsed.backend && parsed.sub !== "install") {
144
152
  return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
145
153
  }
@@ -174,9 +182,11 @@ export function planServiceCommand(
174
182
  export function parseServiceArgs(args: string[]): ParsedServiceArgs {
175
183
  let sub: string | undefined;
176
184
  let backend: ServiceBackend | null = null;
185
+ let json = false;
177
186
  const invalid: string[] = [];
178
187
  for (const arg of args) {
179
- if (arg === "--native") {
188
+ if (arg === "--json") json = true;
189
+ else if (arg === "--native") {
180
190
  if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
181
191
  backend = "native";
182
192
  }
@@ -188,7 +198,7 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
188
198
  else if (sub === undefined) sub = arg;
189
199
  else invalid.push(arg);
190
200
  }
191
- return { sub: normalizeServiceSubcommand(sub), backend, invalid };
201
+ return { sub: normalizeServiceSubcommand(sub), backend, invalid, json };
192
202
  }
193
203
 
194
204
  /** Remove the service credential only when no client connection can own it. */
@@ -243,6 +253,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
243
253
  process.exitCode = 1;
244
254
  return;
245
255
  }
256
+ // Answered before platformOps() resolves: the JSON status reaches no install, start, stop,
257
+ // repair or migration path, and an unsupported platform still gets a document, not an exit.
258
+ if (command === "status" && parsed.json) {
259
+ console.log(JSON.stringify(await collectServiceStatusJson(), null, 2));
260
+ return;
261
+ }
246
262
  if (command === "repair" || command === "restart") {
247
263
  const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
248
264
  assertServiceEnvironmentMatchesInstall();
@@ -488,6 +504,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
488
504
  };
489
505
 
490
506
  const preliminary = parseServiceArgs(filteredArgs);
507
+ // `claim` parses its own --json. Any other verb is refused here, before the Desktop check
508
+ // and the Windows mutation lock, so a misplaced --json never reaches either.
509
+ if (preliminary.json && preliminary.sub !== "status" && preliminary.sub !== "claim") {
510
+ console.error(SERVICE_JSON_STATUS_ONLY);
511
+ process.exitCode = 1;
512
+ return;
513
+ }
491
514
  if (preliminary.invalid.length === 0) {
492
515
  const refusal = desktopServiceCommandRefusal(preliminary.sub, undefined, supervisionLatch);
493
516
  if (refusal) {