@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
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `GET|PUT /api/fallback`: the OCX-11 fallback state (`ocx.fallback/1`) and its one scoped write.
3
+ *
4
+ * GET is a passive read. A Remote Link Child reaches it through the read-only link relay
5
+ * (`LINK_MANAGEMENT_READ_PATHS`); there it drops link aliases (`buildFallbackState` passive). PUT
6
+ * runs on the Home only: a Child's relay and CLI refuse writes before sending.
7
+ */
8
+ import type { ManagementContext } from "./context";
9
+ import { jsonResponse } from "../auth-cors";
10
+ import { mutatePersistedConfig } from "../../config";
11
+ import { isPassiveRead } from "../../lib/passive-read";
12
+ import { readLinkStore, type LinkRecord } from "../../link/store";
13
+ import { linkStorePath } from "../../link/paths";
14
+ import { applyFallbackWrite, buildFallbackState, parseFallbackWrites } from "../../routing/fallback-state";
15
+ import { managementBodyTooLargeResponse, readManagementJsonBody } from "./body";
16
+
17
+ function links(ctx: ManagementContext): LinkRecord[] {
18
+ try {
19
+ return (ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath())).links;
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function view(ctx: ManagementContext): Response {
26
+ const response = jsonResponse(buildFallbackState(ctx.config, { links: links(ctx), passive: isPassiveRead() }), 200, ctx.req, ctx.config);
27
+ response.headers.set("cache-control", "no-store");
28
+ return response;
29
+ }
30
+
31
+ export async function handleFallbackRoutes(ctx: ManagementContext): Promise<Response | null> {
32
+ const { req, url, config } = ctx;
33
+ if (url.pathname === "/api/fallback" && req.method === "GET") return view(ctx);
34
+ if (url.pathname === "/api/fallback" && req.method === "PUT") return await writeFallback(ctx);
35
+ return null;
36
+ }
37
+
38
+ async function writeFallback(ctx: ManagementContext): Promise<Response> {
39
+ const { req, config } = ctx;
40
+ let body: unknown;
41
+ try {
42
+ body = await readManagementJsonBody(req);
43
+ } catch (error) {
44
+ return managementBodyTooLargeResponse(error, req, config)
45
+ ?? jsonResponse({ error: "invalid JSON body", code: "invalid_request" }, 400, req, config);
46
+ }
47
+ const parsed = parseFallbackWrites(body);
48
+ if (!parsed.ok) return jsonResponse({ error: parsed.error, code: "invalid_request" }, 400, req, config);
49
+ const known = links(ctx);
50
+ for (const write of parsed.writes) {
51
+ if (write.scope !== "device") continue;
52
+ // A link alias names its link; otherwise the key must be a known link id or data key id.
53
+ const byAlias = known.find(link => link.alias === write.key);
54
+ if (byAlias) write.key = byAlias.id;
55
+ // Clearing stays possible for a device that is gone (a revoked link, a deleted data key):
56
+ // its entry still lists in the state, and clearing a missing entry is a no-op.
57
+ if (write.value === null) continue;
58
+ const exists = known.some(link => link.id === write.key)
59
+ || (config.apiKeys ?? []).some(entry => entry.id === write.key);
60
+ if (!exists) {
61
+ return jsonResponse({ error: "unknown device: use a link id or alias from ocx link status, or a Hub data key id", code: "unknown_device" }, 404, req, config);
62
+ }
63
+ }
64
+ const outcome = mutatePersistedConfig(persisted => {
65
+ let changed = false;
66
+ for (const write of parsed.writes) changed = applyFallbackWrite(persisted, write) || changed;
67
+ return { changed, value: persisted.fallback === undefined ? undefined : structuredClone(persisted.fallback) };
68
+ });
69
+ if (outcome.status === "unavailable") {
70
+ return jsonResponse({ error: "config.json is " + outcome.reason + "; fallback settings were not saved", code: "config_unavailable" }, 409, req, config);
71
+ }
72
+ if (outcome.value === undefined) delete config.fallback;
73
+ else config.fallback = outcome.value;
74
+ return view(ctx);
75
+ }
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED, remoteOcxArgv } from "../../link/ssh-argv";
5
5
  import { parseFingerprintLine } from "../../link/fingerprint";
6
+ import { detectRemotePlatform, isWindowsShellRefusal, WINDOWS_CHILD_REFUSAL } from "../../link/remote-platform";
7
+ import type { SshRunner } from "../../link/ssh-runner";
6
8
  import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe";
