@bitkyc08/opencodex 2.68.0 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-DDbgOTh6.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CuaBJkG4.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-kUE8tuAK.js +0 -86
@@ -51,6 +51,8 @@ import type { OcxConfig } from "../types";
51
51
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
52
52
  import { selfLaunchArgv } from "../lib/self-launch-argv";
53
53
  import { withoutSiblingMarker } from "../codex/sibling-start";
54
+ import { parseJsonc } from "../lib/jsonc";
55
+ export { parseJsonc };
54
56
 
55
57
  /**
56
58
  * The provider-block serializer, its constants, and the config-path helpers now live in
@@ -124,89 +126,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
124
126
  return typeof value === "object" && value !== null && !Array.isArray(value);
125
127
  }
126
128
 
127
- /**
128
- * Strip `//` and block comments outside string literals. Escape-aware so a quote inside
129
- * an escaped sequence cannot flip string state and expose config text to the stripper.
130
- */
131
- function stripJsonComments(text: string): string {
132
- let out = "";
133
- let inString = false;
134
- let inLine = false;
135
- let inBlock = false;
136
- for (let i = 0; i < text.length; i++) {
137
- const ch = text[i]!;
138
- const next = text[i + 1];
139
- if (inLine) {
140
- if (ch === "\n") {
141
- inLine = false;
142
- out += ch;
143
- }
144
- continue;
145
- }
146
- if (inBlock) {
147
- // Newlines are preserved so JSON.parse error positions stay meaningful.
148
- if (ch === "\n") out += ch;
149
- else if (ch === "*" && next === "/") { inBlock = false; i++; }
150
- continue;
151
- }
152
- if (inString) {
153
- out += ch;
154
- if (ch === "\\") {
155
- const escaped = text[i + 1];
156
- if (escaped !== undefined) { out += escaped; i++; }
157
- continue;
158
- }
159
- if (ch === "\"") inString = false;
160
- continue;
161
- }
162
- if (ch === "\"") { inString = true; out += ch; continue; }
163
- if (ch === "/" && next === "/") { inLine = true; i++; continue; }
164
- if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
165
- out += ch;
166
- }
167
- return out;
168
- }
169
-
170
- /** Drop commas that sit directly before `}` or `]`, ignoring string contents. */
171
- function stripTrailingCommas(text: string): string {
172
- let out = "";
173
- let inString = false;
174
- for (let i = 0; i < text.length; i++) {
175
- const ch = text[i]!;
176
- if (inString) {
177
- out += ch;
178
- if (ch === "\\") {
179
- const escaped = text[i + 1];
180
- if (escaped !== undefined) { out += escaped; i++; }
181
- continue;
182
- }
183
- if (ch === "\"") inString = false;
184
- continue;
185
- }
186
- if (ch === "\"") { inString = true; out += ch; continue; }
187
- if (ch === ",") {
188
- let j = i + 1;
189
- while (j < text.length && /\s/.test(text[j]!)) j++;
190
- if (text[j] === "}" || text[j] === "]") continue;
191
- }
192
- out += ch;
193
- }
194
- return out;
195
- }
196
-
197
- /**
198
- * opencode documents opencode.json as JSONC, so a valid user config may carry comments
199
- * or trailing commas. Strict JSON.parse runs first and untouched — the tolerant path is
200
- * only attempted when that throws, keeping well-formed configs away from the stripper.
201
- */
202
- export function parseJsonc(text: string): unknown {
203
- try {
204
- return JSON.parse(text);
205
- } catch {
206
- return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
207
- }
208
- }
209
-
210
129
  /** Model key as the proxy routes it: `provider/id` for routed models, bare slug for native OpenAI entries. */
211
130
  export function opencodeModelKey(provider: string, id: string): string {
212
131
  return provider === "native" ? id : `${provider}/${id}`;
@@ -416,12 +416,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
416
416
  },
