@bitkyc08/opencodex 2.65.0 → 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-D4bA1vff.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-BZXhB3qz.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-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -14,6 +14,8 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
14
14
 
15
15
  export type CliStdin = NodeJS.ReadableStream & { isTTY?: boolean; readableEnded?: boolean };
16
16
 
17
+ export const MAX_LINK_CREDENTIAL_BYTES = 4 * 1024;
18
+
17
19
  export interface RuntimeApiDeps {
18
20
  baseUrl?: string;
19
21
  fetchImpl?: typeof fetch;
@@ -389,6 +391,81 @@ export async function readSecretLine(deps: RuntimeApiDeps, label: string): Promi
389
391
  return line;
390
392
  }
391
393
 
394
+ /** Read a bounded secret payload while keeping the original bytes available for zeroing. */
395
+ export async function readSecretBytes(
396
+ deps: RuntimeApiDeps,
397
+ label: string,
398
+ maxBytes = MAX_LINK_CREDENTIAL_BYTES,
399
+ ): Promise<Uint8Array> {
400
+ const input: CliStdin = deps.stdinImpl ?? process.stdin;
401
+ const timeoutMs = deps.stdinTimeoutMs ?? 120_000;
402
+ if (input.readableEnded === true) throw new CliUsageError(`${label} input was empty`);
403
+ return await new Promise<Uint8Array>((resolve, reject) => {
404
+ const chunks: Uint8Array[] = [];
405
+ let total = 0;
406
+ let settled = false;
407
+ const cleanup = () => {
408
+ clearTimeout(timer);
409
+ input.removeListener("data", onData);
410
+ input.removeListener("end", onEnd);
411
+ input.removeListener("error", onError);
412
+ };
413
+ const wipeChunks = () => {
414
+ for (const chunk of chunks) chunk.fill(0);
415
+ };
416
+ const finish = (fn: () => void) => {
417
+ if (settled) return;
418
+ settled = true;
419
+ cleanup();
420
+ try { fn(); }
421
+ finally { wipeChunks(); }
422
+ };
423
+ const onData = (chunk: unknown) => {
424
+ const source = chunk instanceof Uint8Array ? chunk : undefined;
425
+ let bytes: Uint8Array | undefined;
426
+ let retained = false;
427
+ try {
428
+ bytes = typeof chunk === "string"
429
+ ? new TextEncoder().encode(chunk)
430
+ : source
431
+ ? new Uint8Array(source)
432
+ : new TextEncoder().encode(String(chunk));
433
+ total += bytes.byteLength;
434
+ if (total > maxBytes) {
435
+ finish(() => reject(new CliUsageError(`${label} exceeds ${maxBytes} bytes`)));
436
+ return;
437
+ }
438
+ chunks.push(bytes);
439
+ retained = true;
440
+ } finally {
441
+ source?.fill(0);
442
+ if (!retained) bytes?.fill(0);
443
+ }
444
+ };
445
+ const onEnd = () => finish(() => {
446
+ if (total === 0) {
447
+ reject(new CliUsageError(`${label} input was empty`));
448
+ return;
449
+ }
450
+ const result = new Uint8Array(total);
451
+ let offset = 0;
452
+ for (const chunk of chunks) {
453
+ result.set(chunk, offset);
454
+ offset += chunk.byteLength;
455
+ }
456
+ resolve(result);
457
+ });
458
+ const onError = (error: Error) => finish(() => reject(error));
459
+ const timer = setTimeout(
460
+ () => finish(() => reject(new CliUsageError(`timed out waiting for ${label} on stdin`))),
461
+ timeoutMs,
462
+ );
463
+ input.on("data", onData);
464
+ input.on("end", onEnd);
465
+ input.on("error", onError);
466
+ });
467
+ }
468
+
392
469
  export function printData(value: unknown, wantsJson: boolean, lines?: string[]): void {
393
470
  if (wantsJson || !lines) console.log(JSON.stringify(value, null, 2));
394
471
  else for (const line of lines) console.log(line);
@@ -406,6 +483,8 @@ export function desktopSwitchApplyReason(reason: unknown): string {
406
483
  if (reason === "not_requested") return "no desktop switch rewrite was requested";
407
484
  if (reason === "proxy_not_running") return "the proxy is not running";
408
485
  if (reason === "integration_disabled") return "Codex integration is disabled";
486
+ if (reason === "external_provider") return "an external model provider owns config.toml";
487
+ if (reason === "ownership_undetermined") return "config.toml ownership could not be determined";
409
488
  if (reason === "write_lock_busy") return "the Codex config write lock is busy";
410
489
  if (reason === "injection_refused") return "Codex config injection was refused";
411
490
  return "the rewrite could not be completed";
@@ -57,6 +57,7 @@ function desktopSwitchInertReason(reason: unknown): string {
57
57
  return "the stored setting is not effective in the current runtime configuration";
58
58
  }
59
59
 
60
+
60
61
  function settingsUpdateLines(
61
62
  result: unknown,
62
63
  changed: { desktopAuthless: boolean; clientCompaction: boolean },
@@ -68,8 +69,19 @@ function settingsUpdateLines(
68
69
  const lines: string[] = [];
69
70
  const appendSwitch = (key: string, label: string): boolean => {
70
71
  const state = recordValue(switches[key]);
71
- if (!state || typeof state.stored !== "boolean" || typeof state.effective !== "boolean") return false;
72
+ if (!state || typeof state.stored !== "boolean"
73
+ || (typeof state.effective !== "boolean" && state.effective !== null)) return false;
72
74
  lines.push(`${label}: stored ${state.stored ? "on" : "off"}.`);
75
+ if (state.effective === null) {
76
+ // `null` is reported for both withheld cases; the apply reason is the only place
77
+ // that still distinguishes them, so the line has to read it rather than claim
78
+ // external control over an ownership the server could not determine.
79
+ const withheld = recordValue(switches.apply)?.reason === "ownership_undetermined"
80
+ ? "effective state could not be determined"
81
+ : "effective state is controlled by the external model provider";
82
+ lines.push(`${label}: ${withheld}.`);
83
+ return true;
84
+ }
73
85
  // The effective value is always stated, even when it matches. Printing it only on a
74
86
  // mismatch would make silence ambiguous — the reader could not tell "the stored value is
75
87
  // in force" from "this build does not report effective state", and that ambiguity is a
@@ -96,7 +108,14 @@ function settingsUpdateLines(
96
108
  lines.push("Codex config: ~/.codex/config.toml was rewritten.");
97
109
  } else {
98
110
  const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : "";
99
- lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail} Run 'ocx sync' to apply the stored settings.`);
111
+ const retry = apply.reason === "external_provider"
112
+ ? ""
113
+ : apply.reason === "ownership_undetermined"
114
+ ? " Resolve the reported config.toml read error, then inspect 'ocx system settings --json'."
115
+ : apply.reason === "integration_disabled"
116
+ ? " Enable Codex integration before applying the stored settings."
117
+ : " Run 'ocx sync' to apply the stored settings.";
118
+ lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail}${retry}`);
100
119
  }
101
120
  lines.push(`Auth source: ${authSource.summary}`);
102
121
  return lines;
@@ -66,11 +66,14 @@ import {
66
66
  clearClientConnection,
67
67
  commitClientConnection,
68
68
  readClientConnectionState,
69
+ isLinkConnection,
69
70
  markClientConnectPending, clearClientConnectPending, pendingClientConnectMayOwnToken,
70
71
  assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner,
71
72
  } from "./state";
72
73
  import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility";
73
74
  import { hubStateCachePath } from "./hub-state";
75
+ import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state";
76
+ import { isLinkPort } from "../link/ports";
74
77
 
75
78
  class RotationRecoveryRequiredError extends Error {
76
79
  constructor(message: string, options?: ErrorOptions) {
@@ -82,13 +85,21 @@ class RotationRecoveryRequiredError extends Error {
82
85
  export interface ConnectOptions {
83
86
  serverUrl: string;
84
87
  managementUrl?: string;
85
- credential: OneTimeConnectCredential;
88
+ credential: OneTimeConnectCredential | LinkClientCredential;
86
89
  selectedClients: OcxConnectedClientId[];
87
90
  managementTransport: "direct" | "relay";
91
+ transport?: "hub" | "link";
92
+ link?: { tunnelPort: number; linkId: string };
88
93
  noSync?: boolean;
89
94
  catalogTimeoutMs?: number;
90
95
  }
91
96
 
97
+ export interface LinkClientCredential {
98
+ kind: "link";
99
+ apiKeyId: string;
100
+ key: string;
101
+ }
102
+
92
103
  export interface ClientConnectDeps {
93
104
  fetchImpl?: typeof fetch;
94
105
  now?: () => Date;
@@ -158,11 +169,20 @@ function catalogMatchesFingerprint(body: string, fingerprint: string | undefined
158
169
  return createHash("sha256").update(body).digest("base64url") === fingerprint;
159
170
  }
160
171
 
161
- function routingTarget(serverUrl: string): CodexRoutingTarget {
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
+ })();
162
181
  return {
163
- baseUrl: `${serverUrl}/v1`,
182
+ baseUrl,
164
183
  requiresAdmissionToken: true,
165
184
  tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
185
+ ...(localPort === undefined ? {} : { link: true as const }),
166
186
  };
167
187
  }
168
188
 
@@ -180,6 +200,10 @@ function releaseCredential(credential: OneTimeConnectCredential): void {
180
200
  credential.value.fill(0);
181
201
  }
182
202
 
203
+ function releaseConnectCredential(credential: ConnectOptions["credential"]): void {
204
+ if (credential.kind !== "link") releaseCredential(credential);
205
+ }
206
+
183
207
  async function rotationAuthority(
184
208
  connection: OcxClientConnectionConfig,
185
209
  options: RotateClientOptions,
@@ -349,6 +373,7 @@ export async function recoverPendingClientRotation(
349
373
  assertNoClientDisconnectPending();
350
374
  const state = readClientConnectionState();
351
375
  if (state.kind !== "connected" || !state.value.pendingOperation) throw new Error("no pending client key rotation to recover");
376
+ if (isLinkConnection(state.value)) throw new Error("connect rotate is unavailable in link mode");
352
377
  const authority = await rotationAuthority(state.value, options, deps);
353
378
  assertClientConnectionUnchanged(state.value);
354
379
  return recoverRotationWithAuthority(held, state.value, authority, deps);
@@ -381,6 +406,7 @@ async function rotateConnectedClientKeyHeld(
381
406
  const state = readClientConnectionState();
382
407
  if (state.kind !== "connected") throw new Error("connect rotate is available only while connected");
383
408
  connection = state.value;
409
+ if (isLinkConnection(connection)) throw new Error("connect rotate is unavailable in link mode");
384
410
  const current = readServiceApiTokenState();
385
411
  if (current.kind !== "present") throw new Error("connected service token unavailable");
386
412
  if (!connection.pendingOperation) {
@@ -501,6 +527,9 @@ export async function connectClient(
501
527
  ): Promise<OcxClientConnectionConfig> {
502
528
  let serverUrl = "";
503
529
  let managementUrl = "";
530
+ let linkAdmissionToken: string | null = null;
531
+ let linkMode = false;
532
+ let linkMetadata: { tunnelPort: number; linkId: string } | undefined;
504
533
  let issued: IssuedClientKey | null = null;
505
534
  let cleanupCredential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null;
506
535
  let tokenFingerprint: string | null = null;
@@ -510,22 +539,68 @@ export async function connectClient(
510
539
  let injectionCommitted = false;
511
540
  let committed = false;
512
541
  try {
513
- serverUrl = normalizeHubOrigin(options.serverUrl);
514
- if (options.managementUrl) managementUrl = normalizeHubOrigin(options.managementUrl);
542
+ linkMode = (options.transport ?? "hub") === "link";
543
+ if (linkMode) {
544
+ if (options.credential.kind !== "link" || !options.link
545
+ || !isLinkPort(options.link.tunnelPort)
546
+ || !/^lnk_[0-9a-f]{16}$/.test(options.link.linkId)
547
+ || !/^ocx_data_[0-9a-f]{40}$/.test(options.credential.key)
548
+ || !options.credential.apiKeyId.trim() || options.credential.apiKeyId.length > 256) {
549
+ throw new Error("invalid link credential");
550
+ }
551
+ linkMetadata = { ...options.link };
552
+ serverUrl = `http://127.0.0.1:${linkMetadata.tunnelPort}`;
553
+ if ((options.managementUrl && options.managementUrl !== serverUrl) || options.managementTransport !== "direct") {
554
+ throw new Error("link mode requires direct management on the tunnel origin");
555
+ }
556
+ managementUrl = serverUrl;
557
+ } else {
558
+ if (options.credential.kind === "link" || options.link) throw new Error("link credential requires link transport");
559
+ serverUrl = normalizeHubOrigin(options.serverUrl);
560
+ if (options.managementUrl) managementUrl = normalizeHubOrigin(options.managementUrl);
561
+ }
515
562
  if (options.selectedClients.length < 1 || new Set(options.selectedClients).size !== options.selectedClients.length) {
516
563
  throw new Error("at least one unique connected client is required");
517
564
  }
565
+ // Only link mode needs the local port before the hub is contacted; hub mode keeps reading config
566
+ // after the catalog download, so its stderr order (catalog refusal first) is unchanged.
567
+ const earlyConfig = linkMode ? loadConfig() : undefined;
568
+ if (earlyConfig && (!Number.isInteger(earlyConfig.port) || earlyConfig.port < 1 || earlyConfig.port > 65535)) {
569
+ throw new Error("link mode requires a valid local config port");
570
+ }
518
571
  withClientLifecycleSync(() => withConfigMutationLockSync(() => {
519
572
  assertConnectingState();
520
573
  const externalProvider = currentExternalCodexModelProvider();
521
574
  if (externalProvider) throw new Error("connect refused: an external Codex provider owns config.toml");
575
+ if (linkMode) {
576
+ const credential = options.credential;
577
+ if (credential.kind !== "link") throw new Error("invalid link credential");
578
+ const persisted = writeServiceApiTokenFile(credential.key);
579
+ pendingConnectFingerprint = persisted.fingerprint;
580
+ const reread = readServiceApiTokenState();
581
+ if (reread.kind !== "present" || reread.fingerprint !== persisted.fingerprint) {
582
+ throw new Error("invalid link credential");
583
+ }
584
+ markClientConnectPending(persisted.fingerprint);
585
+ linkAdmissionToken = reread.token;
586
+ }
522
587
  }), deps.lifecycleLockDeps);
523
588
 
524
- const ready = await fetchHubReady(serverUrl, { fetchImpl: deps.fetchImpl });
589
+ const upstreamFetch = deps.fetchImpl ?? fetch;
590
+ const readinessFetch = linkMode
591
+ ? (async (input, init = {}) => {
592
+ const url = input instanceof Request ? input.url : String(input);
593
+ if (!url.endsWith("/readyz")) return upstreamFetch(input, init);
594
+ const headers = new Headers(init.headers);
595
+ headers.set("x-opencodex-api-key", linkAdmissionToken!);
596
+ return upstreamFetch(input, { ...init, headers });
597
+ }) as typeof fetch
598
+ : upstreamFetch;
599
+ const ready = await fetchHubReady(serverUrl, { fetchImpl: readinessFetch });
525
600
  if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`);
526
- managementUrl = managementUrl || ready.metadata.managementUrl;
601
+ if (!linkMode) managementUrl = managementUrl || ready.metadata.managementUrl;
527
602
 
528
- if (options.credential.kind === "pairing-grant") {
603
+ if (!linkMode && options.credential.kind === "pairing-grant") {
529
604
  const session = await exchangeConnectPairingGrant(
530
605
  managementUrl,
531
606
  localGuiOrigin(),
@@ -533,23 +608,37 @@ export async function connectClient(
533
608
  { fetchImpl: deps.fetchImpl },
534
609
  );
535
610
  cleanupCredential = { kind: "gui-session", value: session };
536
- } else {
611
+ } else if (!linkMode && options.credential.kind === "admin") {
537
612
  cleanupCredential = { kind: "admin", value: options.credential.value };
538
613
  }
539
- issued = await issueClientKey(managementUrl, cleanupCredential, clientKeyName(), { fetchImpl: deps.fetchImpl });
614
+ if (!linkMode) {
615
+ issued = await issueClientKey(managementUrl, cleanupCredential!, clientKeyName(), { fetchImpl: deps.fetchImpl });
616
+ }
540
617
 
541
618
  const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
542
- assertConnectingState();
543
- const fingerprint = serviceApiTokenFingerprint(issued!.key);
544
- markClientConnectPending(fingerprint);
545
- pendingConnectFingerprint = fingerprint;
546
- return { prior: catalogSnapshot(), persisted: writeServiceApiTokenFile(issued!.key) };
619
+ assertConnectingState(linkMode ? pendingConnectFingerprint! : undefined);
620
+ const persisted = linkMode
621
+ ? (() => {
622
+ const current = readServiceApiTokenState();
623
+ if (current.kind !== "present") throw new Error("invalid link credential");
624
+ return { fingerprint: current.fingerprint };
625
+ })()
626
+ : writeServiceApiTokenFile(issued!.key);
627
+ const fingerprint = persisted.fingerprint;
628
+ if (!pendingConnectFingerprint) {
629
+ markClientConnectPending(fingerprint);
630
+ pendingConnectFingerprint = fingerprint;
631
+ }
632
+ return { prior: catalogSnapshot(), persisted };
547
633
  }), deps.lifecycleLockDeps);
548
634
  priorCatalog = initialFiles.prior;
549
635
  const persisted = initialFiles.persisted;
550
636
  tokenFingerprint = persisted.fingerprint;
551
637
 
552
- const catalog = await downloadClientCatalog(serverUrl, issued.key, {
638
+ const admissionToken = linkMode ? linkAdmissionToken : issued?.key;
639
+ if (!admissionToken) throw new Error("client admission credential unavailable");
640
+ const apiKeyId = linkMode ? (options.credential as LinkClientCredential).apiKeyId : issued!.id;
641
+ const catalog = await downloadClientCatalog(serverUrl, admissionToken, {
553
642
  fetchImpl: deps.fetchImpl,
554
643
  timeoutMs: options.catalogTimeoutMs,
555
644
  });
@@ -565,14 +654,14 @@ export async function connectClient(
565
654
  return sha256(catalog.body);
566
655
  }), deps.lifecycleLockDeps);
567
656
 
568
- const config = loadConfig();
569
- const target = routingTarget(serverUrl);
657
+ const config = earlyConfig ?? loadConfig();
658
+ const target = routingTarget(serverUrl, linkMode ? config.port : undefined);
570
659
  const injectConfig = { ...config, syncResumeHistory: false };
571
660
  const preflight = await injectCodexConfig(config.port, injectConfig, {
572
661
  validateOnly: true,
573
662
  routingTarget: target,
574
663
  catalogPath: DEFAULT_CATALOG_PATH,
575
- journalOwner: { kind: "client", apiKeyId: issued.id },
664
+ journalOwner: { kind: "client", apiKeyId },
576
665
  beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
577
666
  });
578
667
  if (!preflight.success) throw new Error(preflight.message);
@@ -581,7 +670,7 @@ export async function connectClient(
581
670
  const injected = await injectCodexConfig(config.port, injectConfig, {
582
671
  routingTarget: target,
583
672
  catalogPath: DEFAULT_CATALOG_PATH,
584
- journalOwner: { kind: "client", apiKeyId: issued.id },
673
+ journalOwner: { kind: "client", apiKeyId },
585
674
  beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
586
675
  });
587
676
  if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
@@ -596,7 +685,7 @@ export async function connectClient(
596
685
  managementTransport: options.managementTransport,
597
686
  selectedClients: [...options.selectedClients],
598
687
  tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
599
- apiKeyId: issued.id,
688
+ apiKeyId,
600
689
  tokenFingerprint: persisted.fingerprint,
601
690
  protocolVersion: 1,
602
691
  connectedAt: now,
@@ -606,6 +695,7 @@ export async function connectClient(
606
695
  // back in the same run.
607
696
  priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "",
608
697
  catalogSyncedAt: now,
698
+ ...(linkMode ? { transport: "link" as const, link: linkMetadata } : {}),
609
699
  };
610
700
  withClientLifecycleSync(() => withConfigMutationLockSync(() => {
611
701
  assertConnectingState(persisted.fingerprint);
@@ -645,7 +735,7 @@ export async function connectClient(
645
735
  ];
646
736
  throw new Error(details.length > 0 ? `${base}. ${details.join(" ")}` : base, { cause: error });
647
737
  } finally {
648
- releaseCredential(options.credential);
738
+ releaseConnectCredential(options.credential);
649
739
  cleanupCredential = null;
650
740
  issued = null;
651
741
  if (!committed) {
@@ -706,7 +796,7 @@ export async function syncConnectedClient(
706
796
  if (next.selectedClients.includes("codex")) {
707
797
  const config = loadConfig();
708
798
  const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
709
- routingTarget: routingTarget(next.serverUrl), catalogPath: DEFAULT_CATALOG_PATH,
799
+ routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined), catalogPath: DEFAULT_CATALOG_PATH,
710
800
  journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
711
801
  });
712
802
  if (!result.success || result.status === "skipped") throw new Error(result.message);
@@ -902,6 +992,17 @@ export async function disconnectClient(
902
992
  if (!disconnectAtLeast(receipt, "clearing_connection")) advance("clearing_connection");
903
993
  if (clearClientConnection(receipt.owner) === "conflict") throw new Error("client_disconnect_owner_changed");
904
994
  if (!disconnectAtLeast(receipt, "connection_cleared")) advance("connection_cleared");
995
+ if (connection?.transport === "link" && connection.link) {
996
+ // A corrupt sidecar is left in place: the connection is already cleared, the client runtime
997
+ // will not start a tunnel for a disconnected client, and the next join overwrites the file.
998
+ let sidecarLinkId: string | null = null;
999
+ try {
1000
+ sidecarLinkId = readClientLinkState()?.linkId ?? null;
1001
+ } catch (error) {
1002
+ if (!(error instanceof ClientLinkStateError)) throw error;
1003
+ }
1004
+ if (sidecarLinkId === connection.link.linkId) clearClientLinkState(connection.link.linkId);
1005
+ }
905
1006
  removeHubStateCache();
906
1007
  requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner));
907
1008
  if (receipt.phase !== "complete") advance("complete");
@@ -940,6 +1041,7 @@ export async function revokeConnectedClientKey(
940
1041
  assertNoClientDisconnectPending();
941
1042
  const state = readClientConnectionState();
942
1043
  if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("connect revoke requires a settled connection");
1044
+ if (isLinkConnection(state.value)) throw new Error("connect revoke is unavailable in link mode");
943
1045
  await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl });
944
1046
  assertClientConnectionUnchanged(state.value);
945
1047
  return { apiKeyId: state.value.apiKeyId };
@@ -108,7 +108,7 @@ function relayDestination(suffix: string, target: HubRelayTarget, method: string
108
108
  return destination;
109
109
  }
110
110
 
111
- async function boundedBody(
111
+ export async function readBoundedRelayRequestBody(
112
112
  stream: ReadableStream<Uint8Array> | null,
113
113
  declared: string | null,
114
114
  limit: number,
@@ -142,16 +142,20 @@ async function boundedBody(
142
142
  return body;
143
143
  }
144
144
 
145
- function filteredHeaders(source: Headers, allowlist: Set<string>, omitted: ReadonlySet<string> = new Set()): Headers {
145
+ export function filterRelayHeaders(
146
+ source: Headers,
147
+ allowlist?: ReadonlySet<string>,
148
+ omitted: ReadonlySet<string> = new Set(),
149
+ ): Headers {
146
150
  const headers = new Headers();
147
151
  for (const [name, value] of source) {
148
152
  const normalized = name.toLowerCase();
149
- if (allowlist.has(normalized) && !HOP_BY_HOP_HEADERS.has(normalized) && !omitted.has(normalized)) headers.append(name, value);
153
+ if ((!allowlist || allowlist.has(normalized)) && !HOP_BY_HOP_HEADERS.has(normalized) && !omitted.has(normalized)) headers.append(name, value);
150
154
  }
151
155
  return headers;
152
156
  }
153
157
 
154
- function headersWithinLimit(headers: Headers): boolean {
158
+ export function headersWithinLimit(headers: Headers): boolean {
155
159
  let bytes = 0;
156
160
  for (const [name, value] of headers) {
157
161
  bytes += name.length + value.length + 4;
@@ -160,7 +164,7 @@ function headersWithinLimit(headers: Headers): boolean {
160
164
  return true;
161
165
  }
162
166
 
163
- function boundedRelayResponseStream(
167
+ export function boundedRelayResponseStream(
164
168
  body: ReadableStream<Uint8Array>,
165
169
  limit: number,
166
170
  signal: AbortSignal,
@@ -228,13 +232,13 @@ export async function relayHubManagementRequest(
228
232
  try {
229
233
  body = method === "GET" || method === "HEAD"
230
234
  ? null
231
- : await boundedBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES);
235
+ : await readBoundedRelayRequestBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES);
232
236
  } catch {
233
237
  return relayError(413, "hub relay request body too large");
234
238
  }
235
239
 
236
240
  const stripped = stripMachineAuthHeaders(req.headers);
237
- const headers = filteredHeaders(stripped, REQUEST_HEADERS, requestHeaderValidation.connectionNamed);
241
+ const headers = filterRelayHeaders(stripped, REQUEST_HEADERS, requestHeaderValidation.connectionNamed);
238
242
  if (!headersWithinLimit(headers)) return relayError(431, "hub relay request headers too large");
239
243
  const browserOrigin = canonicalOrigin(target.browserOrigin);
240
244
  const mutation = method !== "GET" && method !== "HEAD";
@@ -285,7 +289,7 @@ export async function relayHubManagementRequest(
285
289
  }
286
290
 
287
291
  const responseConnectionNamed = new Set((upstream.headers.get("connection") ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
288
- const responseHeaders = filteredHeaders(upstream.headers, RESPONSE_HEADERS, responseConnectionNamed);
292
+ const responseHeaders = filterRelayHeaders(upstream.headers, RESPONSE_HEADERS, responseConnectionNamed);
289
293
  if (!headersWithinLimit(responseHeaders)) {
290
294
  cleanup();
291
295
  try { await upstream.body?.cancel(); } catch { /* best effort */ }