7
9
  import { newLinkId, type LinkStore } from "../../link/store";
8
10
  import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner";
@@ -24,11 +26,23 @@ async function serialized(state: LinkRouteState, alias: string, action: () => Pr
24
26
  }
25
27
 
26
28
  function remoteFailure(result: SshRunResult, code: string, message: string): Response | null {
27
- if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return fail("remote_platform_unsupported", "Remote Link Children require macOS or Linux; Windows is unsupported.", 409);
29
+ if (result.code === REMOTE_PLATFORM_UNSUPPORTED || (result.code !== 0 && isWindowsShellRefusal(result.stderr))) return windowsRefusal();
28
30
  if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
29
31
  return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr));
30
32
  }
31
33
 
34
+ function windowsRefusal(): Response {
35
+ return fail("remote_platform_unsupported", WINDOWS_CHILD_REFUSAL, 409);
36
+ }
37
+
38
+ /**
39
+ * Pre-issuance Windows gate for a failed remote command: exit 125, the English cmd/PowerShell
40
+ * refusal, or (for localized shells) the fixed %OS% probe against the same host key.
41
+ */
42
+ async function windowsChild(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise<boolean> {
43
+ return result.code !== 0 && await detectRemotePlatform(runner, alias, knownHostsFile, result) === "windows";
44
+ }
45
+
32
46
  export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
33
47
  const value = await readManagementJsonBodyOr(ctx.req, null);
34
48
  const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]);
@@ -44,7 +58,8 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom
44
58
  const started = now();
45
59
  const result = await runner.run(buildProbeArgv({ alias, tempKnownHostsFile: path }), { timeoutMs: 30_000 });
46
60
  const latency = Math.max(0, now() - started);
47
- if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
61
+ // cmd.exe and PowerShell refuse the POSIX `true` the probe runs; say Windows, not "probe failed".
62
+ if (result.code !== 0) return await windowsChild(runner, alias, path, result) ? windowsRefusal() : fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
48
63
  const fp = await runner.run(buildFingerprintArgv(path), { timeoutMs: 10_000 });
49
64
  if (fp.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fp.stderr));
50
65
  const parsed = parseFingerprintLine(fp.stdout.trim());
@@ -53,6 +68,7 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom
53
68
  let ocxVersion: string | undefined;