417
417
  {
418
418
  name: "export",
419
- usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside|raycast|omo|cline> [--json] [--out <path>] [--force]",
420
- summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline) wired to the running proxy.",
419
+ usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside|raycast|omo|cline|kilo|droid> [--json] [--out <path>] [--force]",
420
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid) wired to the running proxy.",
421
421
  details: [
422
422
  "--json prints the generated document as JSON on stdout; use --out for the client's native format.",
423
423
  "--out <path> writes the native config there and refuses to replace an existing file without --force.",
424
424
  "Cline exports a two-document bundle: settings for providers.json and catalog for sibling models.json. Use integration client enable --client cline for a journaled write.",
425
+ "Droid exports documented customModels for settings.json; use integration client enable --client droid for a journaled write.",
425
426
  "The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.",
426
427
  "The destination path is printed for merging by hand — ocx never writes your real client config.",
427
428
  ],
package/src/cli/status.ts CHANGED
@@ -31,6 +31,8 @@ import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
31
31
  export { proxyHealthFailureReason, isConnectionRefused, isUncleanExitEvidence, probeUncleanExitState } from "./status-probes";
32
32
  export type { ListenTarget } from "./status-probes";
33
33
  import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes";
34
+ import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
35
+ import { fetchBoundLocalManagementRead } from "../server/local-management-read-client";
34
36
 
