@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
@@ -4,9 +4,12 @@ import { Readable } from "node:stream";
4
4
  import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
5
5
  import { serviceApiTokenFingerprint } from "../lib/service-secrets";
6
6
  import { clearableDeadline } from "../lib/abort";
7
+ import { packageVersion } from "../lib/package-version";
8
+ import { linkPeerReportHeaders } from "../link/peer-report";
9
+ import { buildPeerFeatures } from "../remote/features";
7
10
  import {
8
11
  LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
9
- LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
12
+ LINK_RELAY_PROBE_HEADER, LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
10
13
  } from "../link/relay-auth";
11
14
 
12
15
  export interface BoundLinkRelayTarget {
@@ -24,6 +27,14 @@ export class LinkRelayAuthenticationError extends Error {
24
27
  }
25
28
  }
26
29
 
30
+ /** Only the credential-free preflight deadline expired; no data request has begun. */
31
+ export class LinkRelayAuthTimeoutError extends Error {
32
+ constructor() {
33
+ super("link relay authentication timed out");
34
+ this.name = "LinkRelayAuthTimeoutError";
35
+ }
36
+ }
37
+
27
38
  /** One physical connection, ever. A closed keep-alive socket cannot trigger a silent reconnect. */
28
39
  class SingleConnectionAgent extends Agent {
29
40
  socket: Socket | undefined;
@@ -148,19 +159,78 @@ async function responseOnConnection(incoming: IncomingMessage, agent: SingleConn
148
159
  }, { highWaterMark: 0 }), { status, headers });
149
160
  }
150
161
 
151
- /** Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch. */
152
- export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destination: string, init: RequestInit): Promise<Response> {
162
+ let peerReport: Record<string, string> | undefined;
163
+
164
+ /** The credential-free challenge for one connection; the proof binds this nonce to the link key. */
165
+ function relayChallenge(target: BoundLinkRelayTarget, origin: URL) {
166
+ const challenge = linkRelayChallenge(serviceApiTokenFingerprint(target.admissionKey), target.apiKeyId!, target.linkId!);
167
+ const probe = new URL(LINK_RELAY_AUTH_PATH, origin);
168
+ probe.search = new URLSearchParams({ version: LINK_RELAY_AUTH_VERSION, key: target.apiKeyId!,
169
+ link: target.linkId!, nonce: challenge.nonce, proof: challenge.caller }).toString();
170
+ // The challenge also carries this Child's version and features for the Home's link status.
171
+ peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures());
172
+ return { challenge, probe, headers: peerReport };
173
+ }
174
+
175
+ /**
176
+ * The outcome of the credential-free preflight alone: `ok` when the Home answered with a proof
177
+ * only the holder of the link key can make, `unrecognized` for a peer without the route (404),
178
+ * `auth_failed` for any other answer, and `unreachable` when nothing answered in time.
179
+ */
180
+ export type LinkRelayPreflightResult = "ok" | "unreachable" | "auth_failed" | "unrecognized";
181
+
182
+ /**
183
+ * Run only the preflight a relayed request starts with, on its own connection, and close it. It
184
+ * sends no link key, no data request and no body; the Child's readiness uses it to learn whether
185
+ * the Home answers through the tunnel right now. It is marked probe-only, so the Home reserves no
186
+ * session for a data request that never comes. The response body is never read.
187
+ */
188
+ export async function probeLinkRelayPreflight(
189
+ target: BoundLinkRelayTarget, options: { timeoutMs?: number } = {},
190
+ ): Promise<LinkRelayPreflightResult> {
191
+ const timeoutMs = options.timeoutMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
192
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError("invalid link relay preflight timeout");
193
+ if (!target.linkId || !target.apiKeyId) return "auth_failed";
194
+ const origin = new URL(`http://127.0.0.1:${target.tunnelPort}`);
195
+ let request: ReturnType<typeof relayChallenge>;
196
+ try { request = relayChallenge(target, origin); } catch { return "auth_failed"; }
197
+ const agent = new SingleConnectionAgent({ keepAlive: false, maxSockets: 1, maxFreeSockets: 0 });
198
+ const deadline = clearableDeadline(timeoutMs);
199
+ try {
200
+ const response = await requestOnConnection(request.probe, {
201
+ method: "GET", signal: deadline.signal, headers: { ...request.headers, [LINK_RELAY_PROBE_HEADER]: "1" },
202
+ }, agent);
203
+ const proof = response.headers["x-opencodex-link-proof"];
204
+ if (response.statusCode === 204 && typeof proof === "string" && linkRelayProofMatches(proof, request.challenge.expected)) return "ok";
205
+ return response.statusCode === 404 ? "unrecognized" : "auth_failed";
206
+ } catch {
207
+ return "unreachable";
208
+ } finally {
209
+ deadline.clear();
210
+ await agent.close();
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch.
216
+ * LinkRelayAuthTimeoutError certifies that only the credential-free preflight expired before
217
+ * data-request construction. Caller aborts and failures after that boundary keep their own errors.
218
+ */
219
+ export async function fetchBoundLinkRelay(
220
+ target: BoundLinkRelayTarget, destination: string, init: RequestInit,
221
+ options: { authTimeoutMs?: number } = {},
222
+ ): Promise<Response> {
223
+ const authTimeoutMs = options.authTimeoutMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
224
+ if (!Number.isFinite(authTimeoutMs) || authTimeoutMs <= 0) throw new RangeError("invalid link relay auth timeout");
153
225
  if (!target.linkId || !target.apiKeyId) throw new LinkRelayAuthenticationError();
154
226
  const url = new URL(destination);
155
227
  if (url.origin !== `http://127.0.0.1:${target.tunnelPort}`) throw new LinkRelayAuthenticationError();
156
- const challenge = linkRelayChallenge(serviceApiTokenFingerprint(target.admissionKey), target.apiKeyId, target.linkId);
157
- const probe = new URL(LINK_RELAY_AUTH_PATH, url);
158
- probe.search = new URLSearchParams({ version: LINK_RELAY_AUTH_VERSION, key: target.apiKeyId,
159
- link: target.linkId, nonce: challenge.nonce, proof: challenge.caller }).toString();
228
+ const { challenge, probe, headers: preflightHeaders } = relayChallenge(target, url);
160
229
  const agent = new SingleConnectionAgent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
161
- const deadline = clearableDeadline(LINK_RELAY_AUTH_TIMEOUT_MS, init.signal ?? undefined);
230
+ const deadline = clearableDeadline(authTimeoutMs, init.signal ?? undefined);
231
+ let dataRequestStarted = false;
162
232
  try {
163
- const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal }, agent);
233
+ const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: preflightHeaders }, agent);
164
234
  const socket = agent.socket;
