@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -32,6 +32,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime"
32
32
  import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit";
33
33
  import { buildWindowsServiceScript, windowsTaskActionMatches } from "./service/windows-taskxml";
34
34
  import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state";
35
+ import { parseSystemdUnitHomes } from "./service/systemd-env";
35
36
 
36
37
  /** Short: this runs inside admission, and a slow answer is the same as none. */
37
38
  export const SERVICE_PROBE_TIMEOUT_MS = 2_000;
@@ -238,21 +239,23 @@ function unknown(reason: string): ServiceManagerInstallation {
238
239
  return { kind: "unknown", reason };
239
240
  }
240
241
 
242
+ /** Decode the entities buildPlist's plistString applies. `&` goes last so a
243
+ * literal `<` written by a double-escaped value stays `<`. */
244
+ function plistStringValue(raw: string): string {
245
+ return raw
246
+ .replace(/&lt;/g, "<")
247
+ .replace(/&gt;/g, ">")
248
+ .replace(/&quot;/g, "\"")
249
+ .replace(/&apos;/g, "'")
250
+ .replace(/&amp;/g, "&");
251
+ }
252
+
241
253
  /** Pull `<key>NAME</key><string>VALUE</string>` out of a plist body. */
242
254
  function plistEnvValue(body: string, key: string): string | null {
243
255
  const match = body.match(
244
256
  new RegExp(`<key>\\s*${key}\\s*</key>\\s*<string>([^<]*)</string>`),
245
257
  );
246
- return match ? match[1] : null;
247
- }
248
-
249
- /** Pull `Environment="NAME=VALUE"` (quoted or bare) out of a systemd unit. */
250
- function unitEnvValue(body: string, key: string): string | null {
251
- for (const line of body.split("\n")) {
252
- const match = line.match(new RegExp(`^\\s*Environment=\\s*"?${key}=([^"\\n]*)"?\\s*$`));
253
- if (match) return match[1];
254
- }
255
- return null;
258
+ return match ? plistStringValue(match[1]) : null;
256
259
  }
257
260
 