35
37
  /**
36
38
  * The state of the data-plane admission secret the SERVICE will use. State only -- never the value.
@@ -235,6 +237,84 @@ function statusDashboardUrl(config: StatusListenConfig, hostname: string | undef
235
237
  return `http://${dashboardHostname}:${port}/`;
236
238
  }
237
239
 
240
+ const STARTUP_HEALTH_BOOLEAN_FIELDS = [
241
+ "routingInjected", "localRoutingDependency", "autostartEnabled", "rebootSafe",
242
+ "serviceInstalled", "serviceViable", "serviceEnabled", "serviceRunning",
243
+ "serviceStale", "serviceConflict", "shimInstalled", "shimHealthy",
244
+ "serviceSupported", "diagnosticStale",
245
+ ] as const;
246
+
247
+ export async function fetchLiveStartupHealth(
248
+ live: NonNullable<Awaited<ReturnType<typeof findLiveProxy>>>,
249
+ deps: Parameters<typeof fetchBoundLocalManagementRead>[2] = {},
250
+ ): Promise<StartupHealth | null> {
251
+ const result = await fetchBoundLocalManagementRead(
252
+ live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: 1_500, ...deps, requireResponseProof: true },
253
+ );
254
+ if (result.kind !== "response" || !result.response.ok) return null;
255
+ let payload: unknown;
256
+ try { payload = await result.response.json(); } catch { return null; }
257
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
258
+ const row = payload as Record<string, unknown>;
259
+ if (row.status !== "native" && row.status !== "protected" && row.status !== "at-risk") return null;
260
+ if (row.protection !== "service" && row.protection !== "shim" && row.protection !== "none") return null;
261
+ if (row.routingKind !== "native" && row.routingKind !== "opencodex-local"
262
+ && row.routingKind !== "custom-local" && row.routingKind !== "custom-remote" && row.routingKind !== "unknown") return null;
263
+ if (row.shimCoverage !== "full" && row.shimCoverage !== "cli-only" && row.shimCoverage !== "none") return null;
264
+ if (typeof row.platform !== "string") return null;
265
+ if (row.recommendedCommand !== null && typeof row.recommendedCommand !== "string") return null;
266
+ if (!row.commands || typeof row.commands !== "object" || Array.isArray(row.commands)) return null;
267
+ for (const key of ["installService", "repairService", "installShim", "restoreNative"] as const) {
268
+ if (typeof (row.commands as Record<string, unknown>)[key] !== "string") return null;
269
+ }
270
+ if (row.routingAdoption !== undefined) {
271
+ if (!row.routingAdoption || typeof row.routingAdoption !== "object" || Array.isArray(row.routingAdoption)) return null;
272
+ const adoption = row.routingAdoption as Record<string, unknown>;
273
+ if (adoption.adoption !== "not-applicable" && adoption.adoption !== "adopted"
274
+ && adoption.adoption !== "pending-client-restart" && adoption.adoption !== "unknown") return null;
275
+ if (adoption.injectedAtMs !== null && typeof adoption.injectedAtMs !== "number") return null;
276
+ if (typeof adoption.observedClients !== "number" || !Array.isArray(adoption.staleClients)) return null;
277
+ for (const client of adoption.staleClients) {
278
+ if (!client || typeof client !== "object" || Array.isArray(client)) return null;
279
+ const row = client as Record<string, unknown>;
280
+ if (typeof row.pid !== "number" || typeof row.startedAtMs !== "number") return null;
281
+ }
282
+ }
283
+ for (const key of STARTUP_HEALTH_BOOLEAN_FIELDS) if (typeof row[key] !== "boolean") return null;
284
+ return payload as StartupHealth;
285
+ }
286
+
287
+ /** Prefer an attested live verdict and evaluate the local fallback only when live state is absent. */
288
+ export function selectStatusStartupHealth(
289
+ liveStartup: StartupHealth | null,
290
+ fallback: () => StartupHealth,
291
+ ): StartupHealth {
292
+ return liveStartup ?? fallback();
293
+ }
294
+
295
+ /** Build the service summary from the same startup source that `ocx status` selected. */
296
+ export function statusServiceSummary(
297
+ liveStartup: StartupHealth | null,
298
+ service: Pick<ReturnType<typeof diagnoseService>, "installed" | "summary">,
299
+ live: boolean,
300
+ ): string {
301
+ if (liveStartup) {
302
+ if (liveStartup.protection === "service" && liveStartup.serviceViable) {
303
+ return `running under the live managed service (logs: ${serviceLogPath()})`;
304
+ }
305
+ const state = [
306
+ liveStartup.serviceInstalled ? "installed" : "absent",
307
+ liveStartup.serviceRunning ? "running" : "not running",
308
+ liveStartup.serviceViable ? "viable" : "not viable",
309
+ ].join(", ");
310
+ const action = liveStartup.recommendedCommand ? `; run '${liveStartup.recommendedCommand}'` : "";
311
+ return `live startup reports service ${state}${action} (logs: ${serviceLogPath()})`;
312
+ }
313
+ return service.installed && !live
314
+ ? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service repair'`
315
+ : service.summary;
316
+ }
317
+
238
318
  /**
239
319
  * The hub block, or null when this machine is not a hub.
240
320
  *
@@ -666,20 +746,19 @@ export async function collectStatus(): Promise<CliStatusView> {
666
746
  hostname: config.hostname,
667
747
  });
668
748
  const bunRuntime = durableBunRuntime();
749
+ const liveStartup = live ? await fetchLiveStartupHealth(live) : null;
669
750
  const service = diagnoseService();
670
751
  // A service can be registered and still not serve: the manager reports the job
671
- // either way. `live` was already identity-probed a few lines above, so cross-check
672
- // rather than print registration as if it were service.
673
- const serviceSummary = service.installed && !live
674
- ? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service repair'`
675
- : service.summary;
752
+ // either way. When the identity-probed live proxy provides an attested startup verdict,
753
+ // prefer it over a shell-local service-manager probe that lacks the service environment.
754
+ const serviceSummary = statusServiceSummary(liveStartup, service, Boolean(live));
676
755
  const codexShim = diagnoseCodexShim();
677
756
  const codexShimSummary = codexShim.summary;
678
- const startup = collectStartupHealth(config, {
757
+ const startup = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(config, {
679
758
  service,
680
759
  shim: codexShim,
681
760
  routingKind: getCodexRoutingKind(),
682
- });
761
+ }));
683
762
  const codexPlugins = diagnoseCodexBundledPlugins();
684
763
  const lastClamp = loadLastEffortClamp();
685
764
  const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
@@ -104,6 +104,8 @@ export interface LinkClientCredential {
104
104
  }
105
105
 
106
106
  export interface ClientConnectDeps {
107
+ /** Abort enrollment network work and refuse subsequent writes; rollback still drains. */
108
+ signal?: AbortSignal;
107
109
  fetchImpl?: typeof fetch;