165
235
  const proof = response.headers["x-opencodex-link-proof"];
166
236
  if (response.statusCode !== 204 || typeof proof !== "string" || !linkRelayProofMatches(proof, challenge.expected)) {
@@ -170,15 +240,20 @@ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destinat
170
240
  for await (const chunk of response) {
171
241
  if (chunk.length) throw new LinkRelayAuthenticationError();
172
242
  }
243
+ deadline.signal.throwIfAborted();
173
244
  deadline.clear();
174
245
  init.signal?.throwIfAborted();
175
246
  if (!socket || socket.destroyed || socket !== agent.socket) throw new LinkRelayAuthenticationError();
176
247
  const headers = new Headers(init.headers);
177
248
  headers.set(LINK_RELAY_SESSION_HEADER, challenge.nonce);
249
+ dataRequestStarted = true;
178
250
  const incoming = await requestOnConnection(url, { ...init, headers }, agent, socket);
179
251
  return await responseOnConnection(incoming, agent, init.method ?? "GET");
180
252
  } catch (error) {
253
+ const authDeadlineExpired = !dataRequestStarted && deadline.didExpire()
254
+ && !(error instanceof LinkRelayAuthenticationError);
181
255
  await agent.close();
256
+ if (authDeadlineExpired && !init.signal?.aborted) throw new LinkRelayAuthTimeoutError();
182
257
  throw error;
183
258
  } finally { deadline.clear(); }
184
259
  }
@@ -1,10 +1,13 @@
1
- import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport";
1
+ import { randomUUID } from "node:crypto";
2
+ import { fetchBoundLinkRelay, LinkRelayAuthenticationError, LinkRelayAuthTimeoutError } from "./link-relay-transport";
3
+ import { homeUnreachableBody } from "./home-unreachable";
2
4
  import {
3
5
  boundedRelayResponseStream,
4
6
  filterRelayHeaders,
5
7
  headersWithinLimit,
6
8
  validateHubRelayRequestHeaders,
7
9
  } from "./hub-relay";
10
+ import type { LinkDiagnosticRecord, LinkDiagnosticSink } from "../link/diagnostics";
8
11
  import { linkRouteAllowed } from "../link/routes";
9
12
  import { isLinkPort } from "../link/ports";
10
13
  import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
