@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -40,6 +40,9 @@ import { evidenceFromBody } from "../routing/request-evidence";
40
40
  import { resolveWireProtocolOverride } from "./adapter-resolve";
41
41
  import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
42
42
  import {
43
+ assignNativeLogConversationId,
44
+ fillHermesLogConversationFallback,
45
+ nativeLogConversationIdFromHeaders,
43
46
  getOrAllocateRequestSessionLane,
44
47
  linkRequestSessionLane,
45
48
  } from "./request-log-conversation";
@@ -69,7 +72,7 @@ import {
69
72
  assertRouteAllowedByScope,
70
73
  resolveAdmissionModelScope,
71
74
  } from "./admission-model-scope";
72
- import type { DataPlaneAdmission } from "./auth-cors";
75
+ import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors";
73
76
  import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
74
77
  import {
75
78
  createTranslatorBudget,
@@ -81,6 +84,7 @@ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDec
81
84
  import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract";
82
85
  import { createProtocolEnvelope } from "../protocols/envelope";
83
86
  import { featuresFromChatBody } from "../protocols/features";
87
+ import { credentialDomainFor } from "../protocols/opaque-state";
84
88
  import { checkRepresentable, unrepresentableMessage } from "../protocols/guard";
85
89
  import { requestPathForLane } from "../protocols/path";
86
90
  import { resolveProtocolSettings } from "../protocols/settings";
@@ -113,6 +117,10 @@ export async function handleChatCompletions(
113
117
  logCtx: RequestLogContext,
114
118
  logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission },
115
119
  ): Promise<Response> {
120
+ // Set before routing so pre-dispatch refusals stay correlated. A Hermes-only id is marked as a
121
+ // fallback, so a native id resolved later on the Responses bridge still replaces it.
122
+ assignNativeLogConversationId(logCtx, nativeLogConversationIdFromHeaders(req.headers));
123
+ fillHermesLogConversationFallback(logCtx, req.headers);
116
124
  const translatorBudget = createTranslatorBudget();
117
125
  try {
118
126
  let response = await handleChatCompletionsWithBudget(req, config, logCtx, translatorBudget, logIds);
@@ -344,7 +352,13 @@ async function handleChatCompletionsWithBudget(
344
352
  directConversation = chatConversationToIr(chatBody, chatBody.model as string);
345
353
  internalBody = chatSettingsBody(chatBody);
346
354
  } else {
347
- internalBody = chatToResponsesBody(chatBody);
355
+ internalBody = chatToResponsesBody(chatBody, {
356
+ // #7429: only a route that is and stays first-party Anthropic may receive signed thinking
357
+ // a Chat client returns; a combo, policy or rewritten route resolves its target later.
358
+ acceptAnthropicReasoningSignatures: settledRoute !== null && !routeMayChangeCredentialDomain
359
+ && settledRoute.provider.adapter === "anthropic"
360
+ && credentialDomainFor(settledRoute.provider)?.firstPartyAnthropic === true,
361
+ });
348
362
  }
349
363
  if (effortRow) {
350
364
  internalBody.reasoning = {
@@ -413,6 +427,7 @@ async function handleChatCompletionsWithBudget(
413
427
  // Unresolved combos are checked after their concrete child route is selected in Responses.
414
428
  if (settledRoute && !settledRoute.combo && isCanonicalOpenAiForwardProvider(settledRoute.provider)
415
429
  && isCodexReserveHelperUnsupported(config, settledRoute.modelId, logIds?.admission, visionDescribeTerminal)) {
430
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
416
431
  return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error");
417
432
  }
418
433
  const nativeCallerAuth = captureExplicitOpenAiCallerAuth(req.headers, config);
@@ -491,6 +506,8 @@ async function handleChatCompletionsWithBudget(
491
506
  // #1686: the Chat surface translates its body and replays here, so the admission fact has
492
507
  // to ride along or a bearer-admitted Chat caller would still be refused by Direct.
493
508
  ...(logIds?.admission ? { admission: logIds.admission } : {}),
509
+ // The bridge request carries no caller credential, so resolve the replay principal here.
510
+ replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) },
494
511
  abortSignal: req.signal,
495
512
  // Body is Responses-shaped by now, but the client spoke Chat Completions.
496
513
  inboundWire: "chat",
@@ -505,6 +522,9 @@ async function handleChatCompletionsWithBudget(
505
522
  ...(envelope && nativeChatCombos ? {
506
523
  protocolSource: createNativeChatComboSource({
507
524
  req, config, envelope, requestedModel, requestedStream: stream, translatorBudget,
525
+ // Children run without logIds; the request's admission rides the source so their
526
+ // replay scope resolves the same caller principal as every other surface.
527
+ ...(logIds?.admission ? { admission: logIds.admission } : {}),
508
528
  }),
509
529
  } : {}),
510
530
  // Terminal vision-describe marker (roadmap 180): the bridge rebuilds
@@ -7,11 +7,17 @@ import { reasoningReplayConversationIdFromResponsesRequest, sessionIdHeaderFromR
7
7
 
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
- export function nativeChatReplayScope(headers: Headers): OcxReasoningReplayScopeRef | undefined {
10
+ export function nativeChatReplayScope(
11
+ headers: Headers,
12
+ clientPrincipalId: string | undefined,
13
+ ): OcxReasoningReplayScopeRef | undefined {
11
14
  const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({
12
15
  threadIdHeader: headers.get("thread-id"), sessionIdHeader: sessionIdHeaderFromRequest(headers),
13
16
  });
14
- return clientThreadId ? { clientThreadId } : undefined;
17
+ return clientThreadId ? {
18
+ clientThreadId,
19
+ ...(clientPrincipalId ? { clientPrincipalId } : {}),
20
+ } : undefined;
15
21
  }
16
22
 
17
23
  /** Rebind before EVERY build: a key rotation must not retrieve another credential's reasoning. */
@@ -42,7 +48,8 @@ export function nativeChatReasoningCapture(
42
48
  const maxBytes = 256 * 1024;
43
49
  let bytes = 0;
44
50
  let callCount = 0;
45
- let disabled = !scope?.current || !chatReasoningReplayPolicy(provider, modelId).preserve;
51
+ let disabled = !scope?.clientPrincipalId || !scope.current
52
+ || !chatReasoningReplayPolicy(provider, modelId).preserve;
46
53
  const discard = () => {
47
54
  budget.releaseRetained(bytes, accounting);
48
55
  bytes = 0;
@@ -24,6 +24,7 @@ import {
24
24
  } from "../lib/errors";
25
25
  import { reportDispatchSends, type RequestExecutionBudget, type SingleUseDispatchPermit } from "../lib/request-execution-budget";
26
26
  import type { AdmissionLease } from "../lib/admission";
27
+ import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors";
27
28
  import { readBoundedResponseBody } from "../lib/bounded-body";
28
29
  import { redactSecretString } from "../lib/redact";
29
30
  import { resolveClientRetryAfter } from "../lib/retry-after";
@@ -179,7 +180,7 @@ export interface HandleNativeChatOptions {
179
180
  req: Request;
180
181
  config: OcxConfig;
181
182
  logCtx: RequestLogContext;
182
- logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease };
183
+ logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission };
183
184
  route: RouteResult;
184
185
  chatBody: Rec;
185
186
  requestedModel: string;
@@ -209,6 +210,12 @@ export interface NativeChatExecution extends HandleNativeChatOptions {
209
210
  onFirstOutput?: () => void;
210
211
  /** The lease a streamed body holds; defaults to `logIds.turnAdmissionLease`. */
211
212
  turnAdmissionLease?: AdmissionLease;
213
+ /**
214
+ * The request's resolved data-plane admission, when the caller carries it without `logIds`.
215
+ * A combo child runs on the parent's log row, so it receives the admitted identity here or
216
+ * its reasoning replay scope can never establish a caller principal (PF-07).
217
+ */
218
+ admission?: DataPlaneAdmission;
212
219
  }
213
220
 
214
221
  /**
@@ -246,6 +253,8 @@ export function createNativeChatComboSource(input: {
246
253
  requestedModel: string;
247
254
  requestedStream: boolean;
248
255
  translatorBudget: TranslatorBudget;
256
+ /** The request's data-plane admission; native children inherit it for replay scope. */
257
+ admission?: DataPlaneAdmission;
249
258
  }): ComboProtocolSource {
250
259
  return {
251
260
  inbound: "chat",
@@ -264,6 +273,7 @@ export function createNativeChatComboSource(input: {
264
273
  comboDispatchPermit: child.comboDispatchPermit,
265
274
  onFirstOutput: child.onFirstOutput,
266
275
  ...(child.turnAdmissionLease ? { turnAdmissionLease: child.turnAdmissionLease } : {}),
276
+ ...(input.admission ? { admission: input.admission } : {}),
267
277
  }, child.attemptHandle),
268
278
  };
269
279
  }
@@ -340,7 +350,8 @@ export async function runNativeChatAttempt(
340
350
  const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
341
351
  let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
342
352
  let activeRequest: AdapterRequest;
343
- const replayScope = nativeChatReplayScope(req.headers);
353
+ const replayScope = nativeChatReplayScope(req.headers,
354
+ resolveContextPrincipal(req, config, execution.admission ?? logIds?.admission));
344
355
  let retainedRequestBytes = 0;
345
356
  const releaseRetainedRequest = () => {
346
357
  if (retainedRequestBytes === 0) return;
@@ -100,6 +100,7 @@ import {
100
100
  isApiAuthRequired,
101
101
  isDataPlaneAdmissionSecret,
102
102
  isProxyAdmissionSecret,
103
+ resolveContextPrincipal,
103
104
  type RequestPolicyView,
104
105
  type DataPlaneAdmission,
105
106
  } from "./auth-cors";
@@ -1117,6 +1118,8 @@ async function handleClaudeMessagesWithBudget(
1117
1118
  // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner.
1118
1119
  codexAuthPolicy: config,
1119
1120
  ...(logIds?.admission ? { admission: logIds.admission } : {}),
1121
+ // The translated request never forwards the caller credential; resolve the principal first.
1122
+ replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) },
1120
1123
  ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
1121
1124
  abortSignal: req.signal,
1122
1125
  promptCacheKeyIsSharedCohort: cacheKeySource === "system",
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import type { OcxConfig } from "../types";
3
3
  import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability";
4
+ import { authorizationCredential } from "../lib/authorization-scheme";
4
5
  import {
5
6
  isAllowedManagementOrigin,
6
7
  isApiAuthRequired,
@@ -409,7 +410,7 @@ function isPairingTransportPermitted(origin: string): boolean {
409
410
 
410
411
  function requestCredential(req: Request): string | null {
411
412
  return req.headers.get("x-opencodex-api-key")?.trim()
412
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
413
+ || authorizationCredential(req.headers)
413
414
  || null;
414
415
  }
415
416
 
@@ -1,4 +1,5 @@
1
1
  import { createLinkRelaySessions } from "./link-relay-sessions";
2
+ import { createLinkPeerObservations, type LinkPeerEntry } from "../../link/peer-report";
2
3
  import type { Server } from "bun";
3
4
  import {
4
5
  emptyLinkStore,
@@ -9,6 +10,7 @@ import {
9
10
  import { linkStorePath } from "../../link/paths";
10
11
  import { linkRouteAllowed } from "../../link/routes";
11
12
  import { withConfigMutationLockSync } from "../../config/mutation-lock";
13
+ import { registerFallbackLinkResolver } from "../../routing/fallback-policy";
12
14
 
13
15
  export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
14
16
 
@@ -51,6 +53,8 @@ export interface LinkListenerLifecycle<T> {
51
53
  linkAdmissionKeyIds(): ReadonlySet<string>;
52
54
  onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
53
55
  notifyAuthenticatedCatalog(apiKeyId: string): void;
56
+ /** What this Home last learned of a Child over the link; empty until it relays a request. */
57
+ peerObservation(linkId: string): LinkPeerEntry;
54
58
  status(): LinkListenerStatus;
55
59
  close(): Promise<void>;
56
60
  stop(): Promise<void>;
@@ -76,6 +80,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
76
80
  let stopped = false;
77
81
  const authenticatedCatalogListeners = new Set<(apiKeyId: string) => void>();
78
82
  let lifecycleStatus: LinkListenerStatus = { state: "off", port: null, reason: null };
83
+ const peers = createLinkPeerObservations();
79
84
 
80
85
  const setStatus = (state: LinkListenerStatus["state"], port: number | null, reason: string | null): void => {
81
86
  lifecycleStatus = { state, port, reason };
@@ -89,6 +94,9 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
89
94
  const readStoreForAdmission = (): LinkStore => {
90
95
  try { return readStore(storePath); } catch { return emptyLinkStore(); }
91
96
  };
97
+ // OCX-11: the fallback policy resolves a link data key to its link id through this slot.
98
+ const releaseFallbackLinkResolver = registerFallbackLinkResolver(keyId =>
99
+ readStoreForAdmission().links.find(link => link.apiKeyId === keyId)?.id);
92
100
 
93
101
  const bindIfNeeded = (): void => {
94
102
  if (stopped || listener || !startContext) return;
@@ -104,7 +112,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
104
112
  return;
105
113
  }
106
114
  const requestedPort = store.listenerPort ?? 0;
107
- const sessions = createLinkRelaySessions({ fingerprints: (keyId, linkId) => {
115
+ const sessions = createLinkRelaySessions({ peers, fingerprints: (keyId, linkId) => {
108
116
  if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return [];
109
117
  return startContext?.keyFingerprints?.(keyId) ?? [];
110
118
  } });
@@ -214,12 +222,14 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
214
222
  notifyAuthenticatedCatalog(apiKeyId) {
215
223
  for (const listener of authenticatedCatalogListeners) listener(apiKeyId);
216
224
  },
225
+ peerObservation: linkId => peers.get(linkId),
217
226
  status() {
218
227
  return { ...lifecycleStatus };
219
228
  },
220
229
  close,
221
230
  async stop() {
222
231
  stopped = true;
232
+ releaseFallbackLinkResolver();
223
233
  // Process shutdown is deliberately forceful, unlike last-link removal. Every
224
234
  // authenticated socket is closed, and the client transport cannot reconnect it.
225
235
  relaySessions?.abortReservations();
@@ -14,22 +14,69 @@ import type { ManagementSessionControl } from "../management-auth";
14
14
  import { runPassiveRead } from "../../lib/passive-read";
15
15
  import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes";
16
16
  import { projectHomeCodexSettings } from "../../link/codex-settings";
17
+ import { homeLinkChildParity, LINK_CHILD_PARITY_MAX_BYTES, LINK_CHILD_PARITY_PATH, parseLinkChildParityReport } from "../../link/child-parity";
18
+ import { linkStorePath } from "../../link/paths";
19
+ import { readLinkStore } from "../../link/store";
20
+ import { readBoundedResponseBytes } from "../../lib/bounded-body";
17
21
  import { packageVersion } from "../../lib/package-version";
18
22
 
19
23
  const VERSION = packageVersion("0.0.0");
20
24
  const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"];
25
+ const REPORT_READ_TIMEOUT_MS = 5_000;
21
26
 
22
27
  function notFound(): Response {
23
28
  return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
24
29
  }
25
30
 
31
+ function refused(status: number, code: string): Response {
32
+ return Response.json({ error: code }, { status, headers: { "cache-control": "no-store" } });
33
+ }
34
+
35
+ /**
36
+ * `POST` {@link LINK_CHILD_PARITY_PATH}: the Child's apply report (OCX-6). The one write the link
37
+ * accepts, handled here, before and apart from the read-only table, so it never widens what a
38
+ * read may do. It is admitted only for the link the admitting key belongs to, reads at most
39
+ * {@link LINK_CHILD_PARITY_MAX_BYTES} and changes nothing but the in-memory report.
40
+ */
41
+ async function handleChildParityReport(req: Request, url: URL, admittedKeyId: string | undefined): Promise<Response> {
42
+ if (req.method !== "POST" || url.search !== "" || req.headers.has("upgrade")) return notFound();
43
+ if (!admittedKeyId) return refused(403, "link_key_required");
44
+ const declared = Number(req.headers.get("content-length") ?? "0");
45
+ if (Number.isFinite(declared) && declared > LINK_CHILD_PARITY_MAX_BYTES) return refused(413, "report_too_large");
46
+ let bytes: Uint8Array;
47
+ try {
48
+ const read = await readBoundedResponseBytes(new Response(req.body), {
49
+ maxBytes: LINK_CHILD_PARITY_MAX_BYTES,
50
+ signal: AbortSignal.timeout(REPORT_READ_TIMEOUT_MS),
51
+ });
52
+ if (read.oversized) return refused(413, "report_too_large");
53
+ bytes = read.bytes;
54
+ } catch {
55
+ return refused(400, "invalid_report");
56
+ }
57
+ let report: ReturnType<typeof parseLinkChildParityReport>;
58
+ try {
59
+ report = parseLinkChildParityReport(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
60
+ } catch {
61
+ report = null;
62
+ }
63
+ if (!report) return refused(400, "invalid_report");
64
+ let links: ReturnType<typeof readLinkStore>["links"];
65
+ try { links = readLinkStore(linkStorePath()).links; } catch { return refused(503, "link_store_unavailable"); }
66
+ if (!links.some(link => link.id === report.linkId && link.apiKeyId === admittedKeyId)) return refused(403, "link_mismatch");
67
+ homeLinkChildParity.record(report, new Set(links.map(link => link.id)));
68
+ return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
69
+ }
70
+
26
71
  export async function handleLinkManagementRequest(
27
72
  req: Request,
28
73
  url: URL,
29
74
  config: OcxConfig,
30
75
  deps: ManagementApiDeps,
31
76
  sessionControl?: ManagementSessionControl,
77
+ admittedKeyId?: string,
32
78
  ): Promise<Response> {
79
+ if (url.pathname === LINK_CHILD_PARITY_PATH) return handleChildParityReport(req, url, admittedKeyId);
33
80
  if (!linkManagementRouteAllowed(url, req)) return notFound();
34
81
  if (url.pathname === LINK_CODEX_SETTINGS_PATH) {
35
82
  return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } });
@@ -1,8 +1,9 @@
1
1
  import type { Server } from "bun";
2
2
  import {
3
3
  LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
4
- LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
4
+ LINK_RELAY_PROBE_HEADER, LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
5
5
  } from "../../link/relay-auth";
6
+ import { parseLinkPeerReport, type LinkPeerObservations } from "../../link/peer-report";
6
7
 
7
8
  interface Session {
8
9
  peer: string;
@@ -18,6 +19,8 @@ export interface LinkRelaySessionOptions {
18
19
  fingerprints: (keyId: string, linkId: string) => readonly string[];
19
20
  /** Tests can use a bounded shorter reservation; production uses five seconds. */
20
21
  reservationMs?: number;
22
+ /** Where a verified challenge records the Child's report, and a known link's version mismatch. */
23
+ peers?: LinkPeerObservations;
21
24
  }
22
25
 
23
26
  /** One book per bound listener. A pending proof reserves the bind before the data request arrives. */
@@ -64,21 +67,31 @@ export function createLinkRelaySessions(options: LinkRelaySessionOptions) {
64
67
  if (closing || req.method !== "GET" || sessions.size >= 256 || req.headers.has("upgrade")
65
68
  || req.headers.has("authorization") || req.headers.has("x-opencodex-api-key")) return refuse();
66
69
  const q = url.searchParams;
67
- if (q.get("version") !== LINK_RELAY_AUTH_VERSION || [...q.keys()].length !== 5
68
- || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
70
+ if ([...q.keys()].length !== 5 || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
69
71
  const keyId = q.get("key")!, linkId = q.get("link")!, nonce = q.get("nonce")!;
72
+ if (q.get("version") !== LINK_RELAY_AUTH_VERSION) {
73
+ // Unverifiable, but only for a link and key this Home issued: status names the mismatch.
74
+ const version = q.get("version")!;
75
+ if (/^[1-9]\d{0,3}$/.test(version) && options.fingerprints(keyId, linkId).length > 0) options.peers?.refuse(linkId, version);
76
+ return refuse();
77
+ }
70
78
  const peer = peerOf(req, server as Server<unknown>);
71
79
  if (!peer || sessions.has(nonce)) return refuse();
72
80
  const fingerprint = options.fingerprints(keyId, linkId).find(candidate =>
73
81
  linkRelayProofMatches(q.get("proof"), linkRelayProof(candidate, "caller", keyId, linkId, nonce)));
74
82
  if (!fingerprint) return refuse();
75
- const release = acquire();
76
- const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
77
- sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
78
- return new Response(null, { status: 204, headers: {
83
+ options.peers?.observe(linkId, LINK_RELAY_AUTH_VERSION, parseLinkPeerReport(req.headers));
84
+ const answer = () => new Response(null, { status: 204, headers: {
79
85
  "x-opencodex-link-proof": linkRelayProof(fingerprint, "listener", keyId, linkId, nonce)!,
80
86
  "cache-control": "no-store",
81
87
  } });
88
+ // A readiness probe closes after this answer and sends no data request, so it reserves
89
+ // nothing: no session to consume, no capacity and no drain lease held until expiry.
90
+ if (req.headers.get(LINK_RELAY_PROBE_HEADER) === "1") return answer();
91
+ const release = acquire();
92
+ const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
93
+ sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
94
+ return answer();
82
95
  }
83
96
  const nonce = req.headers.get(LINK_RELAY_SESSION_HEADER);
84
97
  let release: () => void;
@@ -14,7 +14,9 @@ import {
14
14
  type LinkListenerStatus,
15
15
  } from "./link-listener";
16
16
  import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
17
+ import { flushLinkDiagnostics, recordLinkDiagnostic } from "../../link/diagnostics";
17
18
  import { linkManagementRouteAllowed } from "../../link/management-routes";
19
+ import { linkChildParityRouteAllowed } from "../../link/child-parity";
18
20
  import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
19
21
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
20
22
  import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
@@ -38,6 +40,7 @@ export interface OptionalListenerSet<T> {
38
40
  linkAdmissionKeyIds(): ReadonlySet<string>;
39
41
  onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
40
42
  notifyAuthenticatedCatalog(apiKeyId: string): void;
43
+ peerObservation(linkId: string): import("../../link/peer-report").LinkPeerEntry;
41
44
  status(): LinkListenerStatus;
42
45
  linkStatus(): LinkListenerStatus;
43
46
  linkSupervisor(): LinkSupervisor;
@@ -54,6 +57,9 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
54
57
  let activeConfig: OcxConfig | undefined;
55
58
  let stopJevQuotaActivation: (() => void) | undefined;
56
59
  const supervisor = createLinkSupervisor({
60
+ // Tunnel state changes and their causes reach <configDir>/link/diagnostics.jsonl (bounded).
61
+ diagnostic: recordLinkDiagnostic,
62
+ flushDiagnostics: flushLinkDiagnostics,
57
63
  apiKeys: () => activeConfig?.apiKeys ?? [],
58
64
  revokeApiKey: id => {
59
65
  if (!activeConfig) return false;
@@ -83,11 +89,14 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
83
89
  },
84
90
  // The Home's link listener admits the data plane plus the read-only management relay
85
91
  // (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child
86
- // cannot reach the Home's management reads without the Child's dashboard session.
87
- linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req),
92
+ // cannot reach the Home's management reads without the Child's dashboard session. The one
93
+ // write is the Child's apply report (OCX-6), which changes only an in-memory status field.
94
+ linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req)
95
+ || linkChildParityRouteAllowed(url, req),
88
96
  linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
89
97
  onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
90
98
  notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
99
+ peerObservation: linkId => linkListener.peerObservation(linkId),
91
100
  status: () => linkListener.status(),
92
101
  linkStatus: () => linkListener.status(),
93
102
  linkSupervisor: () => supervisor,
@@ -204,11 +204,13 @@ import type { ReadinessGate } from "../readiness";
204
204
  import type { WorkflowRefusalLog } from "../workflow-refusal";
205
205
 
206
206
  import { readyProtocolMetadata } from "../../remote/protocol";
207
+ import { homeInstanceId, stampServedBy } from "../../remote/served-by";
207
208
  import { modelCapabilityFields } from "../models-capabilities";
208
209
  import { createWebsocketHandler } from "./websocket-handler";
209
210
  import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
210
211
  import { withGrokSessionIdentity } from "../../grok/session-identity";
211
212
  import { withCallerSessionIdentity } from "../caller-session-identity";
213
+ import { agentAssertionUnsupportedResponse, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
212
214
 
213
215
  export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link";
214
216
 
@@ -370,6 +372,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
370
372
  : ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
371
373
  ? loopbackPolicy()
372
374
  : config;
375
+ // Codex runtime agent assertion_only (Authorization: AgentAssertion) is refused before
376
+ // admission or routing: it is never a bearer, never an admission secret, and no pool or
377
+ // routed credential may stand in for it.
378
+ if (hasAgentAssertionAuthorization(req.headers)) return withCors(agentAssertionUnsupportedResponse(), req, policy);
373
379
  if (ingress === "hub-link") {
374
380
  const admission = resolveApiAuth(req, policy);
375
381
  if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
@@ -380,7 +386,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
380
386
  // the shared table in src/link/management-routes.ts; never reachable on another ingress.
381
387
  if (isLinkManagementPath(requestUrl.pathname)) {
382
388
  const { handleLinkManagementRequest } = await import("./link-management");
383
- return withCors(await handleLinkManagementRequest(req, requestUrl, config, requestManagementApiDeps, managementSessionControl), req, policy);
389
+ return withCors(await handleLinkManagementRequest(
390
+ req, requestUrl, config, requestManagementApiDeps, managementSessionControl,
391
+ admission.kind === "configured" ? admission.keyId : undefined,
392
+ ), req, policy);
384
393
  }
385
394
  }
386
395
  const url = requestUrl;
@@ -408,11 +417,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
408
417
  const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service).";
409
418
  const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort;
410
419
  const fencedHealth = url.pathname === "/healthz";
420
+ const fencedInstanceId = fencedHealth ? homeInstanceId() : undefined;
411
421
  const response = fencedHealth || readyzPath !== undefined
412
422
  ? jsonResponse({
413
423
  status: "restart_required",
414
424
  service: "opencodex",
415
425
  version: VERSION,
426
+ ...(fencedInstanceId ? { instanceId: fencedInstanceId } : {}),
416
427
  uptime: process.uptime(),
417
428
  pid: process.pid,
418
429
  port: fencedPort,
@@ -623,10 +634,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
623
634
  if (url.pathname === "/healthz" && req.method === "GET") {
624
635
  // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
625
636
  const healthPort = ctx.server.port ?? listenPort;
637
+ // Opaque Home instance id (src/remote/served-by.ts); omitted until the identity exists.
638
+ const instanceId = homeInstanceId();
626
639
  const response = jsonResponse({
627
640
  status: "ok",
628
641
  service: "opencodex",
629
642
  version: VERSION,
643
+ ...(instanceId ? { instanceId } : {}),
630
644
  uptime: process.uptime(),
631
645
  pid: process.pid,
632
646
  port: healthPort,
@@ -1977,5 +1991,8 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1977
1991
  },
1978
1992
  websocket: createWebsocketHandler(ctx, requestMetrics),
1979
1993
  } as const;
1980
- return serveOptions;
1994
+ const routeFetch = serveOptions.fetch;
1995
+ // x-opencodex-served-by on GET /v1/models, whichever branch answered (src/remote/served-by.ts).
1996
+ return { ...serveOptions, fetch: async (req: Request, requestServer: Server<WsData>): Promise<Response> =>
1997
+ stampServedBy(req.method, codexCompatibleUrl(req.url).pathname, await routeFetch(req, requestServer)) } as const;
1981
1998
  }
@@ -88,6 +88,10 @@ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1";
88
88
  * upstream the caller was addressing, so there is nothing to scope it away from. That is not
89
89
  * true of the images sidecar, which strips it deliberately and keeps doing so.
90
90
  *
91
+ * `voice-session-id` is the client-generated voice session id Codex 0.164 forwards on WebSocket,
92
+ * WebRTC and existing-call transports (openai/codex#53784). It is a separate identity from the
93
+ * realtime session id carried in `x-session-id`, so neither may stand in for the other.
94
+ *
91
95
  * Every name here is relayed only when the caller sent it. Nothing on this list is invented,
92
96
  * which is what keeps a caller that omits one byte-identical upstream. Auth headers
93
97
  * (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list.
@@ -100,6 +104,7 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
100
104
  "originator",
101
105
  "x-oai-attestation",
102
106
  "x-codex-turn-metadata",
107
+ "voice-session-id",
103
108
  ] as const;
104
109
 
105
110
  /**
@@ -51,7 +51,7 @@ export interface LocalManagementReadRequestDeps extends LocalManagementReadDeps
51
51
  */
52
52
  export async function fetchBoundLocalManagementRead(
53
53
  target: LiveProxy,
54
- path: LocalManagementReadPath,
54
+ path: LocalManagementReadPath | `${LocalManagementReadPath}?${string}`,
55
55
  deps: LocalManagementReadRequestDeps = {},
56
56
  ): Promise<LocalManagementReadResult> {
57
57
  if (
@@ -89,6 +89,8 @@ export async function fetchBoundLocalManagementRead(
89
89
  const timeoutMs = deps.timeoutMs ?? 4_000;
90
90
  const url = `http://${probeHostname(target.hostname)}:${target.port}${path}`;
91
91
  const init: RequestInit = {
92
+ redirect: "error",
93
+ credentials: "omit",
92
94
  headers: {
93
95
  [LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
94
96
  [LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
@@ -208,7 +208,6 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
208
208
  const {
209
209
  clearComboSelectionState,
210
210
  clearComboTargetCooldowns,
211
- comboConfigError,
212
211
  comboDisabledModelId,
213
212
  comboDisabledModelSelectors,
214
213
  comboModelId,
@@ -225,6 +224,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
225
224
  ...requestedCombo,
226
225
  ...(!Object.hasOwn(requestedCombo, "decisionQuotaSignals") && requestedCombo.strategy === "jev" && previous?.decisionQuotaSignals !== undefined ? { decisionQuotaSignals: previous.decisionQuotaSignals } : {}),
227
226
  ...(!Object.hasOwn(requestedCombo, "decisionQuotaTiers") && requestedCombo.strategy === "jev" && previous?.decisionQuotaTiers !== undefined ? { decisionQuotaTiers: previous.decisionQuotaTiers } : {}),
227
+ // The dashboard does not edit the session pin (#7525): an omitted field keeps it on a JEV save.
228
+ ...(!Object.hasOwn(requestedCombo, "sessionPin") && requestedCombo.strategy === "jev" && previous?.sessionPin != null ? { sessionPin: previous.sessionPin } : {}),
228
229
  ...(!Object.hasOwn(requestedCombo, "allowMeteredTargets") && previous?.allowMeteredTargets === true
229
230
  ? { allowMeteredTargets: true }
230
231
  : {}),
@@ -257,7 +258,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
257
258
  ...(Array.isArray(requestedCombo.targets) && Array.isArray(previous?.targets)
258
259
  ? {
259
260
  targets: requestedCombo.targets.map(target => {
260
- // A non-record entry stays as the client sent it so comboConfigError reports it.
261
+ // A non-record entry stays as the client sent it so comboConfigIssues reports it.
261
262
  // Reading `lastResort` off it here would throw in place of that structured 400.
262
263
  if (!isPlainRecord(target)) return target;
263
264
  if (["lastResort", "metered"].every(key => Object.hasOwn(target, key))) return target;
@@ -331,8 +332,13 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
331
332
  excludeComboId: sourceId,
332
333
  normalizeDecisionModel: (model: string) => normalizeDecisionModelSelector(prospectiveConfig, model),
333
334
  };
334
- const error = comboConfigError(id, effectiveCombo, config.providers, validationOptions);
335
- if (error) return jsonResponse({ error }, 400);
335
+ // `field` names the top-level combo field of the first issue, so a client can tell a target or
336
+ // JEV-setting refusal from an alias or rename one without parsing the message (#7579).
337
+ const invalid = comboConfigIssues(id, effectiveCombo, config.providers, validationOptions)[0];
338
+ if (invalid) {
339
+ const field = invalid.path[0];
340
+ return jsonResponse({ error: invalid.message, ...(typeof field === "string" ? { field } : {}) }, 400);
341
+ }
336
342
  for (const [otherId, combo] of Object.entries(nextCombos)) {
337
343
  if (typeof combo.decisionModel !== "string") continue;
338
344
  const issue = otherId === id ? undefined : comboConfigIssues(otherId, combo, config.providers, {
@@ -156,7 +156,7 @@ export interface ManagementApiDeps {
156
156
  codexPromptPaths?: CodexPromptPaths;
157
157
  /** Link seams are getters so the optional listener and supervisor are singletons. */
158
158
  linkSupervisor?: () => LinkSupervisor;
159
- linkListener?: () => Pick<LinkListenerLifecycle<unknown>, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial<Pick<import("../index/optional-listeners").OptionalListenerSet<unknown>, "ensureClaudeIntercept" | "claudeInterceptOutcome">>;
159
+ linkListener?: () => Pick<LinkListenerLifecycle<unknown>, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial<Pick<LinkListenerLifecycle<unknown>, "peerObservation">> & Partial<Pick<import("../index/optional-listeners").OptionalListenerSet<unknown>, "ensureClaudeIntercept" | "claudeInterceptOutcome">>;
160
160
  readLinkStore?: () => LinkStore;
161
161
  writeLinkStore?: (store: LinkStore) => void;
162
162
  linkKnownHostsPath?: () => string;