@bitkyc08/opencodex 2.67.0 → 2.68.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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-DCBismRi.css +1 -0
  3. package/gui/dist/assets/App-mpsibsFj.js +51 -0
  4. package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-kUE8tuAK.js +86 -0
  7. package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -2,17 +2,19 @@ import {
2
2
  boundedRelayResponseStream,
3
3
  filterRelayHeaders,
4
4
  headersWithinLimit,
5
- HUB_RELAY_DEFAULT_TIMEOUT_MS,
6
- HUB_RELAY_REQUEST_BODY_MAX_BYTES,
7
- HUB_RELAY_RESPONSE_BODY_MAX_BYTES,
8
- readBoundedRelayRequestBody,
9
5
  validateHubRelayRequestHeaders,
10
6
  } from "./hub-relay";
11
7
  import { linkRouteAllowed } from "../link/routes";
12
8
  import { isLinkPort } from "../link/ports";
9
+ import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
13
10
 
14
11
  export interface LinkRelayTarget {
15
12
  tunnelPort: number;
13
+ /**
14
+ * The stored link key. The relay sends it in place of every caller credential, so the Home
15
+ * admits the request as this link and serves it with its own accounts.
16
+ */
17
+ admissionKey: string;
16
18
  }
17
19
 
18
20
  export interface LinkRelayClock {
@@ -20,15 +22,66 @@ export interface LinkRelayClock {
20
22
  clearTimeout: typeof clearTimeout;
21
23
  }
22
24
 
25
+ /**
26
+ * The Child's tunnel as the relay sees it (the client link supervisor implements it). A request
27
+ * waits on it only while the tunnel is being (re)established. Every relayed fetch waits for a
28
+ * fresh bounded asynchronous ownership proof, including after a reconnect or refused retry.
29
+ */
30
+ export interface LinkTunnelGate {
31
+ /** True only while a keyed probe has established this live tunnel. */
32
+ connected(): boolean | Promise<boolean>;
33
+ /** True while the tunnel is connecting or reconnecting. */
34
+ pending(): boolean;
35
+ /**
36
+ * Resolves true once the tunnel is connected, and false after `timeoutMs`, when `signal` aborts,
37
+ * when the tunnel stops being re-established (failed or stopped), or when too many requests wait.
38
+ */
39
+ waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
40
+ }
41
+
42
+ /**
43
+ * The gate for a Home-initiated link. The Home runs `ssh -R` and owns the forward, so the Child
44
+ * has no tunnel supervisor and no local SSH process whose socket it could prove. This gate keeps
45
+ * the 2.67.0 behaviour for that link only: forward to the tunnel port without an ownership proof,
46
+ * never hold, and answer 503 when the connection is refused. Child-initiated links keep their
47
+ * supervisor, and a relay with no gate at all still refuses every request.
48
+ */
49
+ export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = {
50
+ connected: () => true,
51
+ pending: () => false,
52
+ waitForConnected: async () => false,
53
+ };
54
+
23
55
  export interface LinkRelayDeps {
24
56
  fetchImpl?: typeof fetch;
25
57
  clock?: LinkRelayClock;
26
- timeoutMs?: number;
58
+ /** Time allowed for the Home's response headers; a caller abort still ends the wait sooner. */
59
+ headerTimeoutMs?: number;
27
60
  sseIdleTimeoutMs?: number;
61
+ /** Byte cap for the streamed request body and for a non-SSE response body. */
62
+ bodyLimitBytes?: number;
63
+ /** The Child's tunnel; without positive connected proof a request is refused. */
64
+ tunnel?: LinkTunnelGate;
65
+ /** The longest a request waits for a reconnecting tunnel, from its first wait. */
66
+ holdMs?: number;
67
+ now?: () => number;
28
68
  }
29
69
 
30
70
  export const LINK_RELAY_RETRY_AFTER_SECONDS = 1;
31
71
  export const LINK_RELAY_SSE_IDLE_TIMEOUT_MS = 300_000;
72
+ /**
73
+ * The Home may hold a turn (remote compaction, a slow first byte) far past the management
74
+ * relay's 15 s, so the data plane waits for response headers as long as its SSE idle limit.
75
+ */
76
+ export const LINK_RELAY_HEADER_TIMEOUT_MS = 300_000;
77
+ /** The data-plane default: the same inbound limit a standalone listener admits. */
78
+ export const LINK_RELAY_BODY_MAX_BYTES = resolveInboundBodyLimitBytes(undefined);
79
+ /**
80
+ * How long a request waits for a tunnel that is connecting or reconnecting (right after the
81
+ * restart into a Child, after sleep, after an ssh exit) before it is answered 503. Codex's own
82
+ * retries cover only a few seconds.
83
+ */
84
+ export const LINK_RELAY_HOLD_MS = 15_000;
32
85
 
33
86
  const defaultClock: LinkRelayClock = {
34
87
  setTimeout: globalThis.setTimeout,
@@ -36,24 +89,59 @@ const defaultClock: LinkRelayClock = {
36
89
  };
37
90
  const REQUEST_OMITTED_HEADERS = new Set(["content-length", "host"]);
38
91
  const RESPONSE_OMITTED_HEADERS = new Set(["content-encoding", "content-length"]);
92
+ /**
93
+ * Caller credentials never cross the tunnel. The Child's own ChatGPT or Anthropic credential
94
+ * stays on the Child, and the Home sees exactly one admission: the link key.
95
+ */
96
+ const CALLER_CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-opencodex-api-key", "chatgpt-account-id", "cookie"] as const;
39
97
 
40
98
  function jsonError(status: number, error: string, retry = false): Response {
41
99
  const headers = retry ? { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) } : undefined;
42
100
  return Response.json({ error }, { status, headers });
43
101
  }
44
102
 
45
- export function linkRelayDestination(url: URL, target: LinkRelayTarget): string {
103
+ export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
46
104
  if (!isLinkPort(target.tunnelPort)) {
47
105
  throw new RangeError("invalid link tunnel port");
48
106
  }
49
107
  return `http://127.0.0.1:${target.tunnelPort}${url.pathname}${url.search}`;
50
108
  }
51
109
 
52
- export function forwardLinkRequestHeaders(source: Headers): Headers {
53
- const validation = validateHubRelayRequestHeaders([...source]);
110
+ /**
111
+ * The caller's headers for the framing check. The listener's HTTP parser has already de-chunked
112
+ * the body, and the relay re-frames it from the stream, so a lone `Transfer-Encoding: chunked`
113
+ * with no Content-Length is admitted, as a standalone admits it. Any other Transfer-Encoding, or
114
+ * one next to a Content-Length, stays in the list and is refused as ambiguous framing.
115
+ */
116
+ function linkFramingHeaders(source: Headers): Array<[string, string]> {
117
+ const raw = [...source];
118
+ const transferEncoding = source.get("transfer-encoding");
119
+ if (transferEncoding === null || transferEncoding.trim().toLowerCase() !== "chunked" || source.has("content-length")) return raw;
120
+ return raw.filter(([name]) => name.toLowerCase() !== "transfer-encoding");
121
+ }
122
+
123
+ /**
124
+ * The headers sent to the Home: hop-by-hop, Connection-nominated and caller credential headers
125
+ * dropped, then the link key attached. `GET /v1/usage` admits only the dedicated header; every
126
+ * other link route takes the key as a Bearer, the same wire an `env_key` Codex config sent.
127
+ */
128
+ export function forwardLinkRequestHeaders(source: Headers, admissionKey: string, pathname: string): Headers {
129
+ const validation = validateHubRelayRequestHeaders(linkFramingHeaders(source));
54
130
  if (!validation.ok) return new Headers();
55
- const omitted = new Set([...REQUEST_OMITTED_HEADERS, ...validation.connectionNamed]);
56
- return filterRelayHeaders(source, undefined, omitted);
131
+ return linkRequestHeaders(source, admissionKey, pathname, validation.connectionNamed);
132
+ }
133
+
134
+ function linkRequestHeaders(
135
+ source: Headers,
136
+ admissionKey: string,
137
+ pathname: string,
138
+ connectionNamed: ReadonlySet<string>,
139
+ ): Headers {
140
+ const omitted = new Set<string>([...REQUEST_OMITTED_HEADERS, ...CALLER_CREDENTIAL_HEADERS, ...connectionNamed]);
141
+ const headers = filterRelayHeaders(source, undefined, omitted);
142
+ if (pathname === "/v1/usage") headers.set("x-opencodex-api-key", admissionKey);
143
+ else headers.set("authorization", `Bearer ${admissionKey}`);
144
+ return headers;
57
145
  }
58
146
 
59
147
  export function sanitizeLinkResponseHeaders(source: Headers): Headers {
@@ -66,6 +154,60 @@ function isSse(headers: Headers): boolean {
66
154
  return headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
67
155
  }
68
156
 
157
+ function positive(value: number | undefined): number | undefined {
158
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
159
+ }
160
+
161
+ /**
162
+ * The caller's body streamed through unchanged while its bytes are counted. Nothing is
163
+ * buffered: each chunk goes to the upstream as it arrives, and crossing `limit` errors the
164
+ * stream, which fails the upstream fetch.
165
+ */
166
+ function byteCappedRequestBody(
167
+ body: ReadableStream<Uint8Array>,
168
+ limit: number,
169
+ onOverflow: () => void,
170
+ onCancel: () => void,
171
+ ): ReadableStream<Uint8Array> {
172
+ const reader = body.getReader();
173
+ let bytes = 0;
174
+ return new ReadableStream<Uint8Array>({
175
+ async pull(controller) {
176
+ try {
177
+ const next = await reader.read();
178
+ if (next.done) {
179
+ controller.close();
180
+ return;
181
+ }
182
+ bytes += next.value.byteLength;
183
+ if (bytes > limit) {
184
+ const error = new RangeError("link relay request body too large");
185
+ onOverflow();
186
+ try { await reader.cancel(error); } catch { /* best effort */ }
187
+ controller.error(error);
188
+ return;
189
+ }
190
+ controller.enqueue(next.value);
191
+ } catch (error) {
192
+ controller.error(error);
193
+ }
194
+ },
195
+ async cancel(reason) {
196
+ onCancel();
197
+ try { await reader.cancel(reason); } catch { /* best effort */ }
198
+ },
199
+ });
200
+ }
201
+
202
+ /**
203
+ * The connection itself was refused: nothing reached the Home, so the request may be sent again.
204
+ * Any other failure (a reset, a timeout, an error after the body started) may have reached it.
205
+ */
206
+ function connectionRefused(error: unknown): boolean {
207
+ const code = (error as { code?: unknown } | null)?.code;
208
+ return code === "ConnectionRefused" || code === "ECONNREFUSED";
209
+ }
210
+
69
211
  function idleBoundedStream(
70
212
  body: ReadableStream<Uint8Array>,
71
213
  signal: AbortSignal,
@@ -159,51 +301,92 @@ export async function relayLinkDataRequest(
159
301
  if (!linkRouteAllowed(url, req)) return jsonError(404, "not_found");
160
302
  let destination: string;
161
303
  try { destination = linkRelayDestination(url, target); } catch { return jsonError(404, "not_found"); }
162
- const validation = validateHubRelayRequestHeaders([...req.headers]);
304
+ const validation = validateHubRelayRequestHeaders(linkFramingHeaders(req.headers));
163
305
  if (!validation.ok) return jsonError(400, "link relay request headers refused");
164
306
 
165
- let body: Uint8Array<ArrayBuffer> | null;
166
- try {
167
- body = req.method === "GET" || req.method === "HEAD"
168
- ? null
169
- : await readBoundedRelayRequestBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES);
170
- } catch {
307
+ const bodyLimit = positive(deps.bodyLimitBytes) ?? LINK_RELAY_BODY_MAX_BYTES;
308
+ // The check admits at most one all-digit Content-Length, and Transfer-Encoding only as a lone
309
+ // `chunked` without one; the byte cap below bounds a chunked body instead.
310
+ const declaredLength = req.headers.get("content-length")?.trim() ?? null;
311
+ if (declaredLength !== null && Number(declaredLength) > bodyLimit) {
171
312
  return jsonError(413, "link relay request body too large");
172
313
  }
173
- const headers = forwardLinkRequestHeaders(req.headers);
314
+ const headers = linkRequestHeaders(req.headers, target.admissionKey, url.pathname, validation.connectionNamed);
174
315
  if (!headersWithinLimit(headers)) {
175
316
  return jsonError(431, "link relay request headers too large");
176
317
  }
177
318
 
178
319
  const relayAbort = new AbortController();
179
- const timeoutMs = typeof deps.timeoutMs === "number" && Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0
180
- ? Math.min(Math.floor(deps.timeoutMs), 120_000)
181
- : HUB_RELAY_DEFAULT_TIMEOUT_MS;
182
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
183
- const onTimeout = () => relayAbort.abort(timeoutSignal.reason);
320
+ const clock = deps.clock ?? defaultClock;
321
+ let headerTimer: ReturnType<typeof setTimeout> | undefined = clock.setTimeout(() => {
322
+ headerTimer = undefined;
323
+ relayAbort.abort(new DOMException("link relay header deadline", "TimeoutError"));
324
+ }, positive(deps.headerTimeoutMs) ?? LINK_RELAY_HEADER_TIMEOUT_MS);
325
+ const stopHeaderDeadline = () => {
326
+ if (headerTimer !== undefined) clock.clearTimeout(headerTimer);
327
+ headerTimer = undefined;
328
+ };
184
329
  const onClientAbort = () => relayAbort.abort(req.signal.reason);
185
- timeoutSignal.addEventListener("abort", onTimeout, { once: true });
186
330
  req.signal.addEventListener("abort", onClientAbort, { once: true });
187
331
  const cleanup = () => {
188
- timeoutSignal.removeEventListener("abort", onTimeout);
332
+ stopHeaderDeadline();
189
333
  req.signal.removeEventListener("abort", onClientAbort);
190
334
  };
191
335
  if (req.signal.aborted) onClientAbort();
192
- else if (timeoutSignal.aborted) onTimeout();
193
-
194
- let upstream: Response;
195
- try {
196
- const init: RequestInit & { duplex?: "half" } = {
197
- method: req.method,
198
- headers,
199
- redirect: "manual",
200
- signal: relayAbort.signal,
201
- ...(body ? { body, duplex: "half" } : {}),
202
- };
203
- upstream = await (deps.fetchImpl ?? fetch)(destination, init);
204
- } catch {
336
+
337
+ let bodyOverflow = false;
338
+ let bodyCancelled = false;
339
+ const body = req.method === "GET" || req.method === "HEAD" || !req.body
340
+ ? null
341
+ : byteCappedRequestBody(req.body, bodyLimit, () => { bodyOverflow = true; }, () => { bodyCancelled = true; });
342
+ // A streamed body keeps the caller's Content-Length, so the Home sees the same framing a
343
+ // buffered body produced instead of a chunked upload.
344
+ if (body && declaredLength !== null) headers.set("content-length", declaredLength);
345
+
346
+ // The hold: only while the tunnel is connecting or reconnecting, and at most `holdMs` from the
347
+ // first wait. A positive connected verdict is required again before every fetch.
348
+ const tunnel = deps.tunnel;
349
+ let holdUntil: number | undefined;
350
+ const holdForTunnel = async (gate: LinkTunnelGate): Promise<boolean> => {
351
+ const clockNow = deps.now ?? Date.now;
352
+ const now = clockNow();
353
+ holdUntil ??= now + (positive(deps.holdMs) ?? LINK_RELAY_HOLD_MS);
354
+ const remaining = holdUntil - now;
355
+ return remaining > 0 && await gate.waitForConnected(remaining, relayAbort.signal);
356
+ };
357
+ const tunnelUnavailable = async (): Promise<Response> => {
205
358
  cleanup();
359
+ try { await body?.cancel(); } catch { /* best effort */ }
206
360
  return jsonError(503, "link tunnel unavailable", true);
361
+ };
362
+ const readyForFetch = async (): Promise<boolean> => {
363
+ if (!tunnel) return false;
364
+ if (await tunnel.connected()) return true;
365
+ return tunnel.pending() && await holdForTunnel(tunnel) && await tunnel.connected();
366
+ };
367
+
368
+ let upstream: Response | undefined;
369
+ while (!upstream) {
370
+ if (!await readyForFetch()) return await tunnelUnavailable();
371
+ try {
372
+ const init: RequestInit & { duplex?: "half" } = {
373
+ method: req.method,
374
+ headers,
375
+ redirect: "manual",
376
+ signal: relayAbort.signal,
377
+ ...(body ? { body, duplex: "half" } : {}),
378
+ };
379
+ upstream = await (deps.fetchImpl ?? fetch)(destination, init);
380
+ } catch (error) {
381
+ if (bodyOverflow) {
382
+ cleanup();
383
+ return jsonError(413, "link relay request body too large");
384
+ }
385
+ // A refused connection sent nothing, and an untouched body can go again once the tunnel
386
+ // is back inside the same hold window. Anything else is never replayed.
387
+ const resendable = connectionRefused(error) && (body === null || (!body.locked && !bodyCancelled));
388
+ if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable();
389
+ }
207
390
  }
208
391
  if (relayAbort.signal.aborted) {
209
392
  cleanup();
@@ -218,9 +401,9 @@ export async function relayLinkDataRequest(
218
401
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
219
402
  return jsonError(502, "link relay response headers too large");
220
403
  }
221
- const declaredLength = upstream.headers.get("content-length");
222
- if (!sse && declaredLength !== null && (!/^\d+$/.test(declaredLength)
223
- || Number(declaredLength) > HUB_RELAY_RESPONSE_BODY_MAX_BYTES)) {
404
+ const responseLength = upstream.headers.get("content-length");
405
+ if (!sse && responseLength !== null && (!/^\d+$/.test(responseLength)
406
+ || Number(responseLength) > bodyLimit)) {
224
407
  cleanup();
225
408
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
226
409
  return jsonError(502, "link relay response body too large");
@@ -231,11 +414,11 @@ export async function relayLinkDataRequest(
231
414
  return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
232
415
  }
233
416
 
234
- // The handshake deadline ends once a response exists. The body owns cleanup after that.
235
- timeoutSignal.removeEventListener("abort", onTimeout);
417
+ // The header deadline ends once a response exists. The body owns cleanup after that.
418
+ stopHeaderDeadline();
236
419
  const responseBody = sse
237
- ? idleBoundedStream(upstream.body, relayAbort.signal, deps.clock ?? defaultClock,
420
+ ? idleBoundedStream(upstream.body, relayAbort.signal, clock,
238
421
  deps.sseIdleTimeoutMs ?? LINK_RELAY_SSE_IDLE_TIMEOUT_MS, () => relayAbort.abort(new DOMException("link relay SSE idle timeout", "TimeoutError")), cleanup)
239
- : boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, relayAbort.signal, cleanup);
422
+ : boundedRelayResponseStream(upstream.body, bodyLimit, relayAbort.signal, cleanup);
240
423
  return new Response(responseBody, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
241
424
  }
@@ -38,6 +38,52 @@ export function clientLinkStatePath(configDir?: string): string {
38
38
  return join(linkDir(configDir), "client-link.json");
39
39
  }
40
40
 
41
+ /**
42
+ * Durable evidence that this machine joined its link itself. It survives a lost sidecar, so a
43
+ * Child-initiated link whose sidecar is gone fails closed instead of being mistaken for a
44
+ * Home-initiated one, which the relay forwards without a tunnel ownership proof.
45
+ */
46
+ export function childLinkMarkerPath(configDir?: string): string {
47
+ return join(linkDir(configDir), "child-initiated.json");
48
+ }
49
+
50
+ /** True when the marker names `linkId`, or when a marker exists but cannot be read. */
51
+ export function isChildInitiatedLink(linkId: string, path: string = childLinkMarkerPath()): boolean {
52
+ let text: string;
53
+ try {
54
+ text = readFileSync(path, "utf8");
55
+ } catch (error) {
56
+ return !isMissingPathError(error);
57
+ }
58
+ try {
59
+ const raw = JSON.parse(text) as { linkId?: unknown };
60
+ return typeof raw?.linkId !== "string" || raw.linkId === linkId;
61
+ } catch {
62
+ return true;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Record the marker for a join made before markers existed. Called at runtime start while the
68
+ * sidecar is still intact, so a sidecar deleted afterwards cannot turn that link into a
69
+ * Home-initiated one. A missing or unreadable sidecar records nothing.
70
+ */
71
+ export function recordChildInitiatedLink(
72
+ linkId: string,
73
+ path: string = clientLinkStatePath(),
74
+ marker: string = join(dirname(path), "child-initiated.json"),
75
+ ): void {
76
+ let sidecar: ClientLinkState | null;
77
+ try {
78
+ sidecar = readClientLinkState(path);
79
+ } catch {
80
+ return;
81
+ }
82
+ if (!sidecar || sidecar.linkId !== linkId || isChildInitiatedLink(linkId, marker)) return;
83
+ atomicWriteFile(marker, `${JSON.stringify({ linkId })}\n`);
84
+ if (process.platform !== "win32") chmodSync(marker, 0o600);
85
+ }
86
+
41
87
  export function parseClientLinkState(value: unknown): ClientLinkState {
42
88
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new ClientLinkStateError("client-link.json is not an object");
43
89
  const raw = value as Record<string, unknown>;
@@ -91,6 +137,9 @@ export function writeClientLinkState(state: ClientLinkState, path: string = clie
91
137
  // the explicit mode on the final path.
92
138
  atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`);
93
139
  if (process.platform !== "win32") chmodSync(path, 0o600);
140
+ const marker = join(dir, "child-initiated.json");
141
+ atomicWriteFile(marker, `${JSON.stringify({ linkId: normalized.linkId })}\n`);
142
+ if (process.platform !== "win32") chmodSync(marker, 0o600);
94
143
  }
95
144
 
96
145
  /**
@@ -106,5 +155,10 @@ export function clearClientLinkState(expectedLinkId: string, path: string = clie
106
155
  if (isMissingPathError(error)) return false;
107
156
  throw error;
108
157
  }
158
+ try {
159
+ unlinkSync(join(dirname(path), "child-initiated.json"));
160
+ } catch (error) {
161
+ if (!isMissingPathError(error)) throw error;
162
+ }
109
163
  return true;
110
164
  }
@@ -0,0 +1,39 @@
1
+ import type { LinkStatusDto } from "../link/status-projection";
2
+ import type { ClientLinkState } from "./link-state";
3
+ import type { ClientLinkSupervisorStatus } from "./link-tunnel";
4
+
5
+ /** What the status route read from the client sidecar: the state, none, or an unreadable file. */
6
+ export type ClientLinkSidecarRead = ClientLinkState | null | "invalid";
7
+
8
+ /**
9
+ * The K16 status document of a connected Child, built from its own sidecar and tunnel
10
+ * supervisor. A client-initiated Child has no `links.json` record, so the Home-side projection
11
+ * (`projectLinkStatus`) cannot describe it; this one runs in the Child's own listener instead.
12
+ * A Home-initiated Child has no sidecar and reports `child: null`.
13
+ */
14
+ export function projectClientLinkChild(
15
+ sidecar: ClientLinkSidecarRead,
16
+ supervisor: ClientLinkSupervisorStatus,
17
+ now: number,
18
+ ): LinkStatusDto {
19
+ return { role: "child", listener: { state: "off", port: null }, links: [], child: childState(sidecar, supervisor, now) };
20
+ }
21
+
22
+ function childState(sidecar: ClientLinkSidecarRead, supervisor: ClientLinkSupervisorStatus, now: number): LinkStatusDto["child"] {
23
+ const alias = sidecar && sidecar !== "invalid" ? sidecar.alias : "unknown";
24
+ if (sidecar === "invalid" || supervisor.kind === "failed") {
25
+ return { alias, state: "failed", since: new Date(now).toISOString(), reason: "sidecar_invalid" };
26
+ }
27
+ if (!sidecar) return null;
28
+ if (supervisor.kind === "stopped") return { alias, state: "idle", since: new Date(now).toISOString(), reason: null };
29
+ const tunnel = supervisor.state;
30
+ return {
31
+ alias,
32
+ state: tunnel.kind,
33
+ since: new Date(tunnel.kind === "idle" ? now : tunnel.since).toISOString(),
34
+ // The keyed probe's finding (the Home refused the key, did not answer, or reports its own
35
+ // readiness as not ready) names the cause better than the tunnel state, so it wins; otherwise
36
+ // a failed tunnel shows its own reason.
37
+ reason: supervisor.probe ?? (tunnel.kind === "failed" ? tunnel.reason : null),
38
+ };
39
+ }
@@ -1,4 +1,4 @@
1
- import { buildExecArgv } from "../link/ssh-argv";
1
+ import { buildExecArgv, remoteOcxArgv } from "../link/ssh-argv";
2
2
  import type { SshRunner } from "../link/ssh-runner";
3
3
  import type { ClientLinkState } from "./link-state";
4
4
  import type { OrphanTunnelResult } from "./link-tunnel";
@@ -47,7 +47,7 @@ export async function teardownClientLink(
47
47
  const result = await deps.runner.run(
48
48
  buildExecArgv({
49
49
  alias: sidecar.alias,
50
- argv: ["ocx", "link", "revoke", "--link-id", sidecar.linkId],
50
+ argv: remoteOcxArgv(["link", "revoke", "--link-id", sidecar.linkId]),
51
51
  knownHostsFile: deps.knownHostsFile,
52
52
  }),
53
53
  { timeoutMs: deps.timeoutMs ?? HOME_REVOKE_TIMEOUT_MS },