@@ -25,14 +28,23 @@ export interface LinkRelayClock {
25
28
  clearTimeout: typeof clearTimeout;
26
29
  }
27
30
 
31
+ /** A fresh admission verdict plus optional diagnostic evidence; metadata never grants admission. */
32
+ export interface LinkAdmissionResult {
33
+ kind: "match" | "mismatch" | "lookup_error" | "deadline" | "stale" | "identity_unknown" | "identity_mismatch" | "unavailable";
34
+ elapsedMs?: number;
35
+ generation?: number;
36
+ state?: string;
37
+ checkId?: string;
38
+ }
39
+
28
40
  /**
29
41
  * The Child's tunnel as the relay sees it (the client link supervisor implements it). A request
30
42
  * waits on it only while the tunnel is being (re)established. Every relayed fetch waits for a
31
43
  * fresh bounded asynchronous ownership proof, including after a reconnect or refused retry.
32
44
  */
33
45
  export interface LinkTunnelGate {
34
- /** True only while a keyed probe has established this live tunnel. */
35
- connected(): boolean | Promise<boolean>;
46
+ /** Admission and its evidence are returned together; only match permits a fetch. */
47
+ connected(context?: { requestId?: string }): LinkAdmissionResult | Promise<LinkAdmissionResult>;
36
48
  /** True while the tunnel is connecting or reconnecting. */
37
49
  pending(): boolean;
38
50
  /**
@@ -50,12 +62,13 @@ export interface LinkTunnelGate {
50
62
  * supervisor, and a relay with no gate at all still refuses every request.
51
63
  */
52
64
  export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = {
53
- connected: () => true,
65
+ connected: () => ({ kind: "match" }),
54
66
  pending: () => false,
55
67
  waitForConnected: async () => false,
56
68
  };
57
69
 
58
70
  export interface LinkRelayDeps {
71
+ diagnostic?: LinkDiagnosticSink;
59
72
  /** Trusted complete-send test seam. Production always uses the connection-bound transport. */
60
73
  fetchImpl?: typeof fetch;
61
74
  clock?: LinkRelayClock;
@@ -112,6 +125,30 @@ function jsonError(status: number, error: string, retry = false): Response {
112
125
  return Response.json({ error }, { status, headers });
113
126
  }
114
127
 
128
+ /** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */
129
+ function homeUnreachable(error: string): Response {
130
+ return Response.json(homeUnreachableBody(error), {
131
+ status: 503,
132
+ headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
133
+ });
134
+ }
135
+
136
+ /**
137
+ * The peer answered but the connection-bound relay authentication failed. That is not a Home
138
+ * outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as
139
+ * `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it
140
+ * carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection.
141
+ */
142
+ export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed";
143
+
144
+ function relayAuthenticationFailed(error: string): Response {
145
+ return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, {
146
+ status: 503,
147
+ headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
148
+ });
149
+ }
150
+
151
+ /** Preserve path/query on a fixed IPv4 loopback origin; throw when the tunnel port is invalid. */
115
152
  export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
116
153
  if (!isLinkPort(target.tunnelPort)) {
117
154
  throw new RangeError("invalid link tunnel port");
@@ -156,6 +193,7 @@ function linkRequestHeaders(
156
193
  return headers;
157
194
  }
158
195
 
196
+ /** Drop hop-by-hop and stale length/encoding headers before exposing the decoded response. */
159
197
  export function sanitizeLinkResponseHeaders(source: Headers): Headers {
160
198
  const connectionNamed = new Set((source.get("connection") ?? "")
161
199
  .split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
@@ -304,6 +342,11 @@ function idleBoundedStream(
304
342
  });
305
343
  }
306
344
 
345
+ /**
346
+ * Admit an allowlisted route and stream it through the authenticated tunnel with bounded bodies.
347
+ * Owner/auth preflight deadlines share one recovery attempt before data dispatch; an ambiguous
348
+ * post-dispatch failure never earns that recovery. Diagnostic failures cannot change admission.
349
+ */
307
350
  export async function relayLinkDataRequest(
308
351
  req: Request,
309
352
  target: LinkRelayTarget,
@@ -328,6 +371,16 @@ export async function relayLinkDataRequest(
328
371
  return jsonError(431, "link relay request headers too large");
329
372
  }
330
373
 
374
+ const requestId = randomUUID();
375
+ let admission: LinkAdmissionResult = { kind: "unavailable" };
376
+ let recoveryAttempts = 0;
377
+ const diagnostic = (event: LinkDiagnosticRecord["event"], outcome: LinkDiagnosticRecord["outcome"], elapsedMs?: number) => {
378
+ try { deps.diagnostic?.({ event, outcome, requestId,
379
+ checkId: admission.checkId, generation: admission.generation,
380
+ state: admission.state, elapsedMs: admission.elapsedMs,
381
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}), attempt: recoveryAttempts }); }
382
+ catch { /* diagnostics never alter admission */ }
383
+ };
331
384
  const relayAbort = new AbortController();
332
385
  const clock = deps.clock ?? defaultClock;
333
386
  let headerTimer: ReturnType<typeof setTimeout> | undefined = clock.setTimeout(() => {
@@ -366,20 +419,45 @@ export async function relayLinkDataRequest(
366
419
  const remaining = holdUntil - now;
367
420
  return remaining > 0 && await gate.waitForConnected(remaining, relayAbort.signal);
368
421
  };
369
- const tunnelUnavailable = async (): Promise<Response> => {
422
+ const tunnelUnavailable = async (
423
+ reason: LinkDiagnosticRecord["outcome"] = admission.kind === "match" ? "unavailable" : admission.kind,
424
+ message = "link tunnel unavailable",
425
+ elapsedMs?: number,
426
+ ): Promise<Response> => {
427
+ diagnostic("relay_failure", relayAbort.signal.aborted
428
+ ? req.signal.aborted ? "client_abort" : "header_timeout" : reason, elapsedMs);
370
429
  cleanup();
371
430
  try { await body?.cancel(); } catch { /* best effort */ }
372
- return jsonError(503, "link tunnel unavailable", true);
431
+ const response = reason === "auth_failed" ? relayAuthenticationFailed(message) : homeUnreachable(message);
432
+ response.headers.set("x-opencodex-link-request-id", requestId);
433
+ return response;
434
+ };
435
+ const retryPreflight = (reason: "deadline" | "auth_timeout", elapsedMs?: number): boolean => {
436
+ if (recoveryAttempts >= 1 || relayAbort.signal.aborted || bodyOverflow || bodyCancelled || body?.locked) return false;
437
+ recoveryAttempts += 1;
438
+ diagnostic("relay_retry", reason, elapsedMs);
439
+ return true;
440
+ };
441
+ const checkConnected = async (): Promise<boolean> => {
442
+ admission = tunnel ? await tunnel.connected({ requestId }) : { kind: "unavailable" };
443
+ return admission.kind === "match";
373
444
  };
374
445
  const readyForFetch = async (): Promise<boolean> => {
375
- if (!tunnel) return false;
376
- if (await tunnel.connected()) return true;
377
- return tunnel.pending() && await holdForTunnel(tunnel) && await tunnel.connected();
446
+ if (!tunnel || relayAbort.signal.aborted) return false;
447
+ if (await checkConnected()) return !relayAbort.signal.aborted;
448
+ // No fetch or body read has occurred. A deadline proves neither a foreign owner nor a
449
+ // broken SSH process: give this request one fresh proof, without caching or restarting it.
450
+ if (admission.kind === "deadline" && retryPreflight("deadline")) {
451
+ if (await checkConnected()) return !relayAbort.signal.aborted;
452
+ }
453
+ return !relayAbort.signal.aborted && tunnel.pending() && await holdForTunnel(tunnel)
454
+ && await checkConnected() && !relayAbort.signal.aborted;
378
455
  };
379
456
 
380
457
  let upstream: Response | undefined;
381
458
  while (!upstream) {
382
459
  if (!await readyForFetch()) return await tunnelUnavailable();
460
+ const fetchStartedAt = performance.now();
383
461
  try {
384
462
  const init: RequestInit & { duplex?: "half" } = {
385
463
  method: req.method,
@@ -391,24 +469,28 @@ export async function relayLinkDataRequest(
391
469
  upstream = await (deps.fetchImpl ? deps.fetchImpl(destination, init) : fetchBoundLinkRelay(target, destination, init));
392
470
  } catch (error) {
393
471
  if (error instanceof LinkRelayAuthenticationError) {
394
- cleanup();
395
- try { await body?.cancel(); } catch { /* an upload might already be drained */ }
396
- return jsonError(503, error.message, true);
472
+ return await tunnelUnavailable("auth_failed", error.message);
397
473
  }
398
474
  if (bodyOverflow) {
399
475
  cleanup();
400
476
  return jsonError(413, "link relay request body too large");
401
477
  }
478
+ if (error instanceof LinkRelayAuthTimeoutError) {
479
+ // The transport certifies that only its credential-free challenge was sent. A new
480
+ // iteration obtains a fresh ownership proof and a new authenticated physical socket.
481
+ const elapsedMs = performance.now() - fetchStartedAt;
482
+ if (retryPreflight("auth_timeout", elapsedMs)) continue;
483
+ return await tunnelUnavailable("auth_timeout", "link tunnel unavailable", elapsedMs);
484
+ }
402
485
  // A refused connection sent nothing, and an untouched body can go again once the tunnel
403
486
  // is back inside the same hold window. Anything else is never replayed.
404
487
  const resendable = connectionRefused(error) && (body === null || (!body.locked && !bodyCancelled));
405
- if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable();
488
+ if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable("transport_error");
406
489
  }
407
490
  }
408
491
  if (relayAbort.signal.aborted) {
409
- cleanup();
410
492
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
411
- return jsonError(503, "link tunnel unavailable", true);
493
+ return await tunnelUnavailable();
412
494
  }
413
495
 
414
496
  const sse = isSse(upstream.headers);
@@ -425,6 +507,7 @@ export async function relayLinkDataRequest(
425
507
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
426
508
  return jsonError(502, "link relay response body too large");
427
509
  }
510
+ if (recoveryAttempts > 0 && upstream.ok) diagnostic("relay_recovered", "recovered");
428
511
  if (req.method === "HEAD" || !upstream.body) {
429
512
  cleanup();
430
513
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { chmodSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
2
3
  import { execFile } from "node:child_process";
3
4
  import { readFile as readFileAsync } from "node:fs/promises";
@@ -13,17 +14,18 @@ import {
13
14
  CLIENT_TUNNEL_RETRY_POLICY,
14
15
  classifySshStderr,
15
16
  dueForSpawn,
16
- failedTunnel,
17
+ failedSpawnTunnel,
17
18
  IDLE,
18
19
  reduceTunnel,
19
20
  type StderrClass,
20
21
  type TunnelState,
21
22
  } from "../link/tunnel-state";
22
23
  import { isLinkPort } from "../link/ports";
23
- import { ownsIpv4LoopbackListener } from "../server/port-reclaim";
24
+ import { inspectIpv4LoopbackListener, type LoopbackOwnerLookupResult } from "../server/port-reclaim";
25
+ import type { LinkDiagnosticSink } from "../link/diagnostics";
24
26
  import { isLinkConnection, readClientConnectionState } from "./state";
25
27
  import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
26
- import type { LinkTunnelGate } from "./link-relay";
28
+ import type { LinkTunnelGate, LinkAdmissionResult } from "./link-relay";
27
29
 
28
30
  /**
29
31
  * The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
@@ -134,7 +136,8 @@ export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanRe
134
136
  /** The link key for the keyed readiness probe. The runtime passes its cached key source. */
135
137
  linkKey?: () => string | null;
136
138
  /** Async local IPv4 LISTEN ownership; an unknown or foreign owner never receives the link key. */
137
- ownsLoopbackListener?: (port: number, pid: number) => Promise<boolean>;
139
+ inspectLoopbackListener?: (port: number, pid: number) => Promise<LoopbackOwnerLookupResult>;
140
+ diagnostic?: LinkDiagnosticSink;
138
141
  fetchImpl?: typeof fetch;
139
142
  now?: () => number;
140
143
  random?: () => number;
@@ -563,6 +566,10 @@ async function probeTunnel(fetchImpl: typeof fetch, tunnelPort: number, key: str
563
566
  }
564
567
  }
565
568
 
569
+ /**
570
+ * Own the Child's SSH lifecycle and reconnect holds. Each connected() call checks fresh listener
571
+ * ownership and fences results against tunnel replacement or stop; diagnostics cannot admit it.
572
+ */
566
573
  export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
567
574
  const sidecarPath = (): string => clientLinkStatePath(deps.configDir);
568
575
  const readSidecar = deps.readSidecar ?? readWhenFileChanges(sidecarPath, () => readClientLinkState(sidecarPath()));
@@ -573,7 +580,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
573
580
  const policy = CLIENT_TUNNEL_RETRY_POLICY;
574
581
  const isAlive = deps.isAlive ?? defaultIsAlive;
575
582
  const linkKey = deps.linkKey ?? (() => null);
576
- const ownerLookup = deps.ownsLoopbackListener ?? ownsIpv4LoopbackListener;
583
+ const ownerLookup = deps.inspectLoopbackListener ?? inspectIpv4LoopbackListener;
577
584
  const fetchImpl = deps.fetchImpl ?? fetch;
578
585
  const setSupervisorTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
579
586
  const interval = setInterval(callback, ms);
@@ -620,9 +627,16 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
620
627
  if (pid === null || pid === undefined || (adopted !== null && !adoptedRecord)) return null;
621
628
  return { pid, generation: ownerGeneration };
622
629
  };
623
- const listenerOwnedByTunnel = async (port: number): Promise<boolean> => {
630
+ const listenerOwnedByTunnel = async (port: number, context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
624
631
  const candidate = ownerCandidate(port);
625
- if (!candidate) return false;
632
+ if (!candidate) return { kind: "unavailable" };
633
+ const checkId = randomUUID();
634
+ const report = (kind: LinkAdmissionResult["kind"], elapsedMs = 0): LinkAdmissionResult => {
635
+ const check = { kind, elapsedMs, checkId, generation: candidate.generation, state: state.kind };
636
+ if (kind !== "match") try { deps.diagnostic?.({ event: "owner_lookup", outcome: kind, requestId: context?.requestId,
637
+ checkId, generation: candidate.generation, state: state.kind, elapsedMs }); } catch { /* best effort */ }
638
+ return check;
639
+ };
626
640
  if (adopted !== null) {
627
641
  const record = adoptedRecord;
628
642
  const identity = record ? await inspectAdoptedIdentity(record, deps) : "unknown";
@@ -640,16 +654,28 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
640
654
  invalidateOwnerProof();
641
655
  setState(IDLE);
642
656
  }
643
- return false;
657
+ return report(identity === "mismatch" ? "identity_mismatch" : "identity_unknown");
644
658
  }
645
659
  }
646
- const owned = await Promise.resolve().then(() => ownerLookup(port, candidate.pid)).catch(() => false);
647
- return owned && !stopping && ownerGeneration === candidate.generation
648
- && ownerCandidate(port)?.pid === candidate.pid;
660
+ const result = await Promise.resolve().then(() => ownerLookup(port, candidate.pid))
661
+ .catch((): LoopbackOwnerLookupResult => ({ kind: "lookup_error", elapsedMs: 0 }));
662
+ if (stopping || failure !== undefined || ownerGeneration !== candidate.generation || ownerCandidate(port)?.pid !== candidate.pid) {
663
+ return report("stale", result.elapsedMs);
664
+ }
665
+ return report(result.kind, result.elapsedMs);
666
+ };
667
+ const connected = async (context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
668
+ if (!started || stopping || failure !== undefined || state.kind !== "connected" || linkId === null || tunnelPort === null) {
669
+ return { kind: "unavailable" };
670
+ }
671
+ const result = await listenerOwnedByTunnel(tunnelPort, context);
672
+ // State may change during the asynchronous lookup or an optional diagnostic callback.
673
+ if (result.kind === "match" && (!started || stopping || failure !== undefined
674
+ || state.kind !== "connected" || result.generation !== ownerGeneration)) {
675
+ return { ...result, kind: "stale" };
676
+ }
677
+ return result;
649
678
  };
650
- const connected = async (): Promise<boolean> => started && !stopping && failure === undefined
651
- && state.kind === "connected" && linkId !== null && tunnelPort !== null
652
- && await listenerOwnedByTunnel(tunnelPort);
653
679
 
654
680
  const settleWaiters = (): void => {
655
681
  if (waiters.size === 0) return;
@@ -733,7 +759,8 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
733
759
  peerListenerPort: sidecar.peerListenerPort,
734
760
  }, deps);
735
761
  } catch {
736
- setState(failedTunnel("forward", timestamp, policy, state.kind === "failed" ? state.since : timestamp));
762
+ // A due retry that cannot start counts as a failed retry, so the backoff keeps its step.
763
+ setState(failedSpawnTunnel(state, "forward", timestamp, policy));
737
764
  deps.warn?.("client link tunnel could not be started");
738
765
  return;
739
766
  }
@@ -800,7 +827,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
800
827
  probeFlight = flight;
801
828
  const wasConnected = state.kind === "connected";
802
829
  void (async () => {
803
- if (!await listenerOwnedByTunnel(tunnelPort)) return;
830
+ if ((await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
804
831
  if (probeFlight !== flight || stopping || flight.abort.signal.aborted
805
832
  || child !== flight.child || adopted !== flight.adopted) return;
806
833
  const key = linkKey();
@@ -808,7 +835,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
808
835
  const result = await probeTunnel(fetchImpl, tunnelPort, key, flight.abort.signal);
809
836
  // An aborted probe, or one whose listener changed while it ran, proves nothing.
810
837
  if (probeFlight !== flight || stopping || child !== flight.child || adopted !== flight.adopted
811
- || !await listenerOwnedByTunnel(tunnelPort)) return;
838
+ || (await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
812
839
  applyProbe(result, wasConnected);
813
840
  })().catch(() => undefined).finally(() => {
814
841
  if (probeFlight === flight) probeFlight = undefined;
@@ -971,7 +998,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
971
998
  pending,
972
999
  connected,
973
1000
  waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
974
- if (state.kind === "connected") return connected();
1001
+ if (state.kind === "connected") return connected().then(result => result.kind === "match");
975
1002
  if (!pending() || waiters.size >= CLIENT_LINK_MAX_HOLDS || signal?.aborted || !(timeoutMs > 0)) {
976
1003
  return Promise.resolve(false);
977
1004
  }
@@ -1,3 +1,4 @@
1
+ import { recordLinkDiagnostic } from "../link/diagnostics";
1
2
  import type { Server } from "bun";
2
3
  import { loadConfig } from "../config";
3
4
  import { browserSecurityHeaders, requestPolicyView } from "../server/auth-cors";
@@ -21,10 +22,25 @@ import type { LinkTunnelGate } from "./link-relay";
21
22
  import { readClientLinkState, type ClientLinkState } from "./link-state";
22
23
  import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
23
24
  import type { ClientLinkSupervisorStatus } from "./link-tunnel";
25
+ import type { LinkReadiness } from "./link-readiness";
24
26
  import { packageVersion } from "../lib/package-version";
27
+ import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation";
28
+ import {
29
+ CLIENT_RESTART_BUDGET_MS,
30
+ parseExpectedSystemRestartPid,
31
+ RESTART_SUPERVISED_ERROR,
32
+ SYSTEM_RESTART_CAPABILITY_HEADER,
33
+ SYSTEM_RESTART_CAPABILITY_VERSION,
34
+ SYSTEM_RESTART_EXPECTED_PID_HEADER,
35
+ SYSTEM_RESTART_METHOD,
36
+ SYSTEM_RESTART_NONCE_HEADER,
37
+ SYSTEM_RESTART_PATH,
38
+ verifySystemRestartCapability,
39
+ } from "../lib/system-restart-contract";
25
40
  import { linkRouteAllowed } from "../link/routes";
26
41
  import { linkHomeName, linkHomePhrase } from "./link-home";
27
42
  import { relayLinkManagementRead } from "./link-management-relay";
43
+ import type { ClientRestartAdmission } from "./runtime-restart";
28
44
 
29
45
  const VERSION = packageVersion("0.0.0");
30
46
  const GUI_SPA_PATHS = new Set([
@@ -51,6 +67,22 @@ export interface MachineListenerDeps {
51
67
  linkTunnel?: LinkTunnelGate;
52
68
  /** Link mode: relay seams (deadline clock and byte cap). */
53
69
  linkRelay?: LinkIngress["relay"];
70
+ /**
71
+ * Link mode: whether relayed requests can be served now. `GET /readyz` answers 503 `pending`
72
+ * with `link.reason` while it is not; `/healthz` is unaffected (`link-readiness.ts`).
73
+ */
74
+ linkReadiness?: () => LinkReadiness | Promise<LinkReadiness>;
75
+ /**
76
+ * The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge,
77
+ * advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a
78
+ * capability signed with `attestationSecret` for this pid and port, and nothing else.
79
+ */
80
+ restart?: {
81
+ attestationSecret: string;
82
+ /** ISO start time of this runtime's process. */
83
+ startedAt: string;
84
+ request: () => Promise<ClientRestartAdmission>;
85
+ };
54
86
  serve?: (options: Parameters<typeof Bun.serve>[0]) => Server<unknown>;
55
87
  }
56
88
 
@@ -68,6 +100,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
68
100
  if (linkMode && linkRouteAllowed(url, req)) return true;
69
101
  const path = url.pathname;
70
102
  if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
103
+ // The bound restart. Only a capability signed for this exact process admits it (see the handler).
104
+ if (req.method === SYSTEM_RESTART_METHOD && path === SYSTEM_RESTART_PATH) return true;
71
105
  if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
72
106
  // The one link route a connected Child serves: its own read-only link status.
73
107
  if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true;
@@ -125,7 +159,7 @@ export function startMachineListener(
125
159
  apiKeyId: connection.apiKeyId,
126
160
  policy: requestPolicyView(config, "127.0.0.1"),
127
161
  linkKey: deps.linkKeySource ?? createLinkKeySource(connection.tokenFingerprint, deps.linkKey),
128
- relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
162
+ relay: { diagnostic: recordLinkDiagnostic, fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
129
163
  }
130
164
  : null;
131
165
  return (deps.serve ?? (options => Bun.serve(options)))({
@@ -143,10 +177,34 @@ export function startMachineListener(
143
177
  }
144
178
  if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
145
179
  if (url.pathname === "/healthz" && req.method === "GET") {
146
- return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port });
180
+ const restart = deps.restart;
181
+ const response = Response.json({
182
+ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port,
183
+ ...(restart ? { startedAt: restart.startedAt, restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, restartBudgetMs: CLIENT_RESTART_BUDGET_MS } : {}),
184
+ });
185
+ const challenge = restart ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null;
186
+ const proof = challenge && server.port
187
+ ? createLocalAttestationProof(restart!.attestationSecret, challenge, process.pid, server.port)
188
+ : null;
189
+ if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
190
+ return response;
191
+ }
192
+ if (url.pathname === SYSTEM_RESTART_PATH && req.method === SYSTEM_RESTART_METHOD) {
193
+ return await handleBoundRestart(req, server, deps.restart);
147
194
  }
148
195
  if (url.pathname === "/readyz" && req.method === "GET") {
149
- return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 });
196
+ const identity = { service: "opencodex", version: VERSION, role: "client" } as const;
197
+ const facts = { uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 };
198
+ let link: LinkReadiness | null = null;
199
+ if (linkMode && deps.linkReadiness) {
200
+ try { link = await deps.linkReadiness(); } catch { link = { ready: false, reason: "link_unverified" }; }
201
+ }
202
+ if (link && !link.ready) {
203
+ // The /readyz contract: not ready is 503 with Retry-After and status pending or failed.
204
+ return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: link.reason } },
205
+ { status: 503, headers: { "Retry-After": "1", "Cache-Control": "no-store" } });
206
+ }
207
+ return Response.json({ ...identity, status: "ready", ...facts, ...(link ? { link: { state: "available" } } : {}) });
150
208
  }
151
209
  if (url.pathname.startsWith("/api/machine/hub-relay/")) {
152
210
  if (!relayEnabled) return json404(req);
@@ -232,3 +290,45 @@ export function startMachineListener(
232
290
  },
233
291
  } as Parameters<typeof Bun.serve>[0]);
234
292
  }
293
+
294
+ /**
295
+ * `POST /api/system/restart` on the client listener (OCX-6). The only accepted principal is the
296
+ * process-bound restart capability `ocx restart` derives from this runtime's attestation secret:
297
+ * no dashboard session, admin token or link key reaches it, and a browser cannot mint it.
298
+ */
299
+ async function handleBoundRestart(
300
+ req: Request,
301
+ server: Server<unknown>,
302
+ restart: MachineListenerDeps["restart"],
303
+ ): Promise<Response> {
304
+ const noStore = { "cache-control": "no-store" };
305
+ if (!restart || !server.port) return json404(req);
306
+ const expected = parseExpectedSystemRestartPid(req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER));
307
+ if (expected.kind !== "present" || expected.pid !== process.pid) {
308
+ return Response.json({ success: false, error: "Restart target identity changed." }, { status: 409, headers: noStore });
309
+ }
310
+ const authorized = verifySystemRestartCapability(
311
+ restart.attestationSecret,
312
+ req.headers.get(SYSTEM_RESTART_NONCE_HEADER),
313
+ SYSTEM_RESTART_METHOD,
314
+ SYSTEM_RESTART_PATH,
315
+ process.pid,
316
+ server.port,
317
+ req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER),
318
+ );
319
+ if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore });
320
+ const admission = await restart.request();
321
+ if (admission.kind === "supervised") {
322
+ return Response.json({
323
+ success: false,
324
+ error: { code: RESTART_SUPERVISED_ERROR, supervisor: admission.supervisor },
325
+ }, { status: 409, headers: noStore });
326
+ }
327
+ return Response.json({
328
+ success: true,
329
+ message: admission.alreadyRestarting ? "Restart already in progress." : "Restarting the client runtime.",
330
+ activeTurnCount: 0,
331
+ drainTimeoutMs: CLIENT_RESTART_BUDGET_MS,
332
+ alreadyDraining: admission.alreadyRestarting,
333
+ }, { status: 202, headers: noStore });
334
+ }