@bitkyc08/opencodex 2.89.0 → 2.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -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" } });
@@ -3,6 +3,7 @@ import {
3
3
  LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
4
4
  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,14 +67,20 @@ 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();
83
+ options.peers?.observe(linkId, LINK_RELAY_AUTH_VERSION, parseLinkPeerReport(req.headers));
75
84
  const release = acquire();
76
85
  const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
77
86
  sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
@@ -15,6 +15,7 @@ import {
15
15
  } from "./link-listener";
16
16
  import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
17
17
  import { linkManagementRouteAllowed } from "../../link/management-routes";
18
+ import { linkChildParityRouteAllowed } from "../../link/child-parity";
18
19
  import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
19
20
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
20
21
  import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
@@ -38,6 +39,7 @@ export interface OptionalListenerSet<T> {
38
39
  linkAdmissionKeyIds(): ReadonlySet<string>;
39
40
  onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
40
41
  notifyAuthenticatedCatalog(apiKeyId: string): void;
42
+ peerObservation(linkId: string): import("../../link/peer-report").LinkPeerEntry;
41
43
  status(): LinkListenerStatus;
42
44
  linkStatus(): LinkListenerStatus;
43
45
  linkSupervisor(): LinkSupervisor;
@@ -83,11 +85,14 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
83
85
  },
84
86
  // The Home's link listener admits the data plane plus the read-only management relay
85
87
  // (#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),
88
+ // cannot reach the Home's management reads without the Child's dashboard session. The one
89
+ // write is the Child's apply report (OCX-6), which changes only an in-memory status field.
90
+ linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req)
91
+ || linkChildParityRouteAllowed(url, req),
88
92
  linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
89
93
  onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
90
94
  notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
95
+ peerObservation: linkId => linkListener.peerObservation(linkId),
91
96
  status: () => linkListener.status(),
92
97
  linkStatus: () => linkListener.status(),
93
98
  linkSupervisor: () => supervisor,
@@ -204,10 +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";
210
+ import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
209
211
  import { withGrokSessionIdentity } from "../../grok/session-identity";
210
212
  import { withCallerSessionIdentity } from "../caller-session-identity";
213
+ import { agentAssertionUnsupportedResponse, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
211
214
 
212
215
  export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link";
213
216
 
@@ -369,6 +372,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
369
372
  : ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
370
373
  ? loopbackPolicy()
371
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);
372
379
  if (ingress === "hub-link") {
373
380
  const admission = resolveApiAuth(req, policy);
374
381
  if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
@@ -379,7 +386,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
379
386
  // the shared table in src/link/management-routes.ts; never reachable on another ingress.
380
387
  if (isLinkManagementPath(requestUrl.pathname)) {
381
388
  const { handleLinkManagementRequest } = await import("./link-management");
382
- 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);
383
393
  }
384
394
  }
385
395
  const url = requestUrl;
@@ -407,11 +417,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
407
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).";
408
418
  const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort;
409
419
  const fencedHealth = url.pathname === "/healthz";
420
+ const fencedInstanceId = fencedHealth ? homeInstanceId() : undefined;
410
421
  const response = fencedHealth || readyzPath !== undefined
411
422
  ? jsonResponse({
412
423
  status: "restart_required",
413
424
  service: "opencodex",
414
425
  version: VERSION,
426
+ ...(fencedInstanceId ? { instanceId: fencedInstanceId } : {}),
415
427
  uptime: process.uptime(),
416
428
  pid: process.pid,
417
429
  port: fencedPort,
@@ -622,10 +634,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
622
634
  if (url.pathname === "/healthz" && req.method === "GET") {
623
635
  // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
624
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();
625
639
  const response = jsonResponse({
626
640
  status: "ok",
627
641
  service: "opencodex",
628
642
  version: VERSION,
643
+ ...(instanceId ? { instanceId } : {}),
629
644
  uptime: process.uptime(),
630
645
  pid: process.pid,
631
646
  port: healthPort,
@@ -1347,8 +1362,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1347
1362
  response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
1348
1363
  onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
1349
1364
  });
1350
- } catch {
1351
- response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
1365
+ } catch (error) {
1366
+ response = reasoningArchiveErrorResponse(error)
1367
+ ?? formatErrorResponse(500, "server_error", "Unexpected compact request failure");
1352
1368
  }
1353
1369
  addFinalRequestLog(requestId, start, logCtx, response.status,
1354
1370
  response.status === 499 ? { closeReason: "client_cancel" } : undefined);
@@ -1519,6 +1535,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1519
1535
  },
1520
1536
  });
