@bitkyc08/opencodex 2.68.0 → 2.69.0-preview.20260928

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-cDPyCb_V.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-H1IAB8mN.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CI6YTp8z.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-CutOlPu2.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
@@ -33,11 +33,15 @@ import {
33
33
  } from "./ownership-policy";
34
34
  import {
35
35
  INTEGRATION_CLIENTS,
36
+ boundIntegrationConfigPath,
37
+ assertDroidPathsUnambiguous,
38
+ assertDroidRecordedSettingsUnambiguous,
36
39
  resolveIntegrationPaths,
37
40
  unresolvedPathHintFor,
38
41
  type IntegrationClientId,
39
42
  } from "./registry";
40
43
  import { resolveIntegrationTarget, type IntegrationTarget } from "./target";
44
+ import { inspectKiloCandidates } from "./kilo-candidates";
41
45
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
42
46
 
43
47
  export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
@@ -49,6 +53,7 @@ export type StateReason =
49
53
  /** A container we would have to write through holds a non-object value. */
50
54
  | "blocked-container"
51
55
  | "ambiguous-selector"
56
+ | "candidate-conflict"
52
57
  /** A path selector we cannot resolve, e.g. a relative OPENCLAW_CONFIG_PATH. */
53
58
  | "unresolvable-path";
54
59
 
@@ -60,6 +65,10 @@ export interface IntegrationStatus {
60
65
  appliedAt?: string;
61
66
  lastOpId?: string;
62
67
  reason?: StateReason;
68
+ /** Other Kilo global candidates defining provider.opencodex. */
69
+ conflictPaths?: string[];
70
+ /** Kilo candidate that could not be inspected; may differ from the owned target. */
71
+ candidateFailurePath?: string;
63
72
  /**
64
73
  * The store this client reads instead of `configPath`.
65
74
  *
@@ -328,7 +337,13 @@ export function classifyIntegration(input: {
328
337
  }
329
338
  throw error;
330
339
  }
331
- if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
340
+ // A catalog can shrink to zero while the record still owns earlier rows.
341
+ // Presence for disable must include those recorded paths, independent of the
342
+ // current export roster; the ownership fingerprint is checked below.
343
+ if (!hasOurFragments(input.parsed, input.contribution)
344
+ && !(input.record?.fragmentPaths.some(path => readPath(input.parsed, path) !== undefined))) {
345
+ return { state: "absent" };
346
+ }
332
347
 
333
348
  /*
334
349
  * Fragments the desired contribution carries beyond the paths this record names. Both
@@ -520,7 +535,9 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
520
535
  try {
521
536
  // One resolution for both, so a client whose paths come from mutable state
522
537
  // cannot report one account's install beside another account's config path.
523
- const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home);
538
+ const context = exportContextOf(input);
539
+ const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
540
+ if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
524
541
  installed = io.statKind(paths.detectDir) === "dir";
525
542
  if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store);
526
543
  /*
@@ -530,8 +547,12 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
530
547
  * would let the badge and the switch disagree.
531
548
  */
532
549
  record = store.readRecords()[input.clientId] ?? null;
550
+ const recordedPath = boundIntegrationConfigPath({
551
+ clientId: input.clientId, record, resolvedPath: paths.configPath,
552
+ statKind: io.statKind, env: input.env, home: input.home,
553
+ });
533
554
  effective = resolveIntegrationTarget({
534
- clientId: input.clientId, configPath: paths.configPath, io, record, env: input.env, home: input.home,
555
+ clientId: input.clientId, configPath: recordedPath, io, record, env: input.env, home: input.home,
535
556
  });
536
557
  } catch (error) {
537
558
  if (!(error instanceof ClientPathError)) throw error;
@@ -558,6 +579,20 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
558
579
  }
559
580
 
560
581
  const configPath = effective.configPath;