108
110
  now?: () => Date;
109
111
  lifecycleLockDeps?: ClientLifecycleLockDeps;
@@ -539,6 +541,18 @@ export async function connectClient(
539
541
  options: ConnectOptions,
540
542
  deps: ClientConnectDeps = {},
541
543
  ): Promise<OcxClientConnectionConfig> {
544
+ deps.signal?.throwIfAborted();
545
+ const rawFetch = deps.fetchImpl ?? fetch;
546
+ const fetchImpl: typeof fetch = deps.signal ? Object.assign(async (...[input, init = {}]: Parameters<typeof fetch>) => {
547
+ deps.signal!.throwIfAborted();
548
+ const signals = [deps.signal, init.signal, input instanceof Request ? input.signal : undefined]
549
+ .filter((signal): signal is AbortSignal => signal != null);
550
+ return rawFetch(input, { ...init, signal: AbortSignal.any(signals), redirect: "manual" });
551
+ }, { preconnect: rawFetch.preconnect }) : rawFetch;
552
+ const assertActiveConnectingState = (fingerprint?: string) => {
553
+ deps.signal?.throwIfAborted();
554
+ assertConnectingState(fingerprint);
555
+ };
542
556
  let serverUrl = "";
543
557
  let managementUrl = "";
544
558
  let linkAdmissionToken: string | null = null;
@@ -586,7 +600,7 @@ export async function connectClient(
586
600
  throw new Error("link mode requires a valid local config port");
587
601
  }
588
602
  withClientLifecycleSync(() => withConfigMutationLockSync(() => {
589
- assertConnectingState();
603
+ assertActiveConnectingState();
590
604
  const externalProvider = currentExternalCodexModelProvider();
591
605
  if (externalProvider) throw new Error("connect refused: an external Codex provider owns config.toml");
592
606
  if (linkMode) {
@@ -603,7 +617,7 @@ export async function connectClient(
603
617
  }
604
618
  }), deps.lifecycleLockDeps);
605
619
 
606
- const upstreamFetch = deps.fetchImpl ?? fetch;
620
+ const upstreamFetch = fetchImpl;
607
621
  const readinessFetch = linkMode
608
622
  ? (async (input, init = {}) => {
609
623
  const url = input instanceof Request ? input.url : String(input);
@@ -622,18 +636,18 @@ export async function connectClient(
622
636
  managementUrl,
623
637
  localGuiOrigin(),
624
638
  options.credential.value,
625
- { fetchImpl: deps.fetchImpl },
639
+ { fetchImpl },
626
640
  );
627
641
  cleanupCredential = { kind: "gui-session", value: session };
628
642
  } else if (!linkMode && options.credential.kind === "admin") {
629
643
  cleanupCredential = { kind: "admin", value: options.credential.value };
630
644
  }
631
645
  if (!linkMode) {
632
- issued = await issueClientKey(managementUrl, cleanupCredential!, clientKeyName(), { fetchImpl: deps.fetchImpl });
646
+ issued = await issueClientKey(managementUrl, cleanupCredential!, clientKeyName(), { fetchImpl });
633
647
  }
634
648
 
635
649
  const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
636
- assertConnectingState(linkMode ? pendingConnectFingerprint! : undefined);
650
+ assertActiveConnectingState(linkMode ? pendingConnectFingerprint! : undefined);
637
651
  const persisted = linkMode
638
652
  ? (() => {
639
653
  const current = readServiceApiTokenState();
@@ -656,7 +670,7 @@ export async function connectClient(
656
670
  if (!admissionToken) throw new Error("client admission credential unavailable");
657
671
  const apiKeyId = linkMode ? (options.credential as LinkClientCredential).apiKeyId : issued!.id;
658
672
  const catalog = await downloadClientCatalog(serverUrl, admissionToken, {
659
- fetchImpl: deps.fetchImpl,
673
+ fetchImpl,
660
674
  timeoutMs: options.catalogTimeoutMs,
661
675
  });
662
676
  // Fail closed BEFORE the write (#4207). The hub being reachable and the credential working
@@ -666,7 +680,7 @@ export async function connectClient(
666
680
  // than writing one and restoring it afterwards.
667
681
  assertClientCatalogCompatible(catalog.body, deps.catalogCompatibility);
668
682
  writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
669
- assertConnectingState(persisted.fingerprint);
683
+ assertActiveConnectingState(persisted.fingerprint);
670
684
  atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
671
685
  return sha256(catalog.body);
672
686
  }), deps.lifecycleLockDeps);
@@ -679,7 +693,7 @@ export async function connectClient(
679
693
  routingTarget: target,
680
694
  catalogPath: DEFAULT_CATALOG_PATH,
681
695
  journalOwner: { kind: "client", apiKeyId },
682
- beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
696
+ beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint),
683
697
  });
684
698
  if (!preflight.success) throw new Error(preflight.message);
685
699
 
@@ -688,7 +702,7 @@ export async function connectClient(
688
702
  routingTarget: target,
689
703
  catalogPath: DEFAULT_CATALOG_PATH,
690
704
  journalOwner: { kind: "client", apiKeyId },
691
- beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
705
+ beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint),
692
706
  });