1521
1537
  } catch (error) {
1538
+ const archiveError = reasoningArchiveErrorResponse(error);
1539
+ if (archiveError) return withRequestLogId(
1540
+ withCors(responseWithDeferredRequestLog(archiveError, requestId, start, logCtx), req, policy), requestId,
1541
+ );
1522
1542
  // A bounded upstream body can fail before handleResponses returns a client response.
1523
1543
  // Finalize before rethrow so accounting observes the physical send while the caller
1524
1544
  // retains the existing reset/rejection instead of receiving a synthesized response.
@@ -1971,5 +1991,8 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1971
1991
  },
1972
1992
  websocket: createWebsocketHandler(ctx, requestMetrics),
1973
1993
  } as const;
1974
- 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;
1975
1998
  }
@@ -2,6 +2,7 @@ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type Native
2
2
  import { NativeInjectionChannel } from "../responses/native-injection";
3
3
  import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
4
4
  import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
5
+ import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
5
6
  import type { Server, ServerWebSocket } from "bun";
6
7
  import {
7
8
  LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
@@ -396,6 +397,15 @@ export function createWebsocketHandler(
396
397
  }));
397
398
  return;
398
399
  }
400
+ // A local archive refusal happens before any send. Carry the same sanitized 503
401
+ // body and stable code as HTTP so no filesystem path reaches the client frame.
402
+ const archiveError = reasoningArchiveErrorResponse(err);
403
+ if (archiveError) {
404
+ finalizeLog(archiveError.status);
405
+ const body = await archiveError.json() as { error: Record<string, unknown> };
406
+ sendJsonFrame(ws, buildWsErrorFrame(archiveError.status, body.error));
407
+ return;
408
+ }
399
409
  finalizeLog(502);
400
410
  sendJsonFrame(ws, buildWsErrorFrame(502, {
401
411
  type: "proxy_error",
@@ -476,13 +476,25 @@ export function trackStreamLifetime(
476
476
  registerTurn(ac, lease);
477
477
  const reader = body.getReader();
478
478
  let closed = false;
479
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
480
+ // A drain aborts the turn's controller; many callers pass a fresh one nothing else listens to,
481
+ // so the tracked stream itself has to stop.
482
+ const onAbort = () => {
483
+ if (closed) return;
484
+ // Stop the stream before the caller's onDone runs, so a throwing callback cannot leave it open.
485
+ reader.cancel(ac.signal.reason).catch(() => {});
486
+ try { streamController?.error(ac.signal.reason); } catch { /* already closed */ }
487
+ finish();
488
+ };
479
489
  const finish = () => {
480
490
  if (closed) return;
481
491
  closed = true;
492
+ ac.signal.removeEventListener("abort", onAbort);
482
493
  unregisterTurn(ac);
483
494
  onDone?.();
484
495
  };
485
- return new ReadableStream<Uint8Array>({
496
+ const tracked = new ReadableStream<Uint8Array>({
497
+ start(controller) { streamController = controller; },
486
498
  async pull(controller) {
487
499
  try {
488
500
  const { done, value } = await reader.read();
@@ -499,6 +511,10 @@ export function trackStreamLifetime(
499
511
  reader.cancel(reason).catch(() => {});
500
512
  },
501
513
  });
514
+ // After registerTurn, so the lease's own abort listener (release the turn) runs first. A
515
+ // controller registerTurn already aborted (a settled lease) keeps its previous behaviour.
516
+ ac.signal.addEventListener("abort", onAbort, { once: true });
517
+ return tracked;
502
518
  }
503
519
 
504
520
  export async function drainAndShutdown(
@@ -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
  /**
@@ -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;
@@ -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, 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";
@@ -268,6 +272,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
268
272
  store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
269
273
  ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
270
274
  ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
275
+ linkId => homeLinkChildParity.get(linkId),
271
276
  );
272
277
  for (const link of dto.links) {
273
278
  const failure = state.compensationFailures?.get(link.id);
@@ -277,6 +282,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
277
282
  const failure = state.compensationFailures?.get(store.links[0].id);
278
283
  if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
279
284
  }
285
+ // Opt-in: an older `ocx link status` validates the document key by key and would reject the
286
+ // added fields, so only a caller that asks for compatibility gets `local` and `peer`.
287
+ if (url.searchParams.get("compatibility") === "1") {
288
+ attachLinkPeers(dto, localLinkPeer(packageVersion("0.0.0"), buildPeerFeatures()),
289
+ linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null });
290
+ }
280
291
  // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays
281
292
  // the exact K16 document that `ocx link status` validates key by key.
282
293
  const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null;
@@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
374
374
  // Without a flow id this cancels the provider's newest in-flight login, which is what the
375
375
  // single-user GUI has always meant by "Cancel". A host running concurrent code logins for
376
376
  // several users passes the flowId it was given at /api/oauth/login.
377
- const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined);
377
+ const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined,
378
+ { dismissSettledFailure: true });
378
379
  return jsonResponse({ ok: true, cancelled });
379
380
  }
380
381
 
@@ -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
+ }