@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.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
@@ -8,6 +8,7 @@ import {
8
8
  import { hostname } from "node:os";
9
9
  import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config";
10
10
  import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state";
11
+ import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
11
12
  import {
12
13
  inspectRemoteDesktopStore, readDesktopDisconnectReceipt, writeDesktopDisconnectReceipt,
13
14
  replaceRemoteDesktopCredential, restoreRemoteDesktopStore, finishRemoteDesktopCleanup,
@@ -22,6 +23,7 @@ import {
22
23
  injectCodexConfig,
23
24
  currentExternalCodexModelProvider,
24
25
  isCodexRoutingInjected,
26
+ standaloneCodexRoutingTarget,
25
27
  type CodexRoutingTarget,
26
28
  } from "../codex/inject";
27
29
  import {
@@ -44,6 +46,7 @@ import {
44
46
  import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
45
47
  import type {
46
48
  OcxClientConnectionConfig,
49
+ OcxConfig,
47
50
  OcxConnectedClientId,
48
51
  } from "../types";
49
52
  import {
@@ -169,20 +172,31 @@ function catalogMatchesFingerprint(body: string, fingerprint: string | undefined
169
172
  return createHash("sha256").update(body).digest("base64url") === fingerprint;
170
173
  }
171
174
 
172
- export function routingTarget(serverUrl: string, localPort?: number): CodexRoutingTarget & { link?: true } {
173
- const baseUrl = localPort === undefined
174
- ? `${serverUrl}/v1`
175
- : (() => {
176
- if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
177
- throw new Error("link mode requires a valid local config port");
178
- }
179
- return `http://localhost:${localPort}/v1`;
180
- })();
175
+ /**
176
+ * Codex routing for a connected client. A hub client points Codex at the hub with the admission
177
+ * token in `env_key`. A link Child (`localPort` given) keeps exactly the standalone loopback form,
178
+ * root `openai_base_url = "http://127.0.0.1:<port>/v1"` with no provider table and no `env_key`:
179
+ * its own listener relays to the Home and attaches the link key itself, so a GUI-launched Codex
180
+ * that never saw the key's environment variable still works, and joining changes no Codex bytes.
181
+ */
182
+ export function routingTarget(
183
+ serverUrl: string,
184
+ localPort?: number,
185
+ config?: Pick<OcxConfig, "codexDesktopAuthless" | "codexClientCompaction">,
186
+ ): CodexRoutingTarget & { link?: true } {
187
+ if (localPort === undefined) {
188
+ return { baseUrl: `${serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" };
189
+ }
190
+ if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
191
+ throw new Error("link mode requires a valid local config port");
192
+ }
181
193
  return {
182
- baseUrl,
183
- requiresAdmissionToken: true,
184
- tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
185
- ...(localPort === undefined ? {} : { link: true as const }),
194
+ ...standaloneCodexRoutingTarget(localPort, {
195
+ hostname: "127.0.0.1",
196
+ codexDesktopAuthless: config?.codexDesktopAuthless,
197
+ codexClientCompaction: config?.codexClientCompaction,
198
+ }),
199
+ link: true as const,
186
200
  };
187
201
  }
188
202
 
@@ -538,6 +552,9 @@ export async function connectClient(
538
552
  let writtenCatalogFingerprint: string | null = null;
539
553
  let injectionCommitted = false;
540
554
  let committed = false;
555
+ // Before any catalog, journal, client-state or credential write: `CODEX_HOME` is shared with the
556
+ // live proxy a sibling instance runs beside, and connecting re-points it (`sibling-start.ts`).
557
+ if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
541
558
  try {
542
559
  linkMode = (options.transport ?? "hub") === "link";
543
560
  if (linkMode) {
@@ -655,7 +672,7 @@ export async function connectClient(
655
672
  }), deps.lifecycleLockDeps);
656
673
 
657
674
  const config = earlyConfig ?? loadConfig();
658
- const target = routingTarget(serverUrl, linkMode ? config.port : undefined);
675
+ const target = routingTarget(serverUrl, linkMode ? config.port : undefined, config);
659
676
  const injectConfig = { ...config, syncResumeHistory: false };
660
677
  const preflight = await injectCodexConfig(config.port, injectConfig, {
661
678
  validateOnly: true,
@@ -750,6 +767,7 @@ export async function syncConnectedClient(
750
767
  _options: { restartCodex?: boolean } = {},
751
768
  deps: ClientConnectDeps = {},
752
769
  ): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> {
770
+ if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
753
771
  const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
754
772
  assertNoClientDisconnectPending();
755
773
  const state = readClientConnectionState();
@@ -796,7 +814,7 @@ export async function syncConnectedClient(
796
814
  if (next.selectedClients.includes("codex")) {
797
815
  const config = loadConfig();
798
816
  const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
799
- routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined), catalogPath: DEFAULT_CATALOG_PATH,
817
+ routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH,
800
818
  journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
801
819
  });
802
820
  if (!result.success || result.status === "skipped") throw new Error(result.message);
@@ -891,6 +909,7 @@ export async function disconnectClient(
891
909
  desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved";
892
910
  restartRequired: boolean;
893
911
  }> {
912
+ if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
894
913
  const keepCatalog = options.keepCatalog === true;
895
914
  const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => {
896
915
  const read = readDesktopDisconnectReceipt();
@@ -0,0 +1,102 @@
1
+ import type { Server } from "bun";
2
+ import { formatErrorResponse } from "../bridge";
3
+ import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets";
4
+ import { linkRouteAllowed } from "../link/routes";
5
+ import { isAllowedRequestOrigin, type RequestPolicyView } from "../server/auth-cors";
6
+ import { relayLinkDataRequest, type LinkRelayDeps } from "./link-relay";
7
+
8
+ /** How often an unavailable link key is looked for again. A valid key is never re-read. */
9
+ export const LINK_KEY_RETRY_MS = 1_000;
10
+
11
+ export interface LinkKeySourceDeps {
12
+ readToken?: () => ServiceApiTokenState;
13
+ now?: () => number;
14
+ }
15
+
16
+ /**
17
+ * The link key for relayed requests, read from the service token file once and then held in
18
+ * memory, so no data-plane request touches the disk. The file must still hold the key this
19
+ * connection committed (`tokenFingerprint`); anything else yields null. Only while the key is
20
+ * unavailable is the file read again, at most once per `LINK_KEY_RETRY_MS`. The key is never
21
+ * logged, returned in a response, or written anywhere.
22
+ */
23
+ export function createLinkKeySource(expectedFingerprint: string, deps: LinkKeySourceDeps = {}): () => string | null {
24
+ const readToken = deps.readToken ?? readServiceApiTokenState;
25
+ const now = deps.now ?? Date.now;
26
+ let key: string | null = null;
27
+ let lastRead = 0;
28
+ const load = (): void => {
29
+ lastRead = now();
30
+ try {
31
+ const state = readToken();
32
+ key = state.kind === "present" && state.fingerprint === expectedFingerprint ? state.token : null;
33
+ } catch {
34
+ key = null;
35
+ }
36
+ };
37
+ load();
38
+ return () => {
39
+ if (key === null && now() - lastRead >= LINK_KEY_RETRY_MS) load();
40
+ return key;
41
+ };
42
+ }
43
+
44
+ export interface LinkIngress {
45
+ tunnelPort: number;
46
+ /** The listener's loopback policy: the standalone Host and Origin anti-rebinding gate. */
47
+ policy: RequestPolicyView;
48
+ linkKey: () => string | null;
49
+ relay?: LinkRelayDeps;
50
+ }
51
+
52
+ const RESPONSES_WEBSOCKET_DISABLED = "Responses WebSocket transport is disabled; use HTTP";
53
+
54
+ /**
55
+ * Lift Bun's per-request idle timer for a relayed request, as the standalone does on its data
56
+ * routes (`disableResponsesRequestTimeout`). Without it the listener's idle limit cuts a quiet
57
+ * stretch of a long generation: an SSE gap between events, or a Home that holds a turn after
58
+ * its headers. The relay bounds the wait itself (header deadline, SSE idle limit, caller abort).
59
+ * It is a local call rather than the standalone helper's import, which would load the Responses
60
+ * WebSocket upstream modules into every Child.
61
+ */
62
+ function liftRequestIdleTimer(req: Request, server: Pick<Server<unknown>, "timeout"> | undefined): void {
63
+ if (!server) return;
64
+ try { server.timeout(req, 0); } catch { /* the request may already be closed */ }
65
+ }
66
+
67
+ /**
68
+ * The link-mode data plane of the client machine listener. It answers only the requests it
69
+ * owns and returns null for everything else, which the listener handles as before.
70
+ *
71
+ * - A `/v1/responses` WebSocket upgrade gets 426, the answer codex-rs turns into an HTTP
72
+ * fallback; the link never carries a WebSocket.
73
+ * - Every relayed route passes the standalone loopback Host/Origin gate before any upstream
74
+ * fetch, so a rebinding or cross-site browser page cannot reach the Home through this port.
75
+ * - Without the committed link key nothing is fetched, and the caller gets
76
+ * `503 link_credential_unavailable`.
77
+ * - `/readyz` stays local: it reports this listener, not the tunnel.
78
+ * - A relayed request is exempt from the listener's idle limit, like a standalone data route.
79
+ */
80
+ export function handleLinkIngress(
81
+ req: Request,
82
+ url: URL,
83
+ ingress: LinkIngress,
84
+ server?: Pick<Server<unknown>, "timeout">,
85
+ ): Response | Promise<Response> | null {
86
+ const upgrade = req.headers.get("upgrade");
87
+ if (upgrade !== null) {
88
+ if (url.pathname !== "/v1/responses" || upgrade.trim().toLowerCase() !== "websocket") return null;
89
+ if (!isAllowedRequestOrigin(req, ingress.policy)) {
90
+ return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin");
91
+ }
92
+ return formatErrorResponse(426, "upgrade_required", RESPONSES_WEBSOCKET_DISABLED);
93
+ }
94
+ if (url.pathname === "/readyz" || !linkRouteAllowed(url, req)) return null;
95
+ if (!isAllowedRequestOrigin(req, ingress.policy)) {
96
+ return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
97
+ }
98
+ const admissionKey = ingress.linkKey();
99
+ if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 });
100
+ liftRequestIdleTimer(req, server);
101
+ return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey }, ingress.relay);
102
+ }
@@ -1,9 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
- import { findAvailablePort } from "../server/ports";
4
- import { isLinkPort } from "../link/ports";
5
- import { buildExecArgv } from "../link/ssh-argv";
6
- import type { SshRunner } from "../link/ssh-runner";
3
+ import { isPortAvailable } from "../server/ports";
4
+ import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../link/ports";
5
+ import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv";
6
+ import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner";
7
7
  import { connectClient, type ClientConnectDeps } from "./connect";
8
8
  import {
9
9
  clearClientLinkState,
@@ -24,6 +24,7 @@ const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000;
24
24
  const JOIN_TUNNEL_POLL_MS = 100;
25
25
  const JOIN_REVOKE_TIMEOUT_MS = 30_000;
26
26
  const JOIN_CONFIRM_TTL_MS = 5 * 60_000;
27
+ const JOIN_PORT_ATTEMPTS = 32;
27
28
  const LINK_ID = /^lnk_[0-9a-f]{16}$/;
28
29
  const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
29
30
  const DATA_KEY = /^ocx_data_[0-9a-f]{40}$/;
@@ -40,6 +41,7 @@ export type JoinFailureCode =
40
41
  | "host_confirmation_expired"
41
42
  | "join_port_failed"
42
43
  | "join_issue_failed"
44
+ | "remote_ocx_missing"
43
45
  | "join_tunnel_failed"
44
46
  | "admission_failed"
45
47
  | "join_connect_failed"
@@ -47,7 +49,8 @@ export type JoinFailureCode =
47
49
  | "join_restart_failed";
48
50
 
49
51
  export class ClientLinkJoinError extends Error {
50
- constructor(readonly code: JoinFailureCode, readonly linkId?: string) {
52
+ /** `hint` is a bounded line from ssh stderr or the ssh runner's own failure, for the dashboard; it is not part of the message. */
53
+ constructor(readonly code: JoinFailureCode, readonly linkId?: string, readonly hint?: string) {
51
54
  super(linkId ? `${code}: ${linkId}` : code);
52
55
  this.name = "ClientLinkJoinError";
53
56
  }
@@ -118,6 +121,24 @@ function parseIssuedLink(stdout: string): IssuedLink | null {
118
121
  };
119
122
  }
120
123
 
124
+ /**
125
+ * A free loopback port in the join range (`JOIN_TUNNEL_PORT_MIN`-`JOIN_TUNNEL_PORT_MAX`), tried at
126
+ * random so a fixed-port service on this computer is not hit every time.
127
+ */
128
+ export async function chooseJoinTunnelPort(deps: {
129
+ isAvailable?: (port: number) => Promise<boolean>;
130
+ random?: () => number;
131
+ } = {}): Promise<number> {
132
+ const isAvailable = deps.isAvailable ?? (port => isPortAvailable(port, "127.0.0.1"));
133
+ const random = deps.random ?? Math.random;
134
+ const span = JOIN_TUNNEL_PORT_MAX - JOIN_TUNNEL_PORT_MIN + 1;
135
+ for (let attempt = 0; attempt < JOIN_PORT_ATTEMPTS; attempt += 1) {
136
+ const port = JOIN_TUNNEL_PORT_MIN + Math.min(span - 1, Math.floor(random() * span));
137
+ if (await isAvailable(port)) return port;
138
+ }
139
+ throw new Error("no free port in the join tunnel range");
140
+ }
141
+
121
142
  function localAlias(deps: ClientLinkJoinDeps): string {
122
143
  const raw = (deps.hostname ?? hostname)().trim();
123
144
  const normalized = raw.replace(/[^A-Za-z0-9_\.\-]/g, "-").replace(/^-+/, "").slice(0, 253);
@@ -148,7 +169,7 @@ async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string, alias
148
169
  const result = await deps.runner.run(
149
170
  buildExecArgv({
150
171
  alias,
151
- argv: ["ocx", "link", "revoke", "--link-id", linkId],
172
+ argv: remoteOcxArgv(["link", "revoke", "--link-id", linkId]),
152
173
  knownHostsFile: deps.knownHostsFile,
153
174
  }),
154
175
  { timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
@@ -240,7 +261,7 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
240
261
  await compensateStaleSidecar(deps);
241
262
  let tunnelPort: number;
242
263
  try {
243
- tunnelPort = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))();
264
+ tunnelPort = await (deps.choosePort ?? (() => chooseJoinTunnelPort()))();
244
265
  if (!isLinkPort(tunnelPort)) throw new Error("invalid link port");
245
266
  } catch (error) {
246
267
  void error;
@@ -248,24 +269,24 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
248
269
  }
249
270
 
250
271
  const thisAlias = localAlias(deps);
251
- let issued: IssuedLink;
272
+ let result: SshRunResult;
252
273
  try {
253
- const result = await deps.runner.run(
274
+ result = await deps.runner.run(
254
275
  buildExecArgv({
255
276
  alias: input.alias,
256
- argv: ["ocx", "link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"],
277
+ argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"]),
257
278
  knownHostsFile: deps.knownHostsFile,
258
279
  }),
259
280
  { timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
260
281
  );
261
- if (result.code !== 0) throw new Error("issue failed");
262
- const parsed = parseIssuedLink(result.stdout);
263
- if (!parsed) throw new Error("invalid issue response");
264
- issued = parsed;
265
282
  } catch (error) {
266
- void error;
267
- throw new ClientLinkJoinError("join_issue_failed");
283
+ throw new ClientLinkJoinError("join_issue_failed", undefined, sshRunnerErrorHint(error));
268
284
  }
285
+ // Hints come from stderr only: a successful issue prints the new data key on stdout.
286
+ if (result.code === REMOTE_COMMAND_NOT_FOUND) throw new ClientLinkJoinError("remote_ocx_missing", undefined, sshFailureHint(result.stderr));
287
+ const parsed = result.code === 0 ? parseIssuedLink(result.stdout) : null;
288
+ if (!parsed) throw new ClientLinkJoinError("join_issue_failed", undefined, result.code === 0 ? undefined : sshFailureHint(result.stderr));
289
+ const issued: IssuedLink = parsed;
269
290
 
270
291
  let tunnel: ClientLinkTunnelHandle | null = null;
271
292
  try {