693
707
  if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
694
708
  injectionCommitted = true;
@@ -715,7 +729,7 @@ export async function connectClient(
715
729
  ...(linkMode ? { transport: "link" as const, link: linkMetadata } : {}),
716
730
  };
717
731
  withClientLifecycleSync(() => withConfigMutationLockSync(() => {
718
- assertConnectingState(persisted.fingerprint);
732
+ assertActiveConnectingState(persisted.fingerprint);
719
733
  clearClientConnectPending(persisted.fingerprint);
720
734
  commitClientConnection(connection);
721
735
  committed = true;
@@ -43,6 +43,8 @@ export function createLinkKeySource(expectedFingerprint: string, deps: LinkKeySo
43
43
 
44
44
  export interface LinkIngress {
45
45
  tunnelPort: number;
46
+ linkId?: string;
47
+ apiKeyId?: string;
46
48
  /** The listener's loopback policy: the standalone Host and Origin anti-rebinding gate. */
47
49
  policy: RequestPolicyView;
48
50
  linkKey: () => string | null;
@@ -98,5 +100,5 @@ export function handleLinkIngress(
98
100
  const admissionKey = ingress.linkKey();
99
101
  if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 });
100
102
  liftRequestIdleTimer(req, server);
101
- return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey }, ingress.relay);
103
+ return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey, linkId: ingress.linkId, apiKeyId: ingress.apiKeyId }, ingress.relay);
102
104
  }
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { isPortAvailable } from "../server/ports";
4
+ import { scanListenPidsForAddress, type ListenPidScan } from "../server/port-reclaim";
4
5
  import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../link/ports";
5
6
  import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv";
6
7
  import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner";
@@ -22,6 +23,7 @@ import type { OcxConnectedClientId } from "../types";
22
23
 
23
24
  const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000;
24
25
  const JOIN_TUNNEL_POLL_MS = 100;
26
+ const JOIN_TUNNEL_SPAWN_GRACE_MS = 100;
25
27
  const JOIN_REVOKE_TIMEOUT_MS = 30_000;
26
28
  const JOIN_CONFIRM_TTL_MS = 5 * 60_000;
27
29
  const JOIN_PORT_ATTEMPTS = 32;