54
69
  if (body.includeVersion === true) {
55
70
  const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
71
+ if (await windowsChild(runner, alias, path, versionResult)) return windowsRefusal();
56
72
  const denied = remoteFailure(versionResult, "version_probe_failed", "The remote ocx version could not be read.");
57
73
  if (denied) return denied;
58
74
  const parsedVersion = parseVersion(versionResult.stdout);
@@ -84,7 +100,9 @@ export async function confirmHost(ctx: ManagementContext, state: LinkRouteState)
84
100
  writeFileSync(path, pending.knownHostLine + "\n", { mode: 0o600 });
85
101
  try {
86
102
  const started = now();
87
- const result = await runnerFor(ctx).run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
103
+ const runner = runnerFor(ctx);
104
+ const result = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
105
+ if (await windowsChild(runner, alias, path, result)) return windowsRefusal();
88
106
  const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed.");
89
107
  if (denied) return denied;
90
108
  const version = parseVersion(result.stdout);
@@ -27,6 +27,8 @@ export interface LinkRouteListener {
27
27
  status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
28
28
  close(): Promise<void>;
29
29
  onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
30
+ /** Optional so route fakes stay small; without it every link reports an unknown peer. */
31
+ peerObservation?(linkId: string): import("../../link/peer-report").LinkPeerEntry;
30
32
  }
31
33
 
32
34
  export interface LinkRouteState {
@@ -6,7 +6,11 @@ import { clearCompensationFailed, compensationPath, readCompensation } from "../
6
6
  import { loadHostCandidates } from "../../link/ssh-config";
7
7
  import { newLinkId, type LinkStore } from "../../link/store";
8
8
  import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner";
9
- import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
9
+ import { attachLinkPeers, attachLinkTunnelDiagnostics, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
10
+ import { homeLinkChildParity } from "../../link/child-parity";
11
+ import { localLinkPeer } from "../../link/compatibility";
12
+ import { packageVersion } from "../../lib/package-version";
13
+ import { buildPeerFeatures } from "../../remote/features";
10
14
  import { isLinkPort } from "../../link/ports";
11
15
  import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
12
16
  import { clientLinkStatusIdentity } from "../../client/link-status";
@@ -264,10 +268,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
264
268
  if (denied) return denied;
265
269
  const store = readStoreFor(ctx);
266
270
  const listenerStatus = state.listener.status();
271
+ const supervisorStates = state.supervisor.status();
267
272
  const dto: LinkStatusDto = projectLinkStatus(
268
- store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
273
+ store, supervisorStates, listenerStatus, ctx.config, readCompensation(),
269
274
  ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
270
275
  ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
276
+ linkId => homeLinkChildParity.get(linkId),
271
277
  );
272
278
  for (const link of dto.links) {
273
279
  const failure = state.compensationFailures?.get(link.id);
@@ -277,6 +283,14 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
277
283
  const failure = state.compensationFailures?.get(store.links[0].id);
278
284
  if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
279
285
  }
286
+ // Opt-in: an older `ocx link status` validates the document key by key and would reject the
287
+ // added fields, so only a caller that asks for compatibility gets `local` and `peer`.
288
+ if (url.searchParams.get("compatibility") === "1") {
289
+ attachLinkPeers(dto, localLinkPeer(packageVersion("0.0.0"), buildPeerFeatures()),
290
+ linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null });
291
+ }
292
+ // The same opt-in for each Home-initiated tunnel's retry progress and last failure cause.
293
+ if (url.searchParams.get("diagnostics") === "1") attachLinkTunnelDiagnostics(dto, supervisorStates);
280
294
  // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays
281
295
  // the exact K16 document that `ocx link status` validates key by key.
282
296
  const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null;
@@ -1726,7 +1726,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1726
1726
  return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" });
1727
1727
  }
1728
1728
  const { method, url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name, {
1729
- oauthApiBaseUrl: snapshot?.apiBaseUrl,
1729
+ oauthApiBaseUrl: snapshot?.apiBaseUrl, workbuddy: snapshot?.workbuddy,
1730
1730
  });
1731
1731
  const discovery = resolveProviderModelDiscovery(name, prov);
1732
1732
  const started = Date.now();
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `GET /api/quota-pool`: the `ocx.quota-pool/1` read GOAT consumes (see src/quota-pool/).
3
+ *
4
+ * Same management authorization as the neighbouring reads; a Remote Link Child reaches it through
5
+ * the read-only link relay (`LINK_MANAGEMENT_READ_PATHS`) and a Hub client through its hub relay.
6
+ * The read is always passive: there is no refresh form, so nothing here can make the Home probe.
7
+ */
8
+ import type { ManagementContext } from "./context";
9
+ import { jsonResponse } from "../auth-cors";
10
+
11
+ export async function handleQuotaPoolRoutes(ctx: ManagementContext): Promise<Response | null> {
12
+ const { req, url, config } = ctx;
13
+ // Any query (including a stray refresh=1) is ignored: the read has no active form.
14
+ if (url.pathname === "/api/quota-pool" && req.method === "GET") {
15
+ const { readQuotaPool } = await import("../../quota-pool/assemble");
16
+ const result = await readQuotaPool(config);
17
+ const response = result.ok
18
+ ? jsonResponse(result.pool, 200, req, config)
19
+ : jsonResponse({ error: result.error }, 503, req, config);
20
+ response.headers.set("cache-control", "no-store");
21
+ return response;
22
+ }
23
+ return null;
24
+ }
@@ -370,6 +370,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
370
370
  { method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false },
371
371
  { method: "GET", path: "/api/provider-presets", module: "server/management/provider-routes", mutates: false },
372
372
  { method: "GET", path: "/api/provider-quotas", module: "server/management/provider-routes", mutates: false },
373
+ { method: "GET", path: "/api/quota-pool", module: "server/management/quota-pool-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The ocx.quota-pool/1 read is a machine contract GOAT consumes over management and the link relay; the CLI shows the same caches through ocx provider quota and ocx account.", owner: "ocx-1 quota pool", ownerDoc: "structure/quota-pool.md" } },
374
+ { method: "GET", path: "/api/fallback", module: "server/management/fallback-routes", mutates: false },
375
+ { method: "PUT", path: "/api/fallback", module: "server/management/fallback-routes", mutates: true },
373
376
  { method: "GET", path: "/api/provider-request-pacing", module: "server/management/provider-key-pacing-routes", mutates: false },
374
377
  { method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false },
375
378
  { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
@@ -161,6 +161,20 @@ async function handleLowQuotaRoutesOnDemand(ctx: ManagementContext): Promise<Res
161
161
  return handleLowQuotaRoutes(ctx);
162
162
  }
163
163
 
164
+ /** Lazy for the same reason: the quota-pool assembler pulls every quota cache reader. */
165
+ async function handleQuotaPoolRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
166
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-pool", false)) return null;
167
+ const { handleQuotaPoolRoutes } = await import("./management/quota-pool-routes");
168
+ return handleQuotaPoolRoutes(ctx);
169
+ }
170
+
171
+ /** Lazy: the OCX-11 fallback state view and its write live off the dashboard's common path. */
172
+ async function handleFallbackRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
173
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/fallback", false)) return null;
174
+ const { handleFallbackRoutes } = await import("./management/fallback-routes");
175
+ return handleFallbackRoutes(ctx);
176
+ }
177
+
164
178
  /**
165
179
  * Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is
166
180
  * mounted for every dashboard request, so a static import would put the workflow-budget ledger
@@ -351,6 +365,8 @@ export async function handleManagementAPI(
351
365
  ?? (await handleRequestHistoryRoutes(ctx))
352
366
  ?? (await handleQuotaResetRoutesOnDemand(ctx))
353
367
  ?? (await handleLowQuotaRoutesOnDemand(ctx))
368
+ ?? (await handleQuotaPoolRoutesOnDemand(ctx))
369
+ ?? (await handleFallbackRoutesOnDemand(ctx))
354
370
  ?? (await handleWorkflowBudgetRoutesOnDemand(ctx))
355
371
  ?? (await handleCodexAgentRoleRoutesOnDemand(ctx))
356
372
  ?? (await handleProtocolRoutesOnDemand(ctx))
@@ -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
 
@@ -405,22 +405,37 @@ async function runOwnerLookup(file: string, args: string[], timeoutMs: number):
405
405
  return stdout;
406
406
  }
407
407
 
408
+ export type LoopbackOwnerLookupResult = {
409
+ kind: "match" | "mismatch" | "lookup_error" | "deadline";
410
+ elapsedMs: number;
411
+ };
412
+
408
413
  export interface LoopbackOwnerLookupIo {
409
414
  platform?: NodeJS.Platform;
415
+ nowMs?: () => number;
416
+ scheduleDeadline?: (callback: () => void, timeoutMs: number) => () => void;
410
417
  readProc?: (path: string) => Promise<string>;
411
418
  listFds?: (path: string) => Promise<string[]>;
412
419
  readFdLink?: (path: string) => Promise<string>;
413
420
  run?: (file: string, args: string[], timeoutMs: number) => Promise<string>;
414
421
  }
415
422
 
416
- /** Async, bounded proof that this PID owns the IPv4 loopback LISTEN socket used by the relay. */
417
- export async function ownsIpv4LoopbackListener(
423
+ /**
424
+ * Classify fresh IPv4 loopback LISTEN ownership within the bounded lookup deadline.
425
+ * Only match proves the expected PID; mismatch, inspection failure and timeout remain distinct.
426
+ * elapsedMs uses a monotonic clock by default, and no raw OS output is returned.
427
+ */
428
+ export async function inspectIpv4LoopbackListener(
418
429
  port: number,
419
430
  expectedPid: number,
420
431
  io: LoopbackOwnerLookupIo = {},
421
- ): Promise<boolean> {
432
+ ): Promise<LoopbackOwnerLookupResult> {
433
+ const now = io.nowMs ?? (() => performance.now());
434
+ const startedAt = now();
435
+ const result = (kind: LoopbackOwnerLookupResult["kind"]): LoopbackOwnerLookupResult =>
436
+ ({ kind, elapsedMs: Math.max(0, now() - startedAt) });
422
437
  if (!Number.isInteger(port) || port < 1 || port > 65535
423
- || !Number.isSafeInteger(expectedPid) || expectedPid < 1) return false;
438
+ || !Number.isSafeInteger(expectedPid) || expectedPid < 1) return result("mismatch");
424
439
  const platform = io.platform ?? process.platform;
425
440
  const lookup = async (): Promise<boolean> => {
426
441
  if (platform === "linux") {
@@ -458,13 +473,40 @@ export async function ownsIpv4LoopbackListener(
458
473
  }
459
474
  return false;
460
475
  };
461
- let timer: ReturnType<typeof setTimeout> | undefined;
462
- const deadline = new Promise<false>(resolve => {
463
- timer = setTimeout(() => resolve(false), OWNER_LOOKUP_TIMEOUT_MS);
476
+ const scheduleDeadline = io.scheduleDeadline ?? ((callback, timeoutMs) => {
477
+ const timer = setTimeout(callback, timeoutMs);
464
478
  timer.unref?.();
479
+ return () => clearTimeout(timer);
465
480
  });
466
- try { return await Promise.race([lookup().catch(() => false), deadline]); }
467
- finally { if (timer) clearTimeout(timer); }
481
+ let cancelDeadline: (() => void) | undefined;
482
+ const deadline = new Promise<LoopbackOwnerLookupResult["kind"]>(resolve => {
483
+ cancelDeadline = scheduleDeadline(() => resolve("deadline"), OWNER_LOOKUP_TIMEOUT_MS);
484
+ });
485
+ const inspected = lookup().then(
486
+ matches => matches ? "match" as const : "mismatch" as const,
487
+ error => {
488
+ const failure = error as {
489
+ killed?: boolean; signal?: string | null; code?: unknown; stdout?: unknown; stderr?: unknown;
490
+ } | null;
491
+ // lsof exits 1 with no output when no socket matches its selectors.
492
+ if (platform === "darwin" && failure?.code === 1 && !failure.killed
493
+ && failure.signal == null && failure.stdout === "" && failure.stderr === "") return "mismatch" as const;
494
+ // execFile's timeout kills with SIGTERM; other spawn/exit/buffer errors are lookup failures.
495
+ return failure?.killed === true && failure.signal === "SIGTERM" && failure.code === null
496
+ ? "deadline" as const : "lookup_error" as const;
497
+ },
498
+ );
499
+ try { return result(await Promise.race([inspected, deadline])); }
500
+ finally { cancelDeadline?.(); }
501
+ }
502
+
503
+ /** Boolean compatibility API: only a proven match admits the listener. */
504
+ export async function ownsIpv4LoopbackListener(
505
+ port: number,
506
+ expectedPid: number,
507
+ io: LoopbackOwnerLookupIo = {},
508
+ ): Promise<boolean> {
509
+ return (await inspectIpv4LoopbackListener(port, expectedPid, io)).kind === "match";
468
510
  }
469
511
 
470
512
  /**
@@ -172,6 +172,55 @@ export function conversationIdFromResponsesRequest(input: {
172
172
  );
173
173
  }
174
174
 
175
+ /**
176
+ * Hermes Agent session affinity header for log correlation (#330).
177
+ * Kept separate from runtime/session identities so it cannot affect lanes or routing.
178
+ */
179
+ export function conversationIdFromHermesHeader(headers: Headers): string | undefined {
180
+ return normalizeLogConversationId(headers.get("x-hermes-conversation-id"));
181
+ }
182
+
183
+ /**
184
+ * Validates and normalizes native header candidates in priority order for logging:
185
+ * parent-thread -> session_id -> session-id -> thread-id.
186
+ * Each candidate spelling is evaluated and normalized independently so an empty or
187
+ * whitespace-only candidate (e.g. empty session_id) cannot suppress a subsequent valid candidate.
188
+ */
189
+ export function nativeLogConversationIdFromHeaders(headers: Headers): string | undefined {
190
+ return (
191
+ normalizeLogConversationId(headers.get("x-codex-parent-thread-id")) ??
192
+ normalizeLogConversationId(headers.get("session_id")) ??
193
+ normalizeLogConversationId(headers.get("session-id")) ??
194
+ normalizeLogConversationId(headers.get("thread-id"))
195
+ );
196
+ }
197
+
198
+ /** The request-log context's conversation slot; structural so callers need not import request-log. */
199
+ export interface LogConversationSlot {
200
+ conversationId?: string;
201
+ conversationIdIsHermesFallback?: boolean;
202
+ }
203
+
204
+ /**
205
+ * Record a native log identity. Fills an empty slot or replaces the log-only Hermes fallback,
206
+ * so a native id found late (Responses preparation, a Cursor conversation created mid-turn)
207
+ * still wins. A caller-owned id already in the slot (routed Claude metadata) is kept.
208
+ */
209
+ export function assignNativeLogConversationId(slot: LogConversationSlot, nativeId: string | undefined): void {
210
+ if (!nativeId || (slot.conversationId && !slot.conversationIdIsHermesFallback)) return;
211
+ slot.conversationId = nativeId;
212
+ delete slot.conversationIdIsHermesFallback;
213
+ }
214
+
215
+ /** Fill an empty slot with the log-only Hermes digest, marked so any native id replaces it. */
216
+ export function fillHermesLogConversationFallback(slot: LogConversationSlot, headers: Headers): void {
217
+ if (slot.conversationId) return;
218
+ const hermes = conversationIdFromHermesHeader(headers);
219
+ if (!hermes) return;
220
+ slot.conversationId = hermes;
221
+ slot.conversationIdIsHermesFallback = true;
222
+ }
223
+
175
224
  /**
176
225
  * Claude Code metadata.user_id only — never the system-hash Desktop fallback.
177
226
  * Hashes the raw user_id once (same opacity goal as inbound prompt_cache_key).
@@ -150,6 +150,8 @@ export interface RequestLogContext extends UpstreamErrorDiagnostics {
150
150
  lastOutputAt?: number;
151
151
  /** Best-effort chat/session correlation for Logs grouping (#330). Opaque; omit when unknown. */
152
152
  conversationId?: string;
153
+ /** In-memory only: conversationId holds the log-only Hermes digest, which a native id replaces. */
154
+ conversationIdIsHermesFallback?: boolean;
153
155
  surface?: "claude" | "claude-desktop" | "grok";
154
156
  /** The matched configured key's id. Set ONLY for admissionKind "configured" —
155
157
  * never a sentinel, so a hand-edited entry whose id happens to be "loopback"
@@ -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
 
@@ -130,6 +130,7 @@ export function createAnthropicThinkingRecovery(
130
130
  || response.status !== "completed" || !Array.isArray(response.output) || response.output.length === 0
131
131
  || !rejectedScope
132
132
  || rejectedScope.clientThreadId !== parsed._reasoningReplayScope?.clientThreadId
133
+ || rejectedScope.clientPrincipalId !== parsed._reasoningReplayScope?.clientPrincipalId
133
134
  || JSON.stringify(rejectedScope.current) !== JSON.stringify(parsed._reasoningReplayScope?.current)) return;
134
135
  remembered = true;
135
136
  rememberReasoningReplayOpaqueBlobRejection(rejectedScope, rejectedPayloads);
@@ -129,13 +129,14 @@ import {
129
129
  ForwardAdmissionCredentialError,
130
130
  contextPrincipalIdOf,
131
131
  hasForwardableCodexBearer,
132
+ resolveContextPrincipal,
132
133
  validateForwardAdmissionCredential,
133
134
  } from "../auth-cors";
134
135
  import type { DataPlaneAdmission } from "../auth-cors";
135
136
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
136
137
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
137
138
  import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
138
- import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
139
+ import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
139
140
  import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning";
140
141
  import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough";
141
142
  import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination";
@@ -1002,7 +1003,12 @@ export async function handleResponsesCompact(
1002
1003
  cursorConversationId: gatewayReplayParsed._cursorConversationId,
1003
1004
  sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
1004
1005
  });
1005
- if (clientThreadId) gatewayReplayParsed._reasoningReplayScope = { clientThreadId };
1006
+ if (clientThreadId) {
1007
+ gatewayReplayParsed._reasoningReplayScope = {
1008
+ clientThreadId,
1009
+ clientPrincipalId: resolveContextPrincipal(req, config, admission),
1010
+ };
1011
+ }
1006
1012
  }
1007
1013
  let compactUrl = gatewayTransport ? `${gatewayTransport.url.replace(/\/+$/, "")}/compact` : `${base}/responses/compact`;
1008
1014
  const compactTargetKey = `${route.providerName}|${route.modelId}|compact`;
@@ -1155,7 +1161,7 @@ export async function handleResponsesCompact(
1155
1161
  replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody;
1156
1162
  }
1157
1163
  return isOpenAiOperatedResponsesDestination(sendProvider)
1158
- ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody);
1164
+ ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody));
1159
1165
  };
1160
1166
  const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout(
1161
1167
  compactUrl,
@@ -1723,7 +1729,9 @@ export async function handleResponsesCompact(
1723
1729
  // The routed compaction turn is a handoff inside the same logical request, so it draws the
1724
1730
  // REMAINDER. Minting here is what let a native attempt spend three sends and the routed
1725
1731
  // fallback spend four more.
1726
- const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) });
1732
+ // internalReq keeps only FORWARD_HEADERS, so the caller principal is resolved from req.
1733
+ const replayPrincipal = { clientPrincipalId: resolveContextPrincipal(req, config, admission) };
1734
+ const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, replayPrincipal, ...(admission ? { admission } : {}) });
1727
1735
  if (req.signal.aborted) {
1728
1736
  void response.body?.cancel(req.signal.reason).catch(() => undefined);
1729
1737
  return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
@@ -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(),