@bitkyc08/opencodex 2.65.0-preview.20260925 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-DUvc_Wul.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-f0lTZ4sF.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-CsYvvpr3.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-BB0iHG8-.js +0 -86
@@ -0,0 +1,323 @@
1
+ import { randomBytes } from "node:crypto";
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";
7
+ import { connectClient, type ClientConnectDeps } from "./connect";
8
+ import {
9
+ clearClientLinkState,
10
+ clientLinkStatePath,
11
+ readClientLinkState,
12
+ writeClientLinkState,
13
+ type ClientLinkState,
14
+ } from "./link-state";
15
+ import { isLinkConnection, readClientConnectionState, type ClientConnectionState } from "./state";
16
+ import {
17
+ spawnClientLinkTunnel,
18
+ type ClientLinkTunnelDeps,
19
+ type ClientLinkTunnelHandle,
20
+ } from "./link-tunnel";
21
+ import type { OcxConnectedClientId } from "../types";
22
+
23
+ const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000;
24
+ const JOIN_TUNNEL_POLL_MS = 100;
25
+ const JOIN_REVOKE_TIMEOUT_MS = 30_000;
26
+ const JOIN_CONFIRM_TTL_MS = 5 * 60_000;
27
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
28
+ const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
29
+ const DATA_KEY = /^ocx_data_[0-9a-f]{40}$/;
30
+ const VALID_ALIAS = /^[A-Za-z0-9_][A-Za-z0-9._@%+:\[\]-]{0,252}$/;
31
+
32
+ export interface JoinConfirmedHost {
33
+ alias: string;
34
+ fingerprint: string;
35
+ probedAt: number;
36
+ }
37
+
38
+ export type JoinFailureCode =
39
+ | "host_not_confirmed"
40
+ | "host_confirmation_expired"
41
+ | "join_port_failed"
42
+ | "join_issue_failed"
43
+ | "join_tunnel_failed"
44
+ | "admission_failed"
45
+ | "join_connect_failed"
46
+ | "join_rollback_failed"
47
+ | "join_restart_failed";
48
+
49
+ export class ClientLinkJoinError extends Error {
50
+ constructor(readonly code: JoinFailureCode, readonly linkId?: string) {
51
+ super(linkId ? `${code}: ${linkId}` : code);
52
+ this.name = "ClientLinkJoinError";
53
+ }
54
+ }
55
+
56
+ interface IssuedLink {
57
+ linkId: string;
58
+ apiKeyId: string;
59
+ key: string;
60
+ listenerPort: number;
61
+ }
62
+
63
+ export interface ClientLinkJoinDeps {
64
+ runner: SshRunner;
65
+ knownHostsFile: string;
66
+ confirmedHost?: JoinConfirmedHost;
67
+ configDir?: string;
68
+ choosePort?: () => Promise<number>;
69
+ now?: () => number;
70
+ sleep?: (ms: number) => Promise<void>;
71
+ hostname?: () => string;
72
+ randomBytes?: (size: number) => Uint8Array;
73
+ fetchImpl?: typeof fetch;
74
+ spawnTunnel?: (spec: {
75
+ linkId: string;
76
+ alias: string;
77
+ tunnelPort: number;
78
+ peerListenerPort: number;
79
+ }, deps?: ClientLinkTunnelDeps) => ClientLinkTunnelHandle;
80
+ writeState?: (state: ClientLinkState) => void;
81
+ clearState?: (linkId: string) => void;
82
+ readSidecar?: () => ClientLinkState | null;
83
+ readConnectionState?: () => ClientConnectionState;
84
+ connect?: typeof connectClient;
85
+ connectDeps?: ClientConnectDeps;
86
+ selectedClients?: OcxConnectedClientId[];
87
+ /** Hands the standalone process to the client runtime once the join has committed. */
88
+ scheduleRestart: () => void;
89
+ }
90
+
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return value !== null && typeof value === "object" && !Array.isArray(value);
93
+ }
94
+
95
+ function validPort(value: unknown): value is number {
96
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
97
+ }
98
+
99
+ function parseIssuedLink(stdout: string): IssuedLink | null {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(stdout.trim());
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (!isRecord(parsed) || Object.keys(parsed).length !== 4
107
+ || typeof parsed.linkId !== "string" || !LINK_ID.test(parsed.linkId)
108
+ || typeof parsed.apiKeyId !== "string" || !API_KEY_ID.test(parsed.apiKeyId)
109
+ || typeof parsed.key !== "string" || !DATA_KEY.test(parsed.key)
110
+ || !validPort(parsed.listenerPort)) {
111
+ return null;
112
+ }
113
+ return {
114
+ linkId: parsed.linkId,
115
+ apiKeyId: parsed.apiKeyId,
116
+ key: parsed.key,
117
+ listenerPort: parsed.listenerPort,
118
+ };
119
+ }
120
+
121
+ function localAlias(deps: ClientLinkJoinDeps): string {
122
+ const raw = (deps.hostname ?? hostname)().trim();
123
+ const normalized = raw.replace(/[^A-Za-z0-9_\.\-]/g, "-").replace(/^-+/, "").slice(0, 253);
124
+ if (VALID_ALIAS.test(normalized)) return normalized;
125
+ const bytes = (deps.randomBytes ?? randomBytes)(4);
126
+ return `client-${Buffer.from(bytes).toString("hex")}`;
127
+ }
128
+
129
+ function defaultWriteState(configDir: string | undefined, state: ClientLinkState): void {
130
+ writeClientLinkState(state, clientLinkStatePath(configDir));
131
+ }
132
+
133
+ function defaultClearState(configDir: string | undefined, linkId: string): void {
134
+ clearClientLinkState(linkId, clientLinkStatePath(configDir));
135
+ }
136
+
137
+ async function stopTunnel(tunnel: ClientLinkTunnelHandle | null): Promise<void> {
138
+ if (!tunnel) return;
139
+ try {
140
+ await tunnel.stop();
141
+ } catch (error) {
142
+ void error;
143
+ }
144
+ }
145
+
146
+ async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string, alias = deps.confirmedHost?.alias ?? ""): Promise<boolean> {
147
+ try {
148
+ const result = await deps.runner.run(
149
+ buildExecArgv({
150
+ alias,
151
+ argv: ["ocx", "link", "revoke", "--link-id", linkId],
152
+ knownHostsFile: deps.knownHostsFile,
153
+ }),
154
+ { timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
155
+ );
156
+ return result.code === 0;
157
+ } catch (error) {
158
+ void error;
159
+ return false;
160
+ }
161
+ }
162
+
163
+ async function rollback(
164
+ deps: ClientLinkJoinDeps,
165
+ linkId: string,
166
+ tunnel: ClientLinkTunnelHandle | null,
167
+ ): Promise<void> {
168
+ await stopTunnel(tunnel);
169
+ if (!await revokeIssuedLink(deps, linkId)) throw new ClientLinkJoinError("join_rollback_failed", linkId);
170
+ try {
171
+ (deps.clearState ?? (id => defaultClearState(deps.configDir, id)))(linkId);
172
+ } catch (error) {
173
+ void error;
174
+ }
175
+ }
176
+
177
+ async function compensateStaleSidecar(deps: ClientLinkJoinDeps): Promise<void> {
178
+ let sidecar: ClientLinkState | null;
179
+ try {
180
+ sidecar = (deps.readSidecar ?? (() => readClientLinkState(clientLinkStatePath(deps.configDir))))();
181
+ } catch {
182
+ // A corrupt sidecar is overwritten by the next successful join.
183
+ return;
184
+ }
185
+ if (!sidecar) return;
186
+ let connection: ClientConnectionState;
187
+ try {
188
+ connection = (deps.readConnectionState ?? readClientConnectionState)();
189
+ } catch {
190
+ connection = { kind: "invalid", reason: "client connection state could not be read" };
191
+ }
192
+ if (connection.kind === "connected" && isLinkConnection(connection.value)
193
+ && connection.value.link?.linkId === sidecar.linkId) return;
194
+ if (!await revokeIssuedLink(deps, sidecar.linkId, sidecar.alias)) {
195
+ throw new ClientLinkJoinError("join_rollback_failed", sidecar.linkId);
196
+ }
197
+ try {
198
+ (deps.clearState ?? (linkId => defaultClearState(deps.configDir, linkId)))(sidecar.linkId);
199
+ } catch {
200
+ throw new ClientLinkJoinError("join_rollback_failed", sidecar.linkId);
201
+ }
202
+ }
203
+
204
+ async function waitForReady(
205
+ deps: ClientLinkJoinDeps,
206
+ port: number,
207
+ key: string,
208
+ ): Promise<void> {
209
+ const fetchImpl = deps.fetchImpl ?? fetch;
210
+ const now = deps.now ?? Date.now;
211
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
212
+ const deadline = now() + JOIN_TUNNEL_READY_TIMEOUT_MS;
213
+ for (;;) {
214
+ try {
215
+ const response = await fetchImpl(`http://127.0.0.1:${port}/readyz`, {
216
+ headers: { "x-opencodex-api-key": key },
217
+ });
218
+ if (response.status === 200) return;
219
+ if (response.status === 401) throw new ClientLinkJoinError("admission_failed");
220
+ } catch (error) {
221
+ if (error instanceof ClientLinkJoinError) throw error;
222
+ }
223
+ const remaining = deadline - now();
224
+ if (remaining <= 0) throw new ClientLinkJoinError("join_tunnel_failed");
225
+ await sleep(Math.min(JOIN_TUNNEL_POLL_MS, remaining));
226
+ }
227
+ }
228
+
229
+ function requireConfirmedHost(deps: ClientLinkJoinDeps, alias: string): JoinConfirmedHost {
230
+ const confirmed = deps.confirmedHost;
231
+ if (!confirmed || confirmed.alias !== alias) throw new ClientLinkJoinError("host_not_confirmed");
232
+ if ((deps.now ?? Date.now)() - confirmed.probedAt > JOIN_CONFIRM_TTL_MS) {
233
+ throw new ClientLinkJoinError("host_confirmation_expired");
234
+ }
235
+ return confirmed;
236
+ }
237
+
238
+ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string }): Promise<{ linkId: string; apiKeyId: string }> {
239
+ const confirmed = requireConfirmedHost(deps, input.alias);
240
+ await compensateStaleSidecar(deps);
241
+ let tunnelPort: number;
242
+ try {
243
+ tunnelPort = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))();
244
+ if (!isLinkPort(tunnelPort)) throw new Error("invalid link port");
245
+ } catch (error) {
246
+ void error;
247
+ throw new ClientLinkJoinError("join_port_failed");
248
+ }
249
+
250
+ const thisAlias = localAlias(deps);
251
+ let issued: IssuedLink;
252
+ try {
253
+ const result = await deps.runner.run(
254
+ buildExecArgv({
255
+ alias: input.alias,
256
+ argv: ["ocx", "link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"],
257
+ knownHostsFile: deps.knownHostsFile,
258
+ }),
259
+ { timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
260
+ );
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
+ } catch (error) {
266
+ void error;
267
+ throw new ClientLinkJoinError("join_issue_failed");
268
+ }
269
+
270
+ let tunnel: ClientLinkTunnelHandle | null = null;
271
+ try {
272
+ const state: ClientLinkState = {
273
+ linkId: issued.linkId,
274
+ alias: input.alias,
275
+ hubHostKeyFingerprint: confirmed.fingerprint,
276
+ peerListenerPort: issued.listenerPort,
277
+ tunnelPort,
278
+ };
279
+ (deps.writeState ?? (value => defaultWriteState(deps.configDir, value)))(state);
280
+ tunnel = (deps.spawnTunnel ?? spawnClientLinkTunnel)({
281
+ linkId: issued.linkId,
282
+ alias: input.alias,
283
+ tunnelPort,
284
+ peerListenerPort: issued.listenerPort,
285
+ }, {
286
+ runner: deps.runner,
287
+ configDir: deps.configDir,
288
+ knownHostsFile: deps.knownHostsFile,
289
+ });
290
+ await waitForReady(deps, tunnelPort, issued.key);
291
+ } catch (error) {
292
+ const code = error instanceof ClientLinkJoinError ? error.code : "join_tunnel_failed";
293
+ await rollback(deps, issued.linkId, tunnel);
294
+ throw new ClientLinkJoinError(code);
295
+ }
296
+
297
+ try {
298
+ const connect = deps.connect ?? connectClient;
299
+ await connect({
300
+ serverUrl: `http://127.0.0.1:${tunnelPort}`,
301
+ managementUrl: `http://127.0.0.1:${tunnelPort}`,
302
+ credential: { kind: "link", apiKeyId: issued.apiKeyId, key: issued.key },
303
+ transport: "link",
304
+ link: { tunnelPort, linkId: issued.linkId },
305
+ selectedClients: deps.selectedClients ?? ["codex", "claude"],
306
+ managementTransport: "direct",
307
+ }, {
308
+ fetchImpl: deps.fetchImpl,
309
+ ...deps.connectDeps,
310
+ });
311
+ } catch {
312
+ await rollback(deps, issued.linkId, tunnel);
313
+ throw new ClientLinkJoinError("join_connect_failed");
314
+ }
315
+
316
+ await stopTunnel(tunnel);
317
+ try {
318
+ deps.scheduleRestart();
319
+ } catch {
320
+ throw new ClientLinkJoinError("join_restart_failed", issued.linkId);
321
+ }
322
+ return { linkId: issued.linkId, apiKeyId: issued.apiKeyId };
323
+ }
@@ -0,0 +1,241 @@
1
+ import {
2
+ boundedRelayResponseStream,
3
+ filterRelayHeaders,
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
+ validateHubRelayRequestHeaders,
10
+ } from "./hub-relay";
11
+ import { linkRouteAllowed } from "../link/routes";
12
+ import { isLinkPort } from "../link/ports";
13
+
14
+ export interface LinkRelayTarget {
15
+ tunnelPort: number;
16
+ }
17
+
18
+ export interface LinkRelayClock {
19
+ setTimeout: typeof setTimeout;
20
+ clearTimeout: typeof clearTimeout;
21
+ }
22
+
23
+ export interface LinkRelayDeps {
24
+ fetchImpl?: typeof fetch;
25
+ clock?: LinkRelayClock;
26
+ timeoutMs?: number;
27
+ sseIdleTimeoutMs?: number;
28
+ }
29
+
30
+ export const LINK_RELAY_RETRY_AFTER_SECONDS = 1;
31
+ export const LINK_RELAY_SSE_IDLE_TIMEOUT_MS = 300_000;
32
+
33
+ const defaultClock: LinkRelayClock = {
34
+ setTimeout: globalThis.setTimeout,
35
+ clearTimeout: globalThis.clearTimeout,
36
+ };
37
+ const REQUEST_OMITTED_HEADERS = new Set(["content-length", "host"]);
38
+ const RESPONSE_OMITTED_HEADERS = new Set(["content-encoding", "content-length"]);
39
+
40
+ function jsonError(status: number, error: string, retry = false): Response {
41
+ const headers = retry ? { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) } : undefined;
42
+ return Response.json({ error }, { status, headers });
43
+ }
44
+
45
+ export function linkRelayDestination(url: URL, target: LinkRelayTarget): string {
46
+ if (!isLinkPort(target.tunnelPort)) {
47
+ throw new RangeError("invalid link tunnel port");
48
+ }
49
+ return `http://127.0.0.1:${target.tunnelPort}${url.pathname}${url.search}`;
50
+ }
51
+
52
+ export function forwardLinkRequestHeaders(source: Headers): Headers {
53
+ const validation = validateHubRelayRequestHeaders([...source]);
54
+ if (!validation.ok) return new Headers();
55
+ const omitted = new Set([...REQUEST_OMITTED_HEADERS, ...validation.connectionNamed]);
56
+ return filterRelayHeaders(source, undefined, omitted);
57
+ }
58
+
59
+ export function sanitizeLinkResponseHeaders(source: Headers): Headers {
60
+ const connectionNamed = new Set((source.get("connection") ?? "")
61
+ .split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
62
+ return filterRelayHeaders(source, undefined, new Set([...RESPONSE_OMITTED_HEADERS, ...connectionNamed]));
63
+ }
64
+
65
+ function isSse(headers: Headers): boolean {
66
+ return headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
67
+ }
68
+
69
+ function idleBoundedStream(
70
+ body: ReadableStream<Uint8Array>,
71
+ signal: AbortSignal,
72
+ clock: LinkRelayClock,
73
+ idleTimeoutMs: number,
74
+ onIdle: () => void,
75
+ cleanup: () => void,
76
+ ): ReadableStream<Uint8Array> {
77
+ const reader = body.getReader();
78
+ let timer: ReturnType<typeof setTimeout> | undefined;
79
+ let finished = false;
80
+ let closed = false;
81
+ let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined;
82
+ const clearIdleTimer = () => {
83
+ if (timer !== undefined) clock.clearTimeout(timer);
84
+ timer = undefined;
85
+ };
86
+ const finish = () => {
87
+ if (finished) return;
88
+ finished = true;
89
+ clearIdleTimer();
90
+ signal.removeEventListener("abort", onAbort);
91
+ cleanup();
92
+ try { reader.releaseLock(); } catch { /* a pending read may still own it */ }
93
+ };
94
+ const close = () => {
95
+ if (closed) return;
96
+ closed = true;
97
+ try { controllerRef?.close(); } catch { /* the consumer may have cancelled */ }
98
+ };
99
+ const cancelUpstream = (reason: unknown, closeResponse: boolean) => {
100
+ if (finished) return;
101
+ clearIdleTimer();
102
+ try {
103
+ void reader.cancel(reason).catch(() => undefined).finally(() => {
104
+ finish();
105
+ if (closeResponse) close();
106
+ });
107
+ } catch {
108
+ finish();
109
+ if (closeResponse) close();
110
+ }
111
+ };
112
+ const onAbort = () => cancelUpstream(signal.reason, true);
113
+ const armIdleTimer = () => {
114
+ clearIdleTimer();
115
+ timer = clock.setTimeout(() => {
116
+ onIdle();
117
+ cancelUpstream(new DOMException("link relay SSE idle timeout", "TimeoutError"), true);
118
+ }, idleTimeoutMs);
119
+ };
120
+ signal.addEventListener("abort", onAbort, { once: true });
121
+ return new ReadableStream<Uint8Array>({
122
+ start(controller) {
123
+ controllerRef = controller;
124
+ if (signal.aborted) onAbort();
125
+ else armIdleTimer();
126
+ },
127
+ async pull(controller) {
128
+ if (closed) return;
129
+ try {
130
+ const next = await reader.read();
131
+ if (next.done) {
132
+ finish();
133
+ closed = true;
134
+ controller.close();
135
+ return;
136
+ }
137
+ if (next.value.byteLength > 0) armIdleTimer();
138
+ controller.enqueue(next.value);
139
+ } catch (error) {
140
+ finish();
141
+ if (!closed) {
142
+ closed = true;
143
+ controller.error(error);
144
+ }
145
+ }
146
+ },
147
+ async cancel(reason) {
148
+ cancelUpstream(reason, false);
149
+ },
150
+ });
151
+ }
152
+
153
+ export async function relayLinkDataRequest(
154
+ req: Request,
155
+ target: LinkRelayTarget,
156
+ deps: LinkRelayDeps = {},
157
+ ): Promise<Response> {
158
+ const url = new URL(req.url);
159
+ if (!linkRouteAllowed(url, req)) return jsonError(404, "not_found");
160
+ let destination: string;
161
+ try { destination = linkRelayDestination(url, target); } catch { return jsonError(404, "not_found"); }
162
+ const validation = validateHubRelayRequestHeaders([...req.headers]);
163
+ if (!validation.ok) return jsonError(400, "link relay request headers refused");
164
+
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 {
171
+ return jsonError(413, "link relay request body too large");
172
+ }
173
+ const headers = forwardLinkRequestHeaders(req.headers);
174
+ if (!headersWithinLimit(headers)) {
175
+ return jsonError(431, "link relay request headers too large");
176
+ }
177
+
178
+ 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);
184
+ const onClientAbort = () => relayAbort.abort(req.signal.reason);
185
+ timeoutSignal.addEventListener("abort", onTimeout, { once: true });
186
+ req.signal.addEventListener("abort", onClientAbort, { once: true });
187
+ const cleanup = () => {
188
+ timeoutSignal.removeEventListener("abort", onTimeout);
189
+ req.signal.removeEventListener("abort", onClientAbort);
190
+ };
191
+ 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 {
205
+ cleanup();
206
+ return jsonError(503, "link tunnel unavailable", true);
207
+ }
208
+ if (relayAbort.signal.aborted) {
209
+ cleanup();
210
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
211
+ return jsonError(503, "link tunnel unavailable", true);
212
+ }
213
+
214
+ const sse = isSse(upstream.headers);
215
+ const responseHeaders = sanitizeLinkResponseHeaders(upstream.headers);
216
+ if (!headersWithinLimit(responseHeaders)) {
217
+ cleanup();
218
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
219
+ return jsonError(502, "link relay response headers too large");
220
+ }
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)) {
224
+ cleanup();
225
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
226
+ return jsonError(502, "link relay response body too large");
227
+ }
228
+ if (req.method === "HEAD" || !upstream.body) {
229
+ cleanup();
230
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
231
+ return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
232
+ }
233
+
234
+ // The handshake deadline ends once a response exists. The body owns cleanup after that.
235
+ timeoutSignal.removeEventListener("abort", onTimeout);
236
+ const responseBody = sse
237
+ ? idleBoundedStream(upstream.body, relayAbort.signal, deps.clock ?? defaultClock,
238
+ 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);
240
+ return new Response(responseBody, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
241
+ }
@@ -0,0 +1,110 @@
1
+ import { chmodSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
4
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
5
+ import { hardenSecretDir } from "../lib/windows-secret-acl";
6
+ import { linkDir } from "../link/paths";
7
+ import { isLinkPort } from "../link/ports";
8
+ import { assertSshAlias } from "../link/ssh-argv";
9
+
10
+ /**
11
+ * The client-owned half of a client-initiated link, stored beside the hub-side `links.json`
12
+ * as `<configDir>/link/client-link.json` (directory 0700, file 0600). It holds no key: the
13
+ * data key lives only in the client connection config, which already owns it.
14
+ */
15
+ export interface ClientLinkState {
16
+ linkId: string;
17
+ alias: string;
18
+ hubHostKeyFingerprint: string;
19
+ peerListenerPort: number;
20
+ tunnelPort: number;
21
+ }
22
+
23
+ export class ClientLinkStateError extends Error {
24
+ constructor(message: string, options?: ErrorOptions) {
25
+ super(message, options);
26
+ this.name = "ClientLinkStateError";
27
+ }
28
+ }
29
+
30
+ const FIELDS = new Set(["linkId", "alias", "hubHostKeyFingerprint", "peerListenerPort", "tunnelPort"]);
31
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
32
+ const FINGERPRINT = /^[A-Z0-9]+:[A-Za-z0-9+/=]{16,128}$/;
33
+
34
+ const isPort = (value: unknown): value is number =>
35
+ typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
36
+
37
+ export function clientLinkStatePath(configDir?: string): string {
38
+ return join(linkDir(configDir), "client-link.json");
39
+ }
40
+
41
+ export function parseClientLinkState(value: unknown): ClientLinkState {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ClientLinkStateError("client-link.json is not an object");
43
+ const raw = value as Record<string, unknown>;
44
+ const fail = (field: string): never => { throw new ClientLinkStateError(`client-link.${field} is invalid`); };
45
+ for (const field of Object.keys(raw)) if (!FIELDS.has(field)) fail(field);
46
+ if (typeof raw.linkId !== "string" || !LINK_ID.test(raw.linkId)) fail("linkId");
47
+ if (typeof raw.alias !== "string") fail("alias");
48
+ try {
49
+ assertSshAlias(raw.alias as string);
50
+ } catch {
51
+ fail("alias");
52
+ }
53
+ if (typeof raw.hubHostKeyFingerprint !== "string" || !FINGERPRINT.test(raw.hubHostKeyFingerprint)) fail("hubHostKeyFingerprint");
54
+ if (!isPort(raw.peerListenerPort)) fail("peerListenerPort");
55
+ if (!isLinkPort(raw.tunnelPort)) fail("tunnelPort");
56
+ return {
57
+ linkId: raw.linkId as string,
58
+ alias: raw.alias as string,
59
+ hubHostKeyFingerprint: raw.hubHostKeyFingerprint as string,
60
+ peerListenerPort: raw.peerListenerPort as number,
61
+ tunnelPort: raw.tunnelPort as number,
62
+ };
63
+ }
64
+
65
+ /** `null` when no sidecar exists. A present but unreadable or malformed sidecar throws. */
66
+ export function readClientLinkState(path: string = clientLinkStatePath()): ClientLinkState | null {
67
+ let text: string;
68
+ try {
69
+ text = readFileSync(path, "utf8");
70
+ } catch (error) {
71
+ if (isMissingPathError(error)) return null;
72
+ throw error;
73
+ }
74
+ let raw: unknown;
75
+ try {
76
+ raw = JSON.parse(text);
77
+ } catch (error) {
78
+ throw new ClientLinkStateError("client-link.json is not valid JSON", { cause: error });
79
+ }
80
+ return parseClientLinkState(raw);
81
+ }
82
+
83
+ export function writeClientLinkState(state: ClientLinkState, path: string = clientLinkStatePath()): void {
84
+ const normalized = parseClientLinkState(state);
85
+ const dir = dirname(path);
86
+ assertNotRealHomeUnderTest(dirname(dir));
87
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
88
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
89
+ else chmodSync(dir, 0o700);
90
+ // atomicWriteFile hardens its private temp on Windows before the rename, so only POSIX needs
91
+ // the explicit mode on the final path.
92
+ atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`);
93
+ if (process.platform !== "win32") chmodSync(path, 0o600);
94
+ }
95
+
96
+ /**
97
+ * Deletes the sidecar only while it still names `expectedLinkId`. Returns false when there was
98
+ * nothing to delete or it belongs to a different link, which callers treat as "not ours".
99
+ */
100
+ export function clearClientLinkState(expectedLinkId: string, path: string = clientLinkStatePath()): boolean {
101
+ const current = readClientLinkState(path);
102
+ if (!current || current.linkId !== expectedLinkId) return false;
103
+ try {
104
+ unlinkSync(path);
105
+ } catch (error) {
106
+ if (isMissingPathError(error)) return false;
107
+ throw error;
108
+ }
109
+ return true;
110
+ }