258
261
  /**
@@ -321,15 +324,14 @@ function inspectSystemdOffline(home: string): ServiceManagerInstallation {
321
324
  } catch (error) {
322
325
  return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`);
323
326
  }
327
+ const parsed = parseSystemdUnitHomes(body);
328
+ if (parsed.kind === "invalid") return unknown("the systemd unit has unsupported or ambiguous environment assignments");
324
329
  return {
325
330
  kind: "present",
326
331
  claims: [{
327
332
  backend: "systemd",
328
333
  definitionPath,
329
- homes: {
330
- codexHome: unitEnvValue(body, "CODEX_HOME"),
331
- opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
332
- },
334
+ homes: parsed.homes,
333
335
  registration: "absent",
334
336
  }],
335
337
  };
@@ -463,16 +465,14 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
463
465
  } catch (error) {
464
466
  return unknown(`the systemd unit exists but could not be read: ${String(error)}`);
465
467
  }
466
-
468
+ const parsed = parseSystemdUnitHomes(body);
469
+ if (parsed.kind === "invalid") return unknown("the systemd unit has unsupported or ambiguous environment assignments");
467
470
  return {
468
471
  kind: "present",
469
472
  claims: [{
470
473
  backend: "systemd",
471
474
  definitionPath,
472
- homes: {
473
- codexHome: unitEnvValue(body, "CODEX_HOME"),
474
- opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
475
- },
475
+ homes: parsed.homes,
476
476
  registration,
477
477
  }],
478
478
  };
@@ -801,6 +801,59 @@ function probeWinswRegistration(
801
801
  return /\b1060\b/.test(text) ? "absent" : "unknown";
802
802
  }
803
803
 
804
+ /**
805
+ * The `BINARY_PATH_NAME` value from `sc.exe qc` output, or null when the line is missing.
806
+ * `sc qc` field names are not localized; the value may be quoted and carry arguments.
807
+ */
808
+ export function parseScQcBinaryPathName(output: string): string | null {
809
+ const match = /^[ \t]*BINARY_PATH_NAME[ \t]*:[ \t]*([^\r\n]*)$/m.exec(output);
810
+ const value = match?.[1]?.trim();
811
+ return value ? value : null;
812
+ }
813
+
814
+ function normalizeWindowsExecutablePath(path: string): string {
815
+ const stripped = path.startsWith("\\\\?\\") ? path.slice(4) : path;
816
+ return win32Path.normalize(stripped).toLowerCase();
817
+ }
818
+
819
+ /**
820
+ * Whether an SCM `BINARY_PATH_NAME` launches exactly `exePath`. A quoted value names the
821
+ * text between its quotes; an unquoted value names the text through the first `.exe`
822
+ * followed by whitespace or the end. Comparison is Windows path-normalized and case-insensitive.
823
+ */
824
+ export function scBinaryPathNamesExecutable(binaryPathName: string, exePath: string): boolean {
825
+ const value = binaryPathName.trim();
826
+ let executable: string | null;
827
+ if (value.startsWith("\"")) {
828
+ const end = value.indexOf("\"", 1);
829
+ executable = end > 1 ? value.slice(1, end) : null;
830
+ } else {
831
+ executable = /^(.*?\.exe)(?=\s|$)/i.exec(value)?.[1] ?? null;
832
+ }
833
+ if (!executable || !exePath.trim()) return false;
834
+ return normalizeWindowsExecutablePath(executable) === normalizeWindowsExecutablePath(exePath.trim());
835
+ }
836
+
837
+ /**
838
+ * The registered WinSW service's `BINARY_PATH_NAME` through trusted System32 `sc.exe qc`,
839
+ * bounded like every other probe query. Null on any failure; the WinSW executable is never run.
840
+ */
841
+ export function queryWinswBinaryPathName(
842
+ deps: Pick<ProbeDeps, "runRaw" | "windowsLocale"> = {},
843
+ ): string | null {
844
+ let sc: string;
845
+ try {
846
+ sc = join(resolveTrustedWindowsSystemDirectory(), "sc.exe");
847
+ if (artifactPresence(sc) !== "present") return null;
848
+ } catch {
849
+ return null;
850
+ }
851
+ const runRaw = deps.runRaw ?? defaultRawProbeRunner;
852
+ const queried = runRaw(sc, ["qc", WINSW_SERVICE_ID]);
853
+ if (queried.spawnFailed || queried.timedOut || queried.status !== 0) return null;
854
+ return parseScQcBinaryPathName(decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale }));
855
+ }
856
+
804
857
  function inspectWindows(
805
858
  deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
806
859
  & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale" | "windowsTaskListingCache" | "statePaths">,
@@ -495,6 +495,8 @@ export interface OcxClientConnectionConfig {
495
495
  }
496
496
 
497
497
  export interface OcxConfig {
498
+ /** Experimental macOS app-server stdout shim; absent or false disables it. */
499
+ chatgptDesktop?: { appServerShim?: boolean };
498
500
  port: number;
499
501
  /** Runtime topology role. Absence preserves the historical standalone behavior. */
500
502
  runtimeRole?: OcxRuntimeRole;
@@ -576,13 +578,15 @@ export interface OcxConfig {
576
578
  */
577
579
  ultraFastTier?: boolean;
578
580
  /**
579
- * Stop new identity-matched main-account requests at observed 98% usage (#5694).
581
+ * Stop new identity-matched main-account requests at configured window usage (#5694).
580
582
  *
581
583
  * On by default: an absent key and `true` both enable it, and only an explicit `false`
582
584
  * opts out. While it blocks, the main account's Luna Reserve cannot activate, so an operator
583
585
  * who wants Reserve has to turn the setting off rather than delete the key.
584
586
  */
585
587
  codexMainAccountHardLock?: boolean;
588
+ /** Per-window percentages: short defaults to 90, long to 98; integers 80..100, short <= long. */
589
+ codexMainAccountHardLockThresholds?: { short?: number; long?: number };
586
590
  /** Explicit top-level deletion intent used by stale whole-config rebases. */
587
591
  configRebaseProvenance?: OcxConfigRebaseProvenance | Record<string, unknown>;
588
592
  /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */
@@ -651,6 +655,11 @@ export interface OcxConfig {
651
655
  * reject the whole role file as an unknown field (#1190).
652
656
  */
653
657
  subagentModelFallbackByModel?: Record<string, string[]>;
658
+ /**
659
+ * Capability tier of named models for Codex role auto-assign, overriding its price-rank
660
+ * classification. Models no list names and no price covers are never proposed.
661
+ */
662
+ codexRoleTiers?: { fast?: string[]; standard?: string[]; frontier?: string[] };
654
663
  /**
655
664
  * TTL (ms) for cached sub-agent model availability probes. Default 60_000.
656
665
  */
@@ -1015,6 +1024,12 @@ export interface OcxConfig {
1015
1024
  codexAccounts?: CodexAccount[];
1016
1025
  /** Account ids administratively excluded from future pool selection until resumed. */
1017
1026
  pausedCodexAccountIds?: string[];
1027
+ /**
1028
+ * Account ids, `__main__` included, allowed to keep serving from ChatGPT credits once one of
1029
+ * their usage windows is full. Every other account is skipped by selection at 100% until its
1030
+ * window resets, so spending credits is opt-in.
1031
+ */
1032
+ creditCodexAccountIds?: string[];
1018
1033
  /**
1019
1034
  * Codex pool selection policy. Absent means no policy, so an existing install rotates exactly
1020
1035
  * as before.
@@ -1342,6 +1357,21 @@ export interface OcxComboConfig {
1342
1357
  nativeAlias?: boolean;
1343
1358
  /** Display-only label for the public catalog row. Required for native aliases. */
1344
1359
  displayName?: string;
1360
+ /**
1361
+ * `strategy: "jev"` only: provider id of the decision service. Omitted or `"jev"` uses the
1362
+ * canonical TypeSafe endpoint; any other id must name a configured `adapter: "jev-decision"`
1363
+ * row, such as a self-hosted Ollama `tev1` endpoint. That row's baseUrl is the full decision
1364
+ * endpoint and only its own apiKey is sent there.
1365
+ */
1366
+ decisionProvider?: string | null;
1367
+ /** JEV only: an ordinary model route for decisions; null explicitly clears management input. */
1368
+ decisionModel?: string | null;
1369
+ /**
1370
+ * `strategy: "jev"` only: decision deadline in milliseconds before failing open to the first
1371
+ * eligible target. Default 4000; range 1000..120000. Raise it for a self-hosted decision model
1372
+ * whose first call may include a cold model load.
1373
+ */
1374
+ decisionTimeoutMs?: number;
1345
1375
  }
1346
1376
 
1347
1377
  export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude";
@@ -274,6 +274,8 @@ export interface ModelCapabilities {
274
274
  }
275
275
 
276
276
  export interface OcxProviderConfig {
277
+ /** Optional browser-compatible outbound TLS profile; disabled by default. */
278
+ tlsProfile?: "antigravity-browser";
277
279
  /** Optional short provider namespace used only at request/catalog presentation time. */
278
280
  alias?: string;
279
281
  /** Native model id -> short, slash-free request alias. */
@@ -116,6 +116,8 @@ export interface OcxParsedRequest {
116
116
  _cursorIsolateConversation?: boolean;
117
117
  /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */
118
118
  _kiroAuthContext?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
119
+ /** Account-scoped Zed user identity paired with the long-lived access token. */
120
+ _zedAuthContext?: { userId: string };
119
121
  /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
120
122
  _providerContinuation?: OcxProviderContinuationState;
121
123
  /** Persisted continuation considered only after the final physical route is known. */
@@ -570,12 +570,23 @@ export async function runUpdate(): Promise<void> {
570
570
  };
571
571
  const recoverStoppedRuntime = async (reason: string): Promise<void> => {
572
572
  try {
573
- const current = await resolvedRuntimeOwnership();
574
- const recovery = planStoppedRuntimeRecovery({
575
- stopAttempted, ...current, sameOwner: sameOwner(current),
576
- liveness: currentPackageRuntimeLiveness(), serviceInstalled: serviceWasInstalled,
577
- launcherUsable: postUpdateLauncherUsable && existsSync(postUpdateLauncher), hadRuntimeState,
578
- });
573
+ const planRecovery = async () => {
574
+ const current = await resolvedRuntimeOwnership();
575
+ return planStoppedRuntimeRecovery({
576
+ stopAttempted, ...current, sameOwner: sameOwner(current),
577
+ liveness: currentPackageRuntimeLiveness(), serviceInstalled: serviceWasInstalled,
578
+ launcherUsable: postUpdateLauncherUsable && existsSync(postUpdateLauncher), hadRuntimeState,
579
+ });
580
+ };
581
+ let recovery = await planRecovery();
582
+ if (recovery.action === "service") {
583
+ // The service manager starts the proxy outside this process tree, so it
584
+ // cannot join this lease, and holding it through the repair's health wait
585
+ // keeps that proxy from starting (#5760). Release it, then decide again —
586
+ // a claim could have landed while the repair ran unleased.
587
+ mutation.release();
588
+ recovery = await planRecovery();
589
+ }
579
590
  if (recovery.action === "manual") {
580
591
  console.warn(`⚠️ ${reason}; runtime recovery requires manual review (${recovery.reason}).`);
581
592
  } else if (recovery.action === "service") {
@@ -585,6 +596,9 @@ export async function runUpdate(): Promise<void> {
585
596
  env: mutation.controlEnvironment({ ...process.env, OCX_BAKE_PORT: String(capturedListen.port) }),
586
597
  });
587
598
  if (service.status !== 0 || !isServiceViable()) {
599
+ // The refresh ran unleased; a claim could have landed meanwhile, so take
600
+ // the lease back before re-reading ownership and authorizing a direct start.
601
+ mutation.reacquire();
588
602
  const nowOwned = await resolvedRuntimeOwnership();
589
603
  const fallback = planStoppedRuntimeRecovery({
590
604
  stopAttempted, ...nowOwned, sameOwner: sameOwner(nowOwned),
@@ -595,6 +609,8 @@ export async function runUpdate(): Promise<void> {
595
609
  else console.warn("⚠️ Service recovery was not confirmed; no second proxy was started.");
596
610
  }
597
611
  } else if (recovery.action === "direct") {
612
+ // No-op while the lease is held; re-locks if the service branch released it.
613
+ mutation.reacquire();
598
614
  await startProxyDirectly();
599
615
  }
600
616
  } catch {
@@ -815,6 +831,11 @@ export async function runUpdate(): Promise<void> {
815
831
  if (!freed) {
816
832
  console.warn(`⚠️ Port ${capturedListen.port} still busy after 30s; repairing service with pinned --port ${capturedListen.port} anyway (refusing to hop).`);
817
833
  }
834
+ // `service repair` re-activates a manager whose `ocx start` child is not a
835
+ // process descendant and cannot join this lease; held through its serving
836
+ // wait, the lease makes that child die at the acquire deadline (#5760).
837
+ // The kill-authorizing reclaim above already ran under the lease.
838
+ mutation.release();
818
839
  const prevBake = process.env.OCX_BAKE_PORT;
819
840
  process.env.OCX_BAKE_PORT = String(capturedListen.port);
820
841
  try {
@@ -855,6 +876,18 @@ export async function runUpdate(): Promise<void> {
855
876
  // Re-read rather than reuse the plan from before the package install: the app can
856
877
  // claim the runtime during an update that takes minutes, and the refusal the
857
878
  // repair above just returned is indistinguishable from any other failure here.
879
+ // The refresh above ran outside the lease — take it back so this re-read and
880
+ // the direct start stay serialized with a claim that landed in that window.
881
+ // A claim that outlasts the wait is reported here: the package is already
882
+ // swapped, so the unexpected-failure recovery below would run a second,
883
+ // unleased repair and surface the raw lock error instead.
884
+ try {
885
+ mutation.reacquire();
886
+ } catch {
887
+ console.warn("⚠️ Updated, but another process kept the runtime ownership lease claimed, so no proxy was started.");
888
+ console.warn(` Run 'ocx service repair', then 'ocx start --port ${capturedListen.port}'.`);
889
+ return 1;
890
+ }
858
891
  const nowOwned = planUpdateRuntimeHandling({
859
892
  ...(await resolvedRuntimeOwnership()),
860
893
  serviceInstalled: true,
package/src/update/job.ts CHANGED
@@ -238,9 +238,8 @@ async function waitForGhostListenClear(
238
238
  }
239
239
 
240
240
  function packageLauncherPath(): string {
241
- // This module lives at src/update/job.ts — the launcher is <pkg-root>/bin/ocx.mjs.
242
- // After `npm install -g`, import.meta.url can still point at npm's renamed temp
243
- // tree (`@bitkyc08/.opencodex-*`). Prefer the live package path when that happens.
241
+ // The launcher is <pkg-root>/bin/ocx.mjs. After `npm install -g`, import.meta.url
242
+ // can still point at npm's renamed temp tree — prefer the live package path then.
244
243
  const fromMeta = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
245
244
  if (!/[\\/]\.opencodex-/i.test(fromMeta) && existsSync(fromMeta)) return fromMeta;
246
245
  const live = fromMeta.replace(/[\\/]@bitkyc08[\\/]\.opencodex-[^\\/]+/i, `${sep}@bitkyc08${sep}opencodex`);
@@ -470,17 +469,16 @@ export function restartCommand(
470
469
  const startArgs = pinPort
471
470
  ? [launcher, "start", "--port", String(Math.trunc(port))]
472
471
  : [launcher, "start"];
473
- // Default to the in-place refresh: `install` always registers, while repair reuses a healthy
474
- // Windows scheduler definition and re-registers only when the live definition is stale.
472
+ // Default to repair, which reuses a healthy registration and re-registers only a stale one.
475
473
  const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs;
476
474
  if (usesNodeLauncher(installer)) {
477
475
  const bin = nodeBin();
478
476
  const args = svcArgs;
479
477
  return { mode, bin, args, display: formatCommand(bin, args) };
480
478
  }
481
- // bun/source installs: restart via the current runtime executable + package launcher (both real
482
- // .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows
483
- // Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids.
479
+ // bun/source installs restart via the runtime executable + package launcher (real
480
+ // .exe files), not `ocx.cmd` — shell-less `.cmd` spawn throws EINVAL on Windows
481
+ // (CVE-2024-27980 hardening), the same class the npm path avoids.
484
482
  const bin = process.execPath;
485
483
  const args = svcArgs;
486
484
  return { mode, bin, args, display: formatCommand(bin, args) };
@@ -873,9 +871,8 @@ export function summarizeCommandOutput(
873
871
  const parts: string[] = [];
874
872
  parts.push(signal ? `terminated by ${signal}` : `exit ${status ?? "null"}`);
875
873
 
876
- // Read npm's own named fields rather than reproducing its text. This is what makes a failed
877
- // update diagnosable again: `code: E404 · 404: 404 Not Found - GET https://registry...` tells
878
- // a user exactly what happened, and none of it can be a local path.
874
+ // Read npm's own named fields rather than reproducing its text — `code: E404 ·
875
+ // 404: ...` tells a user exactly what happened and none of it can be a local path.
879
876
  const fields = npmDiagnosticFields(`${stderr}\n${stdout}`);
880
877
  if (fields.length > 0) parts.push(...fields);
881
878
 
@@ -1001,6 +998,13 @@ export interface RestartIo {
1001
998
  * to a direct proxy start so dashboard updates never leave /healthz dead.
1002
999
  */
1003
1000
  serviceViableFn?: () => boolean;
1001
+ /**
1002
+ * Free the ownership-mutation lease before a service-manager-mediated start that
1003
+ * cannot join it (#5760); wired by runUpdateRestartWithOwnershipLease.
1004
+ */
1005
+ releaseForServiceManagerFn?: () => void;
1006
+ /** Re-acquire the lease for the direct-start fallthrough, then re-run the veto; a notice stops the restart. */
1007
+ reacquireForDirectStartFn?: () => { readonly notice: string; readonly failed: boolean } | null;
1004
1008
  probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
1005
1009
  /** Richer /healthz read for update-correlated restart evidence (pid + version). */
1006
1010
  probeProxyIdentity?: (port: number, hostname?: string) => Promise<RestartProxyIdentity | null>;
@@ -1031,7 +1035,7 @@ export interface RestartIo {
1031
1035
  job: UpdateJobState,
1032
1036
  captured?: { port: number; hostname: string; oldPid?: number },
1033
1037
  io?: RestartIo,
1034
- ) => Promise<void>;
1038
+ ) => Promise<false | void>;
1035
1039
  /**
1036
1040
  * PIDs currently LISTENing on the captured port. Used to widen the post-update
1037
1041
  * kill allowlist beyond the pre-update PID (Windows often leaves a respawned
@@ -1055,9 +1059,8 @@ export interface RestartIo {
1055
1059
  * window: being wrong here costs one extra start attempt; being wrong the other way
1056
1060
  * leaves the user with no proxy at all.
1057
1061
  *
1058
- * It runs AFTER the child's own 20s install probe (SERVICE_INSTALL_HEALTH_MS on
1059
- * macOS/Linux), so a reinstall that exits 0 but never serves spends up to 45s before
1060
- * the fallback — inside RESTART_TIMEOUT_MS of 60s. That is why this is 25s, not more.
1062
+ * It runs AFTER the child's own 20s install probe, so a reinstall that exits 0 but
1063
+ * never serves spends up to 45s before the fallback — inside the 60s restart budget.
1061
1064
  */
1062
1065
  export const SERVICE_RECOVERY_HEALTH_MS = 25_000;
1063
1066
 
@@ -1095,12 +1098,11 @@ async function restartAfterUpdate(
1095
1098
  job: UpdateJobState,
1096
1099
  captured?: { port: number; hostname: string; oldPid?: number },
1097
1100
  io: RestartIo = {},
1098
- ): Promise<void> {
1101
+ ): Promise<false | void> {
1099
1102
  const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
1100
1103
  const config = loadConfig();
1101
- // The stop-first update flow has already cleared pid/runtime state by the time we run,
1102
- // so the pre-update capture (taken before the update command) is the authoritative
1103
- // port to wait on; config is only the cold-start fallback.
1104
+ // Stop-first update already cleared pid/runtime state, so the pre-update capture is
1105
+ // the authoritative port to wait on; config is only the cold-start fallback.
1104
1106
  const port = captured?.port ?? config.port ?? 10100;
1105
1107
  const hostname = captured?.hostname ?? config.hostname ?? "127.0.0.1";
1106
1108
  const oldPid = typeof captured?.oldPid === "number" && captured.oldPid > 0
@@ -1119,10 +1121,9 @@ async function restartAfterUpdate(
1119
1121
  const listPids = io.listListenPidsFn ?? listListenPids;
1120
1122
  const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity;
1121
1123
  const aliveFn = io.isAliveFn ?? isProcessAlive;
1122
- // Pre-update PID plus any ocx still LISTENing on the captured port. After a
1123
- // stop-first package-manager self-update Windows often leaves a respawned bun/node child
1124
- // that is not the captured PID; treating it as protected blocks reclaim and
1125
- // the direct-start fallback never binds.
1124
+ // Pre-update PID plus any ocx still LISTENing on the captured port: a self-update
1125
+ // often leaves a respawned bun/node child that is not the captured PID, and
1126
+ // treating it as protected blocks reclaim so the direct start never binds.
1126
1127
  const reclaimKillAllowlist = (): number[] => {
1127
1128
  const allow = new Set<number>();
1128
1129
  if (oldPid != null) allow.add(oldPid);
@@ -1137,30 +1138,24 @@ async function restartAfterUpdate(
1137
1138
  intervalMs: 100,
1138
1139
  scanIntervalMs: 500,
1139
1140
  killOcxHolders: true,
1140
- // Windows scheduler wrappers can mint a *new* bun PID during the wait; keep
1141
- // killing every ocx listener on this port, not only the pre-wait snapshot.
1142
- // npm rename trees under `@bitkyc08/.opencodex-*` are classified as ocx by
1143
- // isOcxStartCommandLine — never kill unknown foreign claimants on this port.
1141
+ // Scheduler wrappers mint new ocx PIDs during the wait — kill every ocx
1142
+ // listener, not only the pre-wait snapshot; foreign claimants stay protected.
1144
1143
  killAllOcxOnPort: true,
1145
1144
  onlyKillPids,
1146
1145
  });
1147
1146
 
1148
1147
  if (serviceInstalled) {
1149
1148
  // schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop
1150
- // respawns `ocx start` a few seconds later and races port reclaim. End the
1151
- // task again and best-effort kill those wrappers before we touch the socket.
1149
+ // respawns `ocx start` a few seconds later and races port reclaim, so end the
1150
+ // task again and kill those wrappers before touching the socket, then reclaim it
1151
+ // for wrappers that bake `--port`.
1152
1152
  stopWindowsServiceWrappersBestEffort();
1153
- // Stop-first update already unloaded the service; reclaim the socket, then
1154
- // reinstall wrappers that bake `--port`.
1155
1153
  const preServiceAllow = reclaimKillAllowlist();
1156
1154
  const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow));
1157
1155
  let skipServiceInstall = false;
1158
- // This skip existed because refresh ran `ocx service install`, whose Windows path always
1159
- // registers. `service repair` normally reuses the live task and can refresh a stale
1160
- // definition through its guarded create/elevation path, so the install-only skip no longer
1161
- // applies and would leave the common dashboard update with stale service assets.
1162
- //
1163
- // Only a caller that still passes install argv keeps the old behavior.
1156
+ // `service repair` reuses the live task and refreshes a stale definition through
1157
+ // its guarded create/elevation path, so the old install-only skip no longer
1158
+ // applies; only a caller that still passes install argv keeps that behavior.
1164
1159
  const refreshRegisters = (svcArgs ?? []).includes("install");
1165
1160
  if ((io.platform ?? process.platform) === "win32" && process.env.OCX_SERVICE === "1" && refreshRegisters) {
1166
1161
  updateJob(job, {}, "Skipping service re-registration from the non-elevated update worker; falling back to a direct proxy start.");
@@ -1183,13 +1178,17 @@ async function restartAfterUpdate(
1183
1178
  ? liveScan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
1184
1179
  : null;
1185
1180
  if (liveAfter !== null && liveAfter.length === 0) {
1186
- // Non-elevated `service install` will UAC-fail anyway; skip straight to
1187
- // the direct-start fallthrough instead of burning another minute on it.
1181
+ // Non-elevated `service install` will UAC-fail anyway; go straight to the
1182
+ // direct-start fallthrough instead of burning another minute on it.
1188
1183
  updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start.");
1189
1184
  skipServiceInstall = true;
1190
1185
  }
1191
1186
  }
1192
1187
  if (!skipServiceInstall) {
1188
+ // `service repair` re-activates a manager whose `ocx start` child is not a
1189
+ // process descendant and cannot join this lease; held through its serving
1190
+ // wait, the lease makes that child die at the acquire deadline (#5760).
1191
+ io.releaseForServiceManagerFn?.();
1193
1192
  const prevBake = process.env.OCX_BAKE_PORT;
1194
1193
  process.env.OCX_BAKE_PORT = String(Math.trunc(port));
1195
1194
  let serviceOk = false;
@@ -1211,13 +1210,9 @@ async function restartAfterUpdate(
1211
1210
  + "Run 'ocx service status', then 'ocx service repair' by hand.",
1212
1211
  );
1213
1212
  }
1214
- // The refresh that just failed was `ocx service repair` (serviceReinstallArgs).
1215
- // It normally reuses a healthy registration, but a stale definition may have tried
1216
- // guarded re-registration/elevation. Advising `install` here would unconditionally
1217
- // send the user to re-registration — a UAC prompt on
1218
- // Windows and a possible WinSW-to-scheduler backend switch — to fix a service
1219
- // that is already registered. Point at the same command that failed so its
1220
- // output explains why, on every platform.
1213
+ // The refresh that just failed was `ocx service repair`. Advising `install`
1214
+ // here would send the user to re-registration (UAC, possible WinSW backend
1215
+ // switch) for a service that is already registered; name the failed command.
1221
1216
  updateJob(
1222
1217
  job,
1223
1218
  {},
@@ -1230,13 +1225,10 @@ async function restartAfterUpdate(
1230
1225
  else process.env.OCX_BAKE_PORT = prevBake;
1231
1226
  }
1232
1227
  if (serviceOk) {
1233
- // Exit 0 is not enough, and neither is `viable`. Registration state cannot
1234
- // distinguish a serving supervisor from one that registered and bound nothing:
1235
- // `launchctl list` reports both, and `schtasks` reports a task whose child
1236
- // exited immediately. Since WP2 the child asserts the port itself on
1237
- // macOS/Linux, but Windows still reports success from registration alone, a
1238
- // flapping supervisor can satisfy one probe, and the child may be an older CLI.
1239
- // Ask the port before skipping the fallback this branch exists to protect.
1228
+ // Registration state cannot distinguish a serving supervisor from one that
1229
+ // registered and bound nothing — `launchctl list` reports both, `schtasks`
1230
+ // reports a task whose child exited immediately, a flapping supervisor can
1231
+ // satisfy one probe — so ask the port before skipping the fallback.
1240
1232
  const viable = (io.serviceViableFn ?? isServiceViable)();
1241
1233
  if (viable) {
1242
1234
  if (await serviceRestartServed(job, port, hostname, io)) return;
@@ -1256,9 +1248,13 @@ async function restartAfterUpdate(
1256
1248
  }
1257
1249
  }
1258
1250
  }
1259
- // Fall through to the direct proxy start below so the update never leaves the
1260
- // proxy stopped when the service reinstall could not run or did not leave a
1261
- // viable supervisor.
1251
+ }
1252
+
1253
+ // The service refresh ran outside the lease; take it back so the veto and the kills/start below stay serialized.
1254
+ const restartVeto = io.reacquireForDirectStartFn?.();
1255
+ if (restartVeto) {
1256
+ updateJob(job, restartVeto.failed ? { status: "failed", restarted: false, error: restartVeto.notice } : { status: "succeeded", restarted: false }, restartVeto.notice);
1257
+ return false;
1262
1258
  }
1263
1259
 
1264
1260
  const pid = readPid();
@@ -1272,9 +1268,8 @@ async function restartAfterUpdate(
1272
1268
  }
1273
1269
  }
1274
1270
  if (serviceInstalled) stopWindowsServiceWrappersBestEffort();
1275
- // Reclaim the captured port before the pinned start. Spawning `--port` while the old
1276
- // socket is still busy is how Windows updates used to fail health checks (or hop).
1277
- // killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait.
1271
+ // Reclaim before the pinned start: spawning `--port` while the old socket is busy
1272
+ // is how updates used to fail health checks or hop ports.
1278
1273
  const directAllow = reclaimKillAllowlist();
1279
1274
  const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow));
1280
1275
  if (!freed) {
@@ -1459,7 +1454,7 @@ export function restartAfterUpdateForTests(
1459
1454
  job: UpdateJobState,
1460
1455
  captured: { port: number; hostname: string; oldPid?: number },
1461
1456
  io: RestartIo,
1462
- ): Promise<void> {
1457
+ ): Promise<false | void> {
1463
1458
  return restartAfterUpdate(job, captured, io);
1464
1459
  }
1465
1460
 
@@ -1734,7 +1729,7 @@ export async function finishGuiUpdateRestart(
1734
1729
  }
1735
1730
  }
1736
1731
  const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate;
1737
- await restartFn(job, captured, io);
1732
+ if ((await restartFn(job, captured, io)) === false) return false;
1738
1733
  if (!usesNodeLauncher(installer)) {
1739
1734
  // Bun/source: health alone remains enough unless a richer identity probe is supplied.
1740
1735
  if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io);
@@ -1975,9 +1970,14 @@ export async function runGuiUpdateWorker(
1975
1970
  }
1976
1971
 
1977
1972
  if (restart) {
1978
- const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async () => {
1973
+ const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async lease => {
1979
1974
  job = updateJob(job!, { status: "restarting" }, "Update installed. Restarting proxy...");
1980
- return finishGuiUpdateRestart(job!, captured, check.installer, { ...io.restartIo, packageLauncherPathFn: () => activeLauncher });
1975
+ return finishGuiUpdateRestart(job!, captured, check.installer, {
1976
+ ...io.restartIo,
1977
+ releaseForServiceManagerFn: lease.releaseForServiceManager,
1978
+ reacquireForDirectStartFn: lease.reacquireForDirectStart,
1979
+ packageLauncherPathFn: () => activeLauncher,
1980
+ });
1981
1981
  });
1982
1982
  if (outcome.kind === "veto") { updateJob(job, { status: "succeeded", restarted: false }, outcome.notice); return; }
1983
1983
  if (!outcome.value) return;
@@ -5,10 +5,11 @@ export type NpmCachePreflightReason =
5
5
  | "cache_entry_foreign_owner"
6
6
  | "cache_entry_inaccessible"
7
7
  | "cache_path_malformed"
8
+ | "cache_root_dangling_link"
9
+ | "cache_root_not_directory"
8
10
  | "inspection_incomplete"
9
11
  | "npm_config_failed"
10
12
  | "npm_unavailable"
11
- | "windows_skip"
12
13
  | "worker_failed"
13
14
  | "worker_output_malformed"
14
15
  | "worker_timeout";
@@ -20,17 +21,23 @@ export interface NpmCachePreflightResult {
20
21
 
21
22
  export interface NpmCacheInspectionOptions {
22
23
  expectedUid?: number;
24
+ /** Test seam: lstat used by the cache-root check. Defaults to lstatSync. */
25
+ lstatFn?: (path: string) => { isSymbolicLink(): boolean; isDirectory(): boolean };
23
26
  maxDepth?: number;
24
27
  maxEntries?: number;
25
28
  nowMs?: () => number;
26
29
  /** Test seam: resolve a symlinked cache root. Defaults to realpathSync. */
27
30
  realpathFn?: (path: string) => string;
31
+ /** Test seam: stat (following links) used by the cache-root check. Defaults to statSync. */
32
+ statFn?: (path: string) => { isDirectory(): boolean };
28
33
  /** Test seam: resolve an entry's owner uid. Defaults to the lstat result. */
29
34
  uidOf?: (path: string, stat: { uid: number }) => number;
30
35
  timeoutMs?: number;
31
36
  }
32
37
 
33
38
  export interface NpmCachePreflightOptions {
39
+ /** Inspect this resolved cache root instead of letting the worker resolve npm's cache. */
40
+ cachePath?: string;
34
41
  env?: NodeJS.ProcessEnv;
35
42
  execPath?: string;
36
43
  platform?: NodeJS.Platform;
@@ -38,6 +45,31 @@ export interface NpmCachePreflightOptions {
38
45
  timeoutMs?: number;
39
46
  }
40
47
 
48
+ export type NpmCachePathResolution =
49
+ | { ok: true; path: string }
50
+ | { ok: false; reason: "npm_unavailable" | "npm_config_failed" | "cache_path_malformed" };
51
+
52
+ export interface NpmCachePathOptions {
53
+ /** Working directory for `npm config get`; defaults to the home directory. */
54
+ cwd?: string;
55
+ env?: NodeJS.ProcessEnv;
56
+ invocationFn?: (
57
+ args: string[],
58
+ platform: NodeJS.Platform,
59
+ env: NodeJS.ProcessEnv,
60
+ ) => { file: string; args: string[]; options?: Record<string, unknown> } | null;
61
+ platform?: NodeJS.Platform;
62
+ spawnSyncFn?: typeof spawnSync;
63
+ timeoutMs?: number;
64
+ }
65
+
66
+ export function inspectNpmCacheRoot(
67
+ cachePath: string,
68
+ options?: Pick<NpmCacheInspectionOptions, "lstatFn" | "statFn">,
69
+ ): NpmCachePreflightResult;
70
+
71
+ export function resolveNpmCachePath(options?: NpmCachePathOptions): NpmCachePathResolution;
72
+
41
73
  export function inspectNpmCacheDirectory(
42
74
  cachePath: string,
43
75
  options?: NpmCacheInspectionOptions,