@@ -74,6 +76,12 @@ export interface ClientLinkJoinDeps {
74
76
  hostname?: () => string;
75
77
  randomBytes?: (size: number) => Uint8Array;
76
78
  fetchImpl?: typeof fetch;
79
+ /**
80
+ * LISTEN-owner probe for the tunnel port; defaults to the netstat/lsof/ss scan.
81
+ * Receives the loopback address the tunnel binds so listeners on unrelated
82
+ * addresses do not confuse the readiness check.
83
+ */
84
+ scanListenPids?: (port: number, address?: string) => ListenPidScan;
77
85
  spawnTunnel?: (spec: {
78
86
  linkId: string;
79
87
  alias: string;
@@ -222,8 +230,10 @@ async function compensateStaleSidecar(deps: ClientLinkJoinDeps): Promise<void> {
222
230
  }
223
231
  }
224
232
 
233
+ /** Wait for the live tunnel's authenticated readiness, retaining the deadline after failed ownership rechecks. */
225
234
  async function waitForReady(
226
235
  deps: ClientLinkJoinDeps,
236
+ tunnel: ClientLinkTunnelHandle,
227
237
  port: number,
228
238
  key: string,
229
239
  ): Promise<void> {
@@ -231,19 +241,53 @@ async function waitForReady(
231
241
  const now = deps.now ?? Date.now;
232
242
  const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
233
243
  const deadline = now() + JOIN_TUNNEL_READY_TIMEOUT_MS;
244
+ const tunnelExited = tunnel.exited.then(() => { throw new ClientLinkJoinError("join_tunnel_failed"); });
245
+ await Promise.race([
246
+ tunnelExited,
247
+ new Promise<void>(resolve => setTimeout(resolve, JOIN_TUNNEL_SPAWN_GRACE_MS)),
248
+ ]);
249
+ const listenPids = deps.scanListenPids ?? scanListenPidsForAddress;
250
+ // The tunnel binds 127.0.0.1; a listener on a different loopback or interface address
251
+ // never receives our requests, so ownership is only judged among sockets that serve it.
252
+ const tunnelAddress = "127.0.0.1";
234
253
  for (;;) {
235
254
  try {
236
- const response = await fetchImpl(`http://127.0.0.1:${port}/readyz`, {
237
- headers: { "x-opencodex-api-key": key },
238
- });
239
- if (response.status === 200) return;
240
- if (response.status === 401) throw new ClientLinkJoinError("admission_failed");
255
+ // A squatter answering the 401 challenge would otherwise collect the issued key:
256
+ // the only listener allowed a keyed request is the ssh process we spawned — it owns
257
+ // the port only after a successful bind, and ExitOnForwardFailure makes it exit when
258
+ // it cannot take the port. An unverifiable scan stays "not ready", never a pass.
259
+ const ownership = listenPids(port, tunnelAddress);
260
+ if (ownership.ok && ownership.pids.length === 1 && ownership.pids[0] === tunnel.pid) {
261
+ // Never follow redirects: a port occupant must not reroute the challenge, and a
262
+ // redirected keyed request would carry the issued key to an unrelated listener.
263
+ const probe = await Promise.race([
264
+ tunnelExited,
265
+ fetchImpl(`http://127.0.0.1:${port}/readyz`, { redirect: "manual" }),
266
+ ]);
267
+ if (probe.status === 401) {
268
+ // Ownership can flip between the probe and the keyed request (a squatter
269
+ // takes the port after the tunnel dies). Re-scan in the same iteration and
270
+ // skip only the keyed request on failure, not the deadline check and sleep.
271
+ const recheck = listenPids(port, tunnelAddress);
272
+ if (recheck.ok && recheck.pids.length === 1 && recheck.pids[0] === tunnel.pid) {
273
+ const response = await Promise.race([
274
+ tunnelExited,
275
+ fetchImpl(`http://127.0.0.1:${port}/readyz`, {
276
+ headers: { "x-opencodex-api-key": key },
277
+ redirect: "manual",
278
+ }),
279
+ ]);
280
+ if (response.status === 200) return;
281
+ if (response.status === 401) throw new ClientLinkJoinError("admission_failed");
282
+ }
283
+ }
284
+ }
241
285
  } catch (error) {
242
286
  if (error instanceof ClientLinkJoinError) throw error;
243
287
  }
244
288
  const remaining = deadline - now();
245
289
  if (remaining <= 0) throw new ClientLinkJoinError("join_tunnel_failed");
246
- await sleep(Math.min(JOIN_TUNNEL_POLL_MS, remaining));
290
+ await Promise.race([tunnelExited, sleep(Math.min(JOIN_TUNNEL_POLL_MS, remaining))]);
247
291
  }
248
292
  }
249
293
 
@@ -256,6 +300,7 @@ function requireConfirmedHost(deps: ClientLinkJoinDeps, alias: string): JoinConf
256
300
  return confirmed;
257
301
  }
258
302
 
303
+ /** Issue and enroll a confirmed Home link, compensating failures before committing the connection. */
259
304
  export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string }): Promise<{ linkId: string; apiKeyId: string }> {
260
305
  const confirmed = requireConfirmedHost(deps, input.alias);
261
306
  await compensateStaleSidecar(deps);
@@ -308,15 +353,29 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
308
353
  configDir: deps.configDir,
309
354
  knownHostsFile: deps.knownHostsFile,
310
355
  });