582
+ if (input.clientId === "kilo") {
583
+ const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
584
+ if (candidates.kind !== "ok") return {
585
+ clientId: input.clientId,
586
+ state: candidates.kind === "conflict" ? "conflict" : "unsafe",
587
+ installed,
588
+ configPath,
589
+ reason: candidates.kind === "conflict" ? "candidate-conflict" : candidates.why,
590
+ ...(candidates.kind === "conflict" ? { conflictPaths: candidates.paths } : {}),
591
+ ...(candidates.kind === "unsafe" ? { candidateFailurePath: candidates.path } : {}),
592
+ ...(record && record.configPath === configPath ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
593
+ ...retention,
594
+ };
595
+ }
561
596
  const loaded = loadTarget(io, configPath);
562
597
  if (!loaded.ok) {
563
598
  return {
@@ -572,8 +607,13 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
572
607
 
573
608
  const parsed = input.clientId === "cline"
574
609
  ? parseClineDocument(loaded.before)
575
- : parseConfig(loaded.before, effective.format);
610
+ : parseConfig(loaded.before, effective.format, EXPORT_CLIENTS[input.clientId].jsonc ? { jsonc: true } : undefined);
576
611
  const contribution = effective.buildContribution(exportContextOf(input));
612
+ if (input.clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0
613
+ && (!record || record.configPath !== configPath)) {
614
+ return { clientId: input.clientId, state: "unsafe", installed, configPath,
615
+ reason: "unresolvable-path", ...retention };
616
+ }
577
617
  const { state, reason } = classifyIntegration({
578
618
  fileText: loaded.before,
579
619
  fileIsRegular: true,
@@ -584,6 +624,14 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
584
624
  clientId: input.clientId,
585
625
  format: effective.format,
586
626
  });
627
+ if (input.clientId === "droid" && record && (state === "current" || state === "stale")) {
628
+ try { assertDroidRecordedSettingsUnambiguous(spec.detectDir(input.env, input.home), parsed, record); }
629
+ catch (error) {
630
+ if (!(error instanceof ClientPathError)) throw error;
631
+ return { clientId: input.clientId, state: "unsafe", installed, configPath,
632
+ reason: "unresolvable-path", ...retention };
633
+ }
634
+ }
587
635
 
588
636
  return {
589
637
  clientId: input.clientId,
@@ -194,6 +194,9 @@ export function declaredIntegrationTarget(args: {
194
194
  }): IntegrationTarget | null {
195
195
  const { clientId, configPath, resolvedConfigPath } = args;
196
196
  if (configPath === resolvedConfigPath) return configFileTarget(clientId, configPath, null);
197
+ if (INTEGRATION_CLIENTS[clientId].bindsDriftedRecord?.(configPath, args.env, args.home) === true) {
198
+ return configFileTarget(clientId, configPath, null);
199
+ }
197
200
  const declared = INTEGRATION_CLIENTS[clientId].currentStore;
198
201
  if (!declared) return null;
199
202
  try {
@@ -19,6 +19,7 @@ import { detachedConfigSnapshot } from "../config/admitted-identity";
19
19
  import { copyPlainData } from "../lib/plain-data";
20
20
  import type { OcxConfig } from "../types";
21
21
  import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io";
22
+ import { inspectKiloCandidates } from "./kilo-candidates";
22
23
  import {
23
24
  fingerprint,
24
25
  canonicalContribution,
@@ -33,7 +34,14 @@ import {
33
34
  semanticProtectedContributionFingerprint,
34
35
  } from "./ownership-policy";
35
36
  import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
36
- import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
37
+ import {
38
+ INTEGRATION_CLIENTS,
39
+ assertDroidPathsUnambiguous,
40
+ isLoopbackOnly,
41
+ resolveIntegrationPaths,
42
+ restoreOwnershipCollision,
43
+ type IntegrationClientId,
44
+ } from "./registry";
37
45
  import { declaredIntegrationTarget } from "./target";
38
46
  import { exportContextOf } from "./state";
39
47
  import type { IntegrationState } from "./state";
@@ -225,8 +233,8 @@ function sourcePreservingFragmentValue(
225
233
  * and Cline transaction recovery both write. Translating the planner's refusal into WriteRefused
226
234
  * here keeps the planner free of any dependency on this module's result type.
227
235
  */
228
- function preflight(input: IntegrationWriteInput) {
229
- const observed = observeIntegration(input, { maintenance: true, recover: true });
236
+ function preflight(input: IntegrationWriteInput, operation: "apply" | "disable") {
237
+ const observed = observeIntegration(input, { maintenance: true, recover: true }, operation);
230
238
  if (!observed.failed) return observed;
231
239
  const { reason, state, message, snapshotPath, residual } = observed.failed;
232
240
  const refused = refuse(input.clientId, reason, state, message, snapshotPath);
@@ -251,7 +259,7 @@ function applyOrRefreshIntegration(
251
259
  allowAbsent: boolean,
252
260
  conflictPolicy: ConflictPolicy = "refuse",
253
261
  ): WriteOutcome {
254
- const pre = preflight(input);
262
+ const pre = preflight(input, "apply");
255
263
  if (pre.failed) return pre.failed;
256
264
  const { store, io, clientId, spec, target, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
257
265
 
@@ -264,6 +272,9 @@ function applyOrRefreshIntegration(
264
272
  return refuse(clientId, "non_loopback", classified.state,
265
273
  `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
266
274
  }
275
+ if (clientId === "droid" && contribution.fragments.length === 0) {
276
+ return refuse(clientId, "unsafe", classified.state, "Factory Droid has no addressable models in the selected catalog");
277
+ }
267
278
  /*
268
279
  * The write would land, and nothing would read it.
269
280
  *
@@ -429,6 +440,21 @@ function applyOrRefreshIntegration(
429
440
  if (rechecked === undefined || rechecked !== before) {
430
441
  return refuse(clientId, "conflict", "conflict", `${configPath} changed while applying`);
431
442
  }
443
+ if (clientId === "kilo") {
444
+ const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
445
+ if (candidates.kind === "conflict") return refuse(clientId, "conflict", "conflict",
446
+ `${configPath} cannot be managed while ${candidates.paths.join(", ")} also defines provider.opencodex`);
447
+ if (candidates.kind === "unsafe") return refuse(clientId, "unsafe", "unsafe",
448
+ `${candidates.path} cannot be inspected safely (${candidates.why})`);
449
+ }
450
+ if (clientId === "droid") {
451
+ try {
452
+ assertDroidPathsUnambiguous(detectDir, exportContextOf(input));
453
+ } catch (error) {
454
+ if (!(error instanceof ClientPathError)) throw error;
455
+ return refuse(clientId, "unsafe", "unsafe", messageOf(error));
456
+ }
457
+ }
432
458
 
433
459
  const opId = newOpId();
434
460
  const snapshot = store.captureSnapshot(clientId, opId, before);
@@ -497,7 +523,7 @@ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
497
523
  }
498
524
 
499
525
  export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
500
- const pre = preflight(input);
526
+ const pre = preflight(input, "disable");
501
527
  if (pre.failed) return pre.failed;
502
528
  const { store, io, clientId, spec, target, configPath, before, parsed, record, classified } = pre;
503
529
 
@@ -625,6 +651,21 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
625
651
  return refuse(clientId, "conflict", "conflict",
626
652
  `that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
627
653
  }
654
+ /*
655
+ * Preview refuses this in observeRestore. Refusing here too is what keeps an
656
+ * undo of an older candidate from replacing the record a newer candidate owns.
657
+ */
658
+ const currentOwner = store.readRecords()[clientId] ?? null;
659
+ const collision = restoreOwnershipCollision({
660
+ clientId,
661
+ journaledPath: configPath,
662
+ currentPath: currentOwner && currentOwner.clientId === clientId ? currentOwner.configPath : null,
663
+ env: input.env,
664
+ home: input.home,
665
+ });
666
+ if (collision !== null) {
667
+ return refuse(clientId, "conflict", "conflict", collision);
668
+ }
628
669
  if (clientId === "cline") {
629
670
  try { io = createClineIO(io, configPath, store, true); }
630
671
  catch (error) {
@@ -763,6 +804,14 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
763
804
  const store = input.store ?? createIntegrationStateStore();
764
805
  const io = input.io ?? defaultIntegrationIO(store);
765
806
  const spec = INTEGRATION_CLIENTS[input.clientId];
807
+ const config = detachedConfigSnapshot(input.config);
808
+ if (config === null) {
809
+ throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
810
+ }
811
+ const models = copyPlainData(input.models);
812
+ if (!models.ok) {
813
+ throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
814
+ }
766
815
  /*
767
816
  * One resolution for both paths. Aside derives them from the account id in
768
817
  * its manifest, so two independent calls could verify one account's install
@@ -770,7 +819,7 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
770
819
  */
771
820
  const resolvedPaths = input.resolvedPaths
772
821
  ? { ...input.resolvedPaths }
773
- : resolveIntegrationPaths(input.clientId, env, home);
822
+ : resolveIntegrationPaths(input.clientId, env, home, exportContextOf({ ...input, config, models: models.value }));
774
823
  /*
775
824
  * The configuration and the roster are seams like the others, and they were the two still held
776
825
  * by reference. A coordinated write plans from this input, awaits the writer lock and a
@@ -779,14 +828,6 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
779
828
  * checked in one state and written from another, which is the substitution the fingerprint
780
829
  * exists to prevent. Copying both here gives the plan and the document one input.
781
830
  */
782
- const config = detachedConfigSnapshot(input.config);
783
- if (config === null) {
784
- throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
785
- }
786
- const models = copyPlainData(input.models);
787
- if (!models.ok) {
788
- throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
789
- }
790
831
  return { ...input, config, models: models.value, env, home, store, io, resolvedPaths };
791
832
  }
792
833
 
@@ -6,6 +6,7 @@ export type AccountSelectionEvent = {
6
6
  };
7
7
 
8
8
  const listeners = new Set<(event: AccountSelectionEvent) => void>();
9
+ const oauthPauseListeners = new Set<(provider: string) => void>();
9
10
  let revision = 0;
10
11
 
11
12
  /** Call only after the authoritative selection has been persisted. */
@@ -27,6 +28,19 @@ export function subscribeAccountSelections(listener: (event: AccountSelectionEve
27
28
  return () => { listeners.delete(subscription); };
28
29
  }
29
30
 
31
+ /** Internal eligibility invalidation; separate from the public selection stream contract. */
32
+ export function publishOAuthAccountPauseChange(provider: string): void {
33
+ for (const listener of [...oauthPauseListeners]) {
34
+ try { listener(provider); } catch { /* A disconnected cache consumer cannot undo persistence. */ }
35
+ }
36
+ }
37
+
38
+ export function subscribeOAuthAccountPauseChanges(listener: (provider: string) => void): () => void {
39
+ const subscription = (provider: string) => listener(provider);
40
+ oauthPauseListeners.add(subscription);
41
+ return () => { oauthPauseListeners.delete(subscription); };
42
+ }
43
+
30
44
  export function currentAccountSelectionRevision(): number {
31
45
  return revision;
32
46
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * JSONC parse used by OpenCode's launcher and Kilo's managed writer.
3
+ *
4
+ * Comments and trailing commas are stripped by escape-aware passes that are
5
+ * identity on valid strict JSON, so no strict-parse probe is ever needed.
6
+ */
7
+
8
+ /**
9
+ * Strip `//` and block comments outside string literals. Escape-aware so a quote inside
10
+ * an escaped sequence cannot flip string state and expose config text to the stripper.
11
+ */
12
+ function stripJsonComments(text: string): string {
13
+ let out = "";
14
+ let inString = false;
15
+ let inLine = false;
16
+ let inBlock = false;
17
+ for (let i = 0; i < text.length; i++) {
18
+ const ch = text[i]!;
19
+ const next = text[i + 1];
20
+ if (inLine) {
21
+ if (ch === "\n") {
22
+ inLine = false;
23
+ out += ch;
24
+ }
25
+ continue;
26
+ }
27
+ if (inBlock) {
28
+ // Newlines are preserved so JSON.parse error positions stay meaningful.
29
+ if (ch === "\n") out += ch;
30
+ else if (ch === "*" && next === "/") {
31
+ // Emit a separator, not nothing: a block comment between two digits is
32
+ // two tokens and must not collapse into one, which would silently
33
+ // change a malformed value into a different valid one. Whitespace is
34
+ // legal wherever a comment was, so this is identity for valid JSONC.
35
+ out += " ";
36
+ inBlock = false;
37
+ i++;
38
+ }
39
+ continue;
40
+ }
41
+ if (inString) {
42
+ out += ch;
43
+ if (ch === "\\") {
44
+ const escaped = text[i + 1];
45
+ if (escaped !== undefined) { out += escaped; i++; }
46
+ continue;
47
+ }
48
+ if (ch === "\"") inString = false;
49
+ continue;
50
+ }
51
+ if (ch === "\"") { inString = true; out += ch; continue; }
52
+ if (ch === "/" && next === "/") { inLine = true; i++; continue; }
53
+ if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
54
+ out += ch;
55
+ }
56
+ /*
57
+ * An unterminated block comment means the remainder of the document was
58
+ * comment text. Returning it stripped would let a trailing `/*` delete an
59
+ * arbitrary malformed tail, so the caller sees a parse failure instead.
60
+ */
61
+ if (inBlock) throw new SyntaxError("Unterminated block comment");
62
+ return out;
63
+ }
64
+
65
+ /** Drop commas that sit directly before `}` or `]`, ignoring string contents. */
66
+ function stripTrailingCommas(text: string): string {
67
+ let out = "";
68
+ let inString = false;
69
+ for (let i = 0; i < text.length; i++) {
70
+ const ch = text[i]!;
71
+ if (inString) {
72
+ out += ch;
73
+ if (ch === "\\") {
74
+ const escaped = text[i + 1];
75
+ if (escaped !== undefined) { out += escaped; i++; }
76
+ continue;
77
+ }
78
+ if (ch === "\"") inString = false;
79
+ continue;
80
+ }
81
+ if (ch === "\"") { inString = true; out += ch; continue; }
82
+ if (ch === ",") {
83
+ let j = i + 1;
84
+ while (j < text.length && /\s/.test(text[j]!)) j++;
85
+ if (text[j] === "}" || text[j] === "]") continue;
86
+ }
87
+ out += ch;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * Return JSON that `JSON.parse` will accept. Comments and trailing commas are
94
+ * stripped unconditionally: never probed with a strict `JSON.parse` first,
95
+ * because materializing a deeply nested document before the rewrite guard's
96
+ * depth ceiling would bypass that guard's resource contract. The passes are
97
+ * identity on valid strict JSON — `//`, `/*`, and a comma before `}` or `]`
98
+ * can only appear inside strings there, which the strippers never touch.
99
+ */
100
+ export function canonicalizeJsonc(text: string): string {
101
+ return stripTrailingCommas(stripJsonComments(text));
102
+ }
103
+
104
+ /**
105
+ * JSON with optional comments and trailing commas. Same stripping rules as
106
+ * `canonicalizeJsonc`; throws on text that is still not JSON afterwards.
107
+ */
108
+ export function parseJsonc(text: string): unknown {
109
+ return JSON.parse(canonicalizeJsonc(text));
110
+ }
@@ -7,7 +7,15 @@ export function isStandaloneBinary(): boolean {
7
7
  }
8
8
 
9
9
  export function isStandaloneModuleUrl(url: string): boolean {
10
- return url.includes("/$bunfs/") || /^file:\/\/\/[A-Za-z]:\/~BUN\//.test(url);
10
+ try {
11
+ const parsed = new URL(url);
12
+ if (parsed.protocol !== "file:" || parsed.host !== "") return false;
13
+ // Bun may encode the Windows virtual root's tilde; decode one URL layer only.
14
+ const path = decodeURIComponent(parsed.pathname);
15
+ return path.startsWith("/$bunfs/") || /^\/[A-Za-z]:\/~BUN\//.test(path);
16
+ } catch {
17
+ return false;
18
+ }
11
19
  }
12
20
 
13
21
  /** Directory containing the compiled executable and its copied runtime assets. */
@@ -10,7 +10,8 @@ import { decodeWindowsTextBytes } from "./windows-text";
10
10
  * normalized `http://host:port` URL when a static proxy is enabled. PAC/WPAD, per-request
11
11
  * resolution, ProxyOverride, live refresh, and direct fallback are deliberately out of scope:
12
12
  * this is the piece an operator can audit from one log line, and everything else needs the
13
- * transport boundary the reviewer asked for first.
13
+ * transport boundary the reviewer asked for first. The bypass readers further down serve
14
+ * `ocx doctor` only; egress discovery does not consult them.
14
15
  */
15
16
 
16
17
  const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
@@ -114,6 +115,105 @@ export function readWindowsSystemProxy(
114
115
  return parseWindowsProxyServer(values.proxyServer);
115
116
  }
116
117
 
118
+ /**
119
+ * The two values that decide whether a host skips the static proxy: `ProxyOverride` (the bypass
120
+ * list) and `AutoConfigURL` (a PAC script, which takes over the decision entirely), plus the
121
+ * "Automatically detect settings" (WPAD) flag, which can also pick a proxy per request. Read on
122
+ * demand by diagnostics only; startup discovery above still ignores all three.
123
+ */
124
+ export interface WindowsProxyBypassValues {
125
+ proxyOverride: string | null;
126
+ autoConfigUrl: string | null;
127
+ /** `null` when the connection-settings blob could not be read: detection may be on. */
128
+ autoDetect: boolean | null;
129
+ }
130
+
131
+ /** Output lines of `reg query <key>`, or `null` when the key could not be read at all. */
132
+ export type WindowsRegistryKeyLister = (key: string) => string | null;
133
+
134
+ function listRegistryKey(key: string): string | null {
135
+ try {
136
+ const stdout = execFileSync(registryExe(), ["query", key], {
137
+ encoding: "buffer",
138
+ stdio: ["ignore", "pipe", "ignore"],
139
+ timeout: 2000,
140
+ maxBuffer: 256 * 1024,
141
+ windowsHide: true,
142
+ });
143
+ return decodeWindowsTextBytes(stdout);
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function registryValue(listing: string, name: string): string | null {
150
+ for (const row of listing.split(/\r?\n/)) {
151
+ // " Name REG_TYPE data" (data is absent for an empty string)
152
+ const match = row.match(/^ {4}(\S.*?) {4}(REG_[A-Z_]+)(?: {4}(.*))?$/);
153
+ if (match && match[1]!.toLowerCase() === name.toLowerCase()) return (match[3] ?? "").trim();
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * WinINET `DefaultConnectionSettings` stores its flags in the ninth byte; 0x08 is
160
+ * "Automatically detect settings". Anything unparseable returns `null` (unknown), never `false`.
161
+ */
162
+ export function parseWindowsAutoDetect(hex: string | null): boolean | null {
163
+ if (!hex || !/^[0-9a-f]{18,}$/i.test(hex)) return null;
164
+ return (Number.parseInt(hex.slice(16, 18), 16) & 0x08) !== 0;
165
+ }
166
+
167
+ /**
168
+ * Reads the bypass values from one listing of the Internet Settings key, so an absent value
169
+ * (`null`) is distinguishable from a failed read (the whole result is `null`).
170
+ */
171
+ export function readWindowsProxyBypassRegistry(
172
+ list: WindowsRegistryKeyLister = listRegistryKey,
173
+ ): WindowsProxyBypassValues | null {
174
+ const settings = list(INTERNET_SETTINGS_KEY);
175
+ if (settings === null) return null;
176
+ const connections = list(`${INTERNET_SETTINGS_KEY}\\Connections`);
177
+ return {
178
+ proxyOverride: registryValue(settings, "ProxyOverride"),
179
+ autoConfigUrl: registryValue(settings, "AutoConfigURL") || null,
180
+ autoDetect: connections === null ? null : parseWindowsAutoDetect(registryValue(connections, "DefaultConnectionSettings")),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Whether a WinINET `ProxyOverride` list exempts an HTTPS `host` (port 443). Entries are
186
+ * semicolon separated, case-insensitive, and may use `*` wildcards, a leading `.` for
187
+ * subdomains, an optional `scheme://` prefix and an optional `:port`. `<local>` matches only
188
+ * dotless names, so it never exempts a public API host.
189
+ */
190
+ export function windowsProxyOverrideBypasses(proxyOverride: string | null, host: string): boolean {
191
+ if (!proxyOverride) return false;
192
+ const target = host.toLowerCase();
193
+ for (const raw of proxyOverride.split(";")) {
194
+ let entry = raw.trim().toLowerCase();
195
+ if (!entry) continue;
196
+ if (entry === "<local>") {
197
+ if (!target.includes(".")) return true;
198
+ continue;
199
+ }
200
+ const scheme = entry.match(/^([a-z][a-z0-9+.-]*):\/\//);
201
+ if (scheme) {
202
+ if (scheme[1] !== "https") continue;
203
+ entry = entry.slice(scheme[0].length);
204
+ }
205
+ const port = entry.match(/:(\d+)$/);
206
+ if (port) {
207
+ if (port[1] !== "443") continue;
208
+ entry = entry.slice(0, -port[0].length);
209
+ }
210
+ if (entry.startsWith(".")) entry = `*${entry}`;
211
+ const pattern = new RegExp(`^${entry.split("*").map(part => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
212
+ if (pattern.test(target)) return true;
213
+ }
214
+ return false;
215
+ }
216
+
117
217
  /** Log-safe form: origin only, so a credentialed value can never reach the console. */
118
218
  export function describeProxyForLog(url: string): string {
119
219
  try { return new URL(url).origin; } catch { return "<unparseable>"; }
@@ -0,0 +1,29 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ export const LINK_RELAY_AUTH_PATH = "/.well-known/opencodex/link-relay-auth";
4
+ export const LINK_RELAY_SESSION_HEADER = "x-opencodex-link-session";
5
+ export const LINK_RELAY_AUTH_VERSION = "2";
6
+ export const LINK_RELAY_AUTH_TIMEOUT_MS = 5_000;
7
+
8
+ /** The direction and both identities are covered; a caller proof is never a listener proof. */
9
+ export function linkRelayProof(
10
+ fingerprint: string, direction: "caller" | "listener", keyId: string, linkId: string, nonce: string,
11
+ ): string | null {
12
+ if (!/^[a-f0-9]{64}$/.test(fingerprint) || !/^lnk_[a-f0-9]{16}$/.test(linkId)
13
+ || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(keyId) || !/^[a-f0-9]{64}$/.test(nonce)) return null;
14
+ return createHmac("sha256", Buffer.from(fingerprint, "hex"))
15
+ .update(`opencodex-link-relay-v2\0${direction}\0${keyId}\0${linkId}\0${nonce}`).digest("hex");
16
+ }
17
+
18
+ export function linkRelayChallenge(fingerprint: string, keyId: string, linkId: string) {
19
+ const nonce = randomBytes(32).toString("hex");
20
+ const caller = linkRelayProof(fingerprint, "caller", keyId, linkId, nonce);
21
+ const expected = linkRelayProof(fingerprint, "listener", keyId, linkId, nonce);
22
+ if (!caller || !expected) throw new Error("invalid link relay identity");
23
+ return { nonce, caller, expected };
24
+ }
25
+
26
+ export function linkRelayProofMatches(actual: string | null, expected: string | null): boolean {
27
+ return !!actual && !!expected && /^[a-f0-9]{64}$/.test(actual) && /^[a-f0-9]{64}$/.test(expected)
28
+ && timingSafeEqual(Buffer.from(actual, "hex"), Buffer.from(expected, "hex"));
29
+ }
@@ -114,7 +114,7 @@ export function buildTunnelArgv(options: TunnelArgvOptions): string[] {
114
114
 
115
115
  export interface ExecArgvOptions {
116
116
  alias: string;
117
- /** Remote argv. Each element is quoted for the remote POSIX shell. */
117
+ /** Remote argv. The command must be sh; its arguments are quoted for the remote shell. */
118
118
  argv: readonly string[];
119
119
  knownHostsFile: string;
120
120
  }
@@ -177,10 +177,13 @@ export function buildResolveArgv(alias: string): string[] {
177
177
  return ["ssh", "-G", "--", assertSshAlias(alias)];
178
178
  }
179
179
 
180
- /** Quote argv for a POSIX remote shell: every element single-quoted, embedded quotes escaped. */
180
+ /** Emit only sh in command position; single-quote every argument for the remote shell. */
181
181
  export function quoteRemote(argv: readonly string[]): string {
182
- return argv.map(arg => {
182
+ if (argv[0] !== "sh") throw new LinkSshArgumentError("remote command must be sh");
183
+ return argv.map((arg, index) => {
183
184
  if (arg.includes("\0")) throw new LinkSshArgumentError("remote argument contains NUL");
185
+ // PowerShell treats a leading quoted word as an expression, not a command invocation.
186
+ if (index === 0) return "sh";
184
187
  return `'${arg.replaceAll("'", `'"'"'`)}'`;
185
188
  }).join(" ");
186
189
  }
@@ -93,7 +93,7 @@ export class SshRunnerError extends Error {
93
93
  }
94
94
  }
95
95
 
96
- async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void): Promise<string> {
96
+ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true): Promise<string> {
97
97
  const reader = stream.getReader();
98
98
  const chunks: Uint8Array[] = [];
99
99
  let total = 0;
@@ -118,7 +118,7 @@ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number,
118
118
  offset += chunk.byteLength;
119
119
  }
120
120
  try {
121
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
121
+ return new TextDecoder("utf-8", { fatal: fatalUtf8 }).decode(bytes);
122
122
  } catch (error) {
123
123
  throw new SshRunnerError("decode", "ssh output was not valid UTF-8", { cause: error });
124
124
  }
@@ -150,7 +150,7 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
150
150
  throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
151
151
  }
152
152
  const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
153
- const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
153
+ const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false);
154
154
  void stdout.catch(() => {});
155
155
  void stderr.catch(() => {});
156
156
  return {
@@ -181,8 +181,8 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
181
181
  throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
182
182
  }
183
183
 
184
- const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
185
- const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
184
+ const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
185
+ const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"), false);
186
186
  if (options.stdin !== undefined) {
187
187
  try {
188
188
  const input = child.stdin;