311
- await waitForReady(deps, tunnelPort, issued.key);
356
+ await waitForReady(deps, tunnel, tunnelPort, issued.key);
312
357
  } catch (error) {
313
358
  const code = error instanceof ClientLinkJoinError ? error.code : "join_tunnel_failed";
314
359
  await rollback(deps, issued.linkId, tunnel);
315
360
  throw new ClientLinkJoinError(code);
316
361
  }
317
362
 
363
+ const enrollmentAbort = new AbortController();
364
+ let enrollmentFinished = false;
318
365
  try {
366
+ if (!tunnel) throw new ClientLinkJoinError("join_tunnel_failed");
319
367
  const connect = deps.connect ?? connectClient;
368
+ // A tunnel exit cancels work; it is NOT a competing terminal result. The connect
369
+ // transaction alone decides commit versus rollback, so an exit queued immediately
370
+ // after commit cannot revoke a key that a connected client has already retained.
371
+ void tunnel.exited.then(() => {
372
+ if (!enrollmentFinished) enrollmentAbort.abort(new ClientLinkJoinError("join_tunnel_failed"));
373
+ });
374
+ const signal = deps.connectDeps?.signal
375
+ ? AbortSignal.any([enrollmentAbort.signal, deps.connectDeps.signal]) : enrollmentAbort.signal;
376
+ // Observe a tunnel that exited after readiness before starting any enrollment write.
377
+ await Promise.resolve();
378
+ signal.throwIfAborted();
320
379
  await connect({
321
380
  serverUrl: `http://127.0.0.1:${tunnelPort}`,
322
381
  managementUrl: `http://127.0.0.1:${tunnelPort}`,
@@ -328,10 +387,18 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
328
387
  }, {
329
388
  fetchImpl: deps.fetchImpl,
330
389
  ...deps.connectDeps,
390
+ signal,
331
391
  });
332
- } catch {
392
+ } catch (error) {
393
+ // connectClient has drained its local rollback before rejecting. Only then can
394
+ // the tunnel and the remote key be compensated without racing a late writer.
395
+ const tunnelAborted = enrollmentAbort.signal.aborted;
396
+ enrollmentFinished = true;
333
397
  await rollback(deps, issued.linkId, tunnel);
334
- throw new ClientLinkJoinError("join_connect_failed");
398
+ throw new ClientLinkJoinError(tunnelAborted ? "join_tunnel_failed"
399
+ : error instanceof ClientLinkJoinError ? error.code : "join_connect_failed");
400
+ } finally {
401
+ enrollmentFinished = true;
335
402
  }
336
403
 
337
404
  await stopTunnel(tunnel);