@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -1,4 +1,4 @@
1
- import { lstatSync, realpathSync } from "node:fs";
1
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  // POSIX path operations on purpose: every path this module classifies is a macOS path. The
4
4
  // host's own path module would build backslash or drive-letter paths on Windows and make the
@@ -15,7 +15,11 @@ const { join, resolve } = posix;
15
15
  * synced folder turns ordinary requests into intermittent 502s (#6314). This only answers
16
16
  * "is it likely synced"; the guard itself stays strict.
17
17
  */
18
- export type SyncedStateLocation = "icloud-drive" | "file-provider" | "icloud-desktop-documents";
18
+ export type SyncedStateLocation =
19
+ | "icloud-drive"
20
+ | "file-provider"
21
+ | "icloud-desktop-documents"
22
+ | "google-drive-desktop-documents";
19
23
 
20
24
  export interface SyncedStateLocationProbe {
21
25
  readonly platform?: NodeJS.Platform;
@@ -24,23 +28,34 @@ export interface SyncedStateLocationProbe {
24
28
  readonly realpath?: (path: string) => string;
25
29
  /** Whether a directory entry exists at the path, without following it. */
26
30
  readonly entryExists?: (path: string) => boolean;
31
+ /** Entry names in a directory. An unreadable directory is empty, not an error. */
32
+ readonly listEntries?: (dir: string) => readonly string[];
27
33
  }
28
34
 
29
35
  const defaultEntryExists = (path: string): boolean => {
30
36
  try { lstatSync(path); return true; } catch { return false; }
31
37
  };
32
38
 
39
+ const defaultListEntries = (dir: string): readonly string[] => {
40
+ try { return readdirSync(dir); } catch { return []; }
41
+ };
42
+
33
43
  /**
34
44
  * Advisory only. Apple publishes no API a CLI can ask, so this reads the observed layout:
35
45
  * iCloud Drive lives under ~/Library/Mobile Documents, File Provider clients (OneDrive, Dropbox,
36
46
  * Google Drive) under ~/Library/CloudStorage, and with "Desktop & Documents Folders" turned on
37
- * iCloud Drive holds a Desktop/Documents entry of its own. A false positive costs one warning
38
- * line; nothing is refused on the strength of it.
47
+ * iCloud Drive holds a Desktop/Documents entry of its own. Google Drive for desktop can also
48
+ * sync the native Desktop and Documents folders without moving them under CloudStorage (#6314).
49
+ * The cheap signals for that are a DriveFS directory or a `GoogleDrive-*` CloudStorage folder.
50
+ * They do not read which folders the app was told to sync, so Drive being installed can warn
51
+ * even when Documents is not selected. A false positive costs one warning line; nothing is
52
+ * refused on the strength of it.
39
53
  */
40
54
  export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe = {}): SyncedStateLocation | undefined {
41
55
  if ((probe.platform ?? process.platform) !== "darwin") return undefined;
42
56
  const realpath = probe.realpath ?? ((path: string) => realpathSync.native(path));
43
57
  const entryExists = probe.entryExists ?? defaultEntryExists;
58
+ const listEntries = probe.listEntries ?? defaultListEntries;
44
59
  const canonical = (path: string): string => {
45
60
  try { return realpath(path); } catch { return resolve(path); }
46
61
  };
@@ -56,17 +71,42 @@ export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe
56
71
  if (within(mobileDocuments)) return "icloud-drive";
57
72
  if (within(join(home, "Library", "CloudStorage"))) return "file-provider";
58
73
  for (const folder of ["Desktop", "Documents"]) {
59
- if (within(join(home, folder)) && entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) {
60
- return "icloud-desktop-documents";
74
+ if (!within(join(home, folder))) continue;
75
+ if (entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) return "icloud-desktop-documents";
76
+ if (googleDriveAppearsToSyncDesktopDocuments(home, entryExists, listEntries)) {
77
+ return "google-drive-desktop-documents";
61
78
  }
62
79
  }
63
80
  return undefined;
64
81
  }
65
82
 
83
+ /**
84
+ * Presence only. Drive for desktop keeps its config under DriveFS and, on current macOS,
85
+ * a `GoogleDrive-*` directory in CloudStorage. Either one means the app is in use. Missing
86
+ * or unreadable markers are "not detected", never a startup failure.
87
+ */
88
+ function googleDriveAppearsToSyncDesktopDocuments(
89
+ home: string,
90
+ entryExists: (path: string) => boolean,
91
+ listEntries: (dir: string) => readonly string[],
92
+ ): boolean {
93
+ const driveFs = join(home, "Library", "Application Support", "Google", "DriveFS");
94
+ try {
95
+ if (entryExists(driveFs)) return true;
96
+ } catch { /* unreadable marker */ }
97
+ try {
98
+ return listEntries(join(home, "Library", "CloudStorage"))
99
+ .some((name) => name.toLowerCase().startsWith("googledrive-"));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
66
105
  const LOCATION_LABEL: Record<SyncedStateLocation, string> = {
67
106
  "icloud-drive": "inside iCloud Drive",
68
107
  "file-provider": "inside a cloud-storage (File Provider) folder",
69
108
  "icloud-desktop-documents": "in Desktop or Documents, which iCloud Drive appears to sync",
109
+ "google-drive-desktop-documents": "in Desktop or Documents, which Google Drive appears to sync",
70
110
  };
71
111
 
72
112
  /** The startup warning. Names the location kind, never the path. */
@@ -20,7 +20,7 @@
20
20
  * how this incident happened.
21
21
  */
22
22
  import { homedir } from "node:os";
23
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
24
  import { realpathSync } from "node:fs";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
@@ -31,6 +31,8 @@ const GUARD_ENV = "OCX_TEST_HOME_GUARD";
31
31
  * loads, so the true home is only knowable from this hand-off.
32
32
  */
33
33
  const REAL_HOME_ENV = "OCX_REAL_HOME";
34
+ /** Set by `scripts/test.ts` to the developer's own Claude config directory before it sandboxes one. */
35
+ const REAL_CLAUDE_CONFIG_DIR_ENV = "OCX_REAL_CLAUDE_CONFIG_DIR";
34
36
 
35
37
  /**
36
38
  * Resolve symlinks so two spellings of one location compare equal — macOS hands out
@@ -62,6 +64,25 @@ function canonicalize(path: string): string {
62
64
  const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
63
65
  const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
64
66
  const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
67
+ /**
68
+ * The developer's Claude config directory: the default `<real home>/.claude`, plus their own
69
+ * `CLAUDE_CONFIG_DIR` when they set one. `scripts/test.ts` hands the original over as
70
+ * `OCX_REAL_CLAUDE_CONFIG_DIR` because its child already starts with a sandboxed
71
+ * `CLAUDE_CONFIG_DIR`; a bare run imports this module before the preload rewrites the
72
+ * environment, so `CLAUDE_CONFIG_DIR` still holds the developer's value here.
73
+ *
74
+ * Claude agent sync prunes generated `agents/ocx-*.md`, and a bare `bun test` once did that
75
+ * to a live directory: Bun's `os.homedir()` ignores a HOME rewritten after startup (#6775).
76
+ */
77
+ const REAL_CLAUDE_CONFIG_DIRS = [...new Set([
78
+ join(REAL_HOME, ".claude"),
79
+ process.env[REAL_CLAUDE_CONFIG_DIR_ENV]?.trim() || process.env.CLAUDE_CONFIG_DIR?.trim() || join(REAL_HOME, ".claude"),
80
+ ])];
81
+ const PROTECTED_CLAUDE_TREES = REAL_CLAUDE_CONFIG_DIRS.map(path => ({
82
+ path: canonicalize(path),
83
+ lexical: resolve(path),
84
+ label: "the real Claude config directory",
85
+ }));
65
86
  /**
66
87
  * `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it:
67
88
  * `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites
@@ -83,6 +104,11 @@ export function protectedCodexHomeForTests(): string {
83
104
  return PROTECTED_CODEX_HOME;
84
105
  }
85
106
 
107
+ /** The Claude config directories this process protects. Exported for the guard's own tests. */
108
+ export function protectedClaudeConfigDirsForTests(): readonly string[] {
109
+ return PROTECTED_CLAUDE_TREES.map(tree => tree.path);
110
+ }
111
+
86
112
  export function isTestHomeGuardArmed(): boolean {
87
113
  return process.env[GUARD_ENV] === "1";
88
114
  }
@@ -155,6 +181,54 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
155
181
  );
156
182
  }
157
183
 
184
+ /**
185
+ * Throw when an armed test process is about to write or prune inside the real Claude config
186
+ * directory. Unlike the OpenCodex and Codex homes this refuses descendants too, and callers pass
187
+ * every directory they are about to touch (the config root AND its `agents`/`cache` child), so a
188
+ * sandbox child that is a link into the real directory is judged by where it resolves.
189
+ *
190
+ * The protected roots are resolved at check time rather than at import, so a custom directory
191
+ * created after this module loaded still compares by its current identity. On macOS and Windows,
192
+ * whose default filesystems ignore case, paths compare case-insensitively: `.CLAUDE` is the same
193
+ * directory there. Comparing too broadly only refuses more, which is the safe direction here.
194
+ *
195
+ * Call FIRST, outside any best-effort catch, so a refusal is visible and nothing is touched.
196
+ */
197
+ export function assertNotRealClaudeConfigUnderTest(...paths: string[]): void {
198
+ if (!isTestHomeGuardArmed()) return;
199
+ const roots = currentClaudeConfigRoots();
200
+ for (const path of paths) {
201
+ for (const candidate of [canonicalize(path), resolve(path)]) {
202
+ for (const root of roots) {
203
+ if (!isSameOrInsideIgnoringPlatformCase(root, candidate)) continue;
204
+ throw new Error(
205
+ `refusing to write the real Claude config directory (${root}) from a test process. `
206
+ + "Set CLAUDE_CONFIG_DIR to a temp directory, or pass an explicit config directory "
207
+ + "(for a server, startServer's managementApi.claudeAgentConfigDir).",
208
+ );
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ function currentClaudeConfigRoots(): string[] {
215
+ return REAL_CLAUDE_CONFIG_DIRS.flatMap(path => [canonicalize(path), resolve(path)]);
216
+ }
217
+
218
+ function foldPlatformCase(path: string): string {
219
+ return process.platform === "darwin" || process.platform === "win32" ? path.toLowerCase() : path;
220
+ }
221
+
222
+ function isSameOrInsideIgnoringPlatformCase(root: string, candidate: string): boolean {
223
+ const parent = foldPlatformCase(root);
224
+ const child = foldPlatformCase(candidate);
225
+ if (parent === child) return true;
226
+ const rel = relative(parent, child);
227
+ // Only a leading ".." COMPONENT leaves the root, split on this platform's separator: a child
228
+ // named "..fixture", or "..\\fixture" on POSIX where a backslash is a filename character, is inside.
229
+ return rel !== "" && !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
230
+ }
231
+
158
232
  /**
159
233
  * The trees a removal must never reach, and the reason each one is named.
160
234
  *
@@ -172,6 +246,7 @@ const PROTECTED_TREES: ReadonlyArray<{ path: string; lexical: string; label: str
172
246
  lexical: resolve(join(REAL_HOME, "Library", "LaunchAgents")),
173
247
  label: "the real LaunchAgents directory",
174
248
  },
249
+ ...PROTECTED_CLAUDE_TREES,
175
250
  ];
176
251
  const PROTECTED_REAL_HOME = canonicalize(REAL_HOME);
177
252
  const LEXICAL_REAL_HOME = resolve(REAL_HOME);
@@ -258,6 +333,17 @@ export function protectedRemovalReason(target: string): string | null {
258
333
  if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`;
259
334
  }
260
335
  }
336
+ // The Claude directory is judged like its writer guard: roots resolved now, case folded
337
+ // where the filesystem folds it, so an alias created after import is still refused.
338
+ for (const root of currentClaudeConfigRoots()) {
339
+ if (isSameOrInsideIgnoringPlatformCase(root, candidate)) {
340
+ // Same narrow lift as the trees above: content of the running checkout when that
341
+ // checkout itself lives inside the directory. Never the root itself.
342
+ if (foldPlatformCase(root) !== foldPlatformCase(candidate) && isOwnCheckoutContent(root, candidate)) continue;
343
+ return `the real Claude config directory or a path inside it (${root})`;
344
+ }
345
+ if (isSameOrInsideIgnoringPlatformCase(candidate, root)) return `an ancestor of the real Claude config directory (${root})`;
346
+ }
261
347
  }
262
348
  return null;
263
349
  }
@@ -191,6 +191,7 @@ class Budget implements TranslatorBudget {
191
191
  }
192
192
 
193
193
  openCall(id: string): void {
194
+ if (this.disposed) return;
194
195
  if (!this.calls.has(id)) {
195
196
  this.calls.set(id, { logicalBytes: 0, physicalBytes: 0 });
196
197
  aggregateActiveCalls += 1;
@@ -210,6 +211,7 @@ class Budget implements TranslatorBudget {
210
211
  bytes: number,
211
212
  scope: { kind: TranslatorBufferKind; callId?: string },
212
213
  ): TranslatorTransientReservation {
214
+ if (this.disposed) return { commitRetained() {}, release() {} };
213
215
  const admitted = normalizeBytes(bytes);
214
216
  if (scope.callId && admitted > this.maxCallArgumentBytes) {
215
217
  this.reject("tool_args", this.maxCallArgumentBytes);
@@ -222,6 +224,7 @@ class Budget implements TranslatorBudget {
222
224
  return {
223
225
  commitRetained: () => {
224
226
  if (state !== "reserved") return;
227
+ if (this.disposed) { state = "released"; return; }
225
228
  state = "committed";
226
229
  if (!scope.callId) return;
227
230
  const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 };
@@ -234,6 +237,7 @@ class Budget implements TranslatorBudget {
234
237
  release: () => {
235
238
  if (state !== "reserved") return;
236
239
  state = "released";
240
+ if (this.disposed) return;
237
241
  this.charged.set(scope.kind, Math.max(0, (this.charged.get(scope.kind) ?? 0) - admitted));
238
242
  this.hardChargedBytes = Math.max(0, this.hardChargedBytes - admitted);
239
243
  this.changed();
@@ -242,6 +246,7 @@ class Budget implements TranslatorBudget {
242
246
  }
243
247
 
244
248
  chargeRetained(delta: number, scope: { kind: TranslatorBufferKind; callId?: string }): void {
249
+ if (this.disposed) return;
245
250
  const admitted = normalizeBytes(delta);
246
251
  if (scope.callId) {
247
252
  const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 };
@@ -279,6 +284,7 @@ class Budget implements TranslatorBudget {
279
284
  }
280
285
 
281
286
  private observe(bytes: number): () => void {
287
+ if (this.disposed) return () => {};
282
288
  const observed = normalizeBytes(bytes);
283
289
  this.observedBytes += observed;
284
290
  this.changed();
@@ -369,7 +375,7 @@ export function resetTranslatorAggregateForTests(): void {
369
375
  aggregateOverflows = 0;
370
376
  }
371
377
 
372
- export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget): Response {
378
+ export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget, signal?: AbortSignal): Response {
373
379
  if (!response.body) {
374
380
  budget.dispose();
375
381
  return response;
@@ -379,9 +385,12 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
379
385
  const finalize = () => {
380
386
  if (finalized) return;
381
387
  finalized = true;
388
+ signal?.removeEventListener("abort", finalize);
382
389
  budget.dispose();
383
390
  };
384
- return new Response(new ReadableStream<Uint8Array>({
391
+ // The transport owns cancellation and terminal precedence. Abort releases only
392
+ // accounting here, including when no consumer ever pulls or cancels this body.
393
+ const body = new ReadableStream<Uint8Array>({
385
394
  async pull(controller) {
386
395
  try {
387
396
  const result = await reader.read();
@@ -395,7 +404,11 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
395
404
  }
396
405
  },
397
406
  async cancel(reason) {
398
- try { await reader.cancel(reason); } finally { finalize(); }
407
+ finalize();
408
+ await reader.cancel(reason);
399
409
  },
400
- }), { status: response.status, statusText: response.statusText, headers: response.headers });
410
+ });
411
+ if (signal?.aborted) finalize();
412
+ else signal?.addEventListener("abort", finalize, { once: true });
413
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
401
414
  }
@@ -37,6 +37,9 @@ export type ReplacePublisher =
37
37
  | "storage-cleanup"
38
38
  | "tray";
39
39
 
40
+ /** Called immediately before every rename attempt, including each Windows retry. */
41
+ export interface RenameValidationHooks { validateBeforeRename?: (destination: string) => void }
42
+
40
43
  /** The Windows error codes this module treats as a momentary hold. */
41
44
  export type ReplaceRetryCode = "EBUSY" | "EPERM" | "EACCES";
42
45
 
@@ -97,7 +100,7 @@ const MAX_RETRIES = 2;
97
100
  * Windows sharing violations only, returning the code so the caller can record
98
101
  * which one. Any other error is the caller's to see, immediately.
99
102
  */
100
- function transientWindowsReplaceCode(
103
+ export function transientWindowsReplaceCode(
101
104
  platform: NodeJS.Platform,
102
105
  error: unknown,
103
106
  ): ReplaceRetryCode | null {
@@ -106,6 +109,32 @@ function transientWindowsReplaceCode(
106
109
  return code === "EBUSY" || code === "EPERM" || code === "EACCES" ? code : null;
107
110
  }
108
111
 
112
+ /** One filesystem operation plus its retry observers, carried as members (not free callbacks). */
113
+ export interface WindowsFileOperationStep<T> {
114
+ run: () => T;
115
+ onRetry?: (code: ReplaceRetryCode, exhausted: boolean) => void;
116
+ beforeAttempt?: () => void;
117
+ }
118
+
119
+ /** Bounded sharing-violation retries for one filesystem operation, never a transaction. */
120
+ export function retryWindowsFileOperation<T>(
121
+ step: WindowsFileOperationStep<T>,
122
+ io: Pick<AtomicRenameIO, "platform" | "sleep"> = { platform: process.platform, sleep: Bun.sleepSync },
123
+ ): T {
124
+ for (let attempt = 0; ; attempt += 1) {
125
+ step.beforeAttempt?.();
126
+ try { return step.run(); }
127
+ catch (error) {
128
+ const code = transientWindowsReplaceCode(io.platform, error);
129
+ if (!code) throw error;
130
+ const exhausted = attempt >= MAX_RETRIES;
131
+ step.onRetry?.(code, exhausted);
132
+ if (exhausted) throw error;
133
+ io.sleep(25 * (attempt + 1));
134
+ }
135
+ }
136
+ }
137
+
109
138
  export function renameAtomicFile(
110
139
  source: string,
111
140
  destination: string,
@@ -115,30 +144,23 @@ export function renameAtomicFile(
115
144
  sleep: Bun.sleepSync,
116
145
  },
117
146
  publisher: ReplacePublisher = "config",
147
+ hooks: RenameValidationHooks = {},
118
148
  ): void {
119
- for (let attempt = 0; ; attempt += 1) {
120
- try {
121
- io.rename(source, destination);
122
- return;
123
- } catch (error) {
124
- const code = transientWindowsReplaceCode(io.platform, error);
125
- if (!code) throw error;
126
- if (attempt >= MAX_RETRIES) {
127
- bump(publisher, code, "exhausted");
128
- throw error;
129
- }
130
- bump(publisher, code, "retried");
131
- io.sleep(25 * (attempt + 1));
132
- }
133
- }
149
+ retryWindowsFileOperation({
150
+ run: () => io.rename(source, destination),
151
+ onRetry: (code, exhausted) => bump(publisher, code, exhausted ? "exhausted" : "retried"),
152
+ beforeAttempt: () => hooks.validateBeforeRename?.(destination),
153
+ }, io);
134
154
  }
135
155
 
136
156
  export async function renameAtomicFileAsync(
137
157
  source: string,
138
158
  destination: string,
139
159
  publisher: ReplacePublisher = "config",
160
+ hooks: RenameValidationHooks = {},
140
161
  ): Promise<void> {
141
162
  for (let attempt = 0; ; attempt += 1) {
163
+ hooks.validateBeforeRename?.(destination);
142
164
  try {
143
165
  renameSync(source, destination);
144
166
  return;
@@ -203,6 +203,12 @@ export function resolveTrustedWindowsPowerShellExe(): string {
203
203
  return assertTrustedSystemExecutable(candidate, "PowerShell");
204
204
  }
205
205
 
206
+ /** Absolute System32\\reg.exe path; PATH, SystemRoot and WINDIR cannot select it. */
207
+ export function resolveTrustedWindowsRegExe(): string {
208
+ const candidate = join(resolveTrustedWindowsSystemDirectory(), "reg.exe");
209
+ return assertTrustedSystemExecutable(candidate, "reg.exe");
210
+ }
211
+
206
212
  /** Absolute path to System32\\schtasks.exe from a trusted system directory. */
207
213
  export function resolveTrustedWindowsSchtasksExe(): string {
208
214
  if (elevationExeOverridesForTests?.schtasks) {
@@ -36,8 +36,8 @@
36
36
  * own caches so one shape never satisfies the other's lookup.
37
37
  */
38
38
 
39
- import { existsSync, statSync } from "node:fs";
40
- import { isAbsolute, relative, resolve } from "node:path";
39
+ import { existsSync, realpathSync, statSync } from "node:fs";
40
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
41
41
  import { env, platform } from "node:process";
42
42
  import {
43
43
  SUBPROCESS_KILL_GRACE_MS,
@@ -82,7 +82,10 @@ const timedOutPaths = new Map<string, { consumed: boolean; consumedAt: number }>
82
82
  export const TIMEOUT_MEMO_REARM_MS = 5 * 60_000;
83
83
  /** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */
84
84
  const ASYNC_ICACLS_BELT_MARGIN_MS = 250;
85
- const pendingAsyncIcaclsReaps = new Map<string, Set<Promise<void>>>();
85
+ interface RemovalIdentity { readonly lexical: string; readonly canonical: string | undefined }
86
+ interface AsyncAclWork { readonly identity: RemovalIdentity; readonly reap: Promise<void> }
87
+ const pendingAsyncIcaclsReaps = new Map<string, Set<AsyncAclWork>>();
88
+ const activeAsyncAclWork = new Map<string, Set<AsyncAclWork>>();
86
89
 
87
90
  const scheduleAsyncIcaclsBelt: SubprocessDeadlineScheduler = (callback, milliseconds) => {
88
91
  const timer = setTimeout(callback, milliseconds);
@@ -414,10 +417,13 @@ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<Icac
414
417
  cancelBelt?.();
415
418
  resolve(result);
416
419
  };
417
- const runner = asyncIcaclsRunner(args, timeoutMs).then(
420
+ const run = () => asyncIcaclsRunner(args, timeoutMs).then(
418
421
  result => { finish(result); },
419
422
  () => { finish(spawnFailedResult()); },
420
423
  );
424
+ // Capture and register ownership before the child can start or mutate its alias.
425
+ const tracked = args[0] ? startTrackedAsyncAclWork(args[0], run) : undefined;
426
+ if (!tracked) run();
421
427
  // The belt has to outlast the runner it is guarding, or it is not a belt -- it is the
422
428
  // deadline. The runner may now legitimately outlive it while a killed child is reaped. The
423
429
  // caller is still released, but the target is registered so removal can wait for the distinct
@@ -425,7 +431,7 @@ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<Icac
425
431
  cancelBelt = asyncIcaclsBeltScheduler(
426
432
  () => {
427
433
  const targetPath = args[0];
428
- if (targetPath) registerPendingAsyncIcaclsReap(targetPath, runner);
434
+ if (targetPath && tracked) registerPendingAsyncIcaclsReap(targetPath, tracked.entry);
429
435
  finish({ success: false, exitCode: null, timedOut: true, stdout: "" });
430
436
  },
431
437
  Math.max(1, timeoutMs) + SUBPROCESS_KILL_GRACE_MS + ASYNC_ICACLS_BELT_MARGIN_MS,
@@ -433,22 +439,69 @@ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<Icac
433
439
  });
434
440
  }
435
441
 
436
- function registerPendingAsyncIcaclsReap(targetPath: string, reap: Promise<void>): void {
437
- let pending = pendingAsyncIcaclsReaps.get(targetPath);
442
+ function registerPendingAsyncIcaclsReap(
443
+ targetPath: string,
444
+ entry: AsyncAclWork,
445
+ registry = pendingAsyncIcaclsReaps,
446
+ ): void {
447
+ let pending = registry.get(targetPath);
438
448
  if (!pending) {
439
449
  pending = new Set();
440
- pendingAsyncIcaclsReaps.set(targetPath, pending);
450
+ registry.set(targetPath, pending);
441
451
  }
442
- pending.add(reap);
443
- void reap.finally(() => {
444
- pending!.delete(reap);
445
- if (pending!.size === 0) pendingAsyncIcaclsReaps.delete(targetPath);
452
+ pending.add(entry);
453
+ void entry.reap.then(() => {
454
+ pending!.delete(entry);
455
+ if (pending!.size === 0) registry.delete(targetPath);
446
456
  });
447
457
  }
448
458
 
449
- function pathIsAtOrBelow(targetPath: string, rootPath: string): boolean {
450
- const relativePath = relative(resolve(rootPath), resolve(targetPath));
451
- return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
459
+ /** Register the captured identity before invoking a work/child factory. */
460
+ function startTrackedAsyncAclWork<T>(targetPath: string, start: () => Promise<T>): { work: Promise<T>; entry: AsyncAclWork } {
461
+ const identity = captureRemovalIdentity(targetPath);
462
+ let release!: () => void;
463
+ const entry: AsyncAclWork = { identity, reap: new Promise<void>(done => { release = done; }) };
464
+ registerPendingAsyncIcaclsReap(targetPath, entry, activeAsyncAclWork);
465
+ try {
466
+ const work = start();
467
+ void work.then(release, release);
468
+ return { work, entry };
469
+ } catch (error) { release(); throw error; }
470
+ }
471
+
472
+ function canonicalRemovalPath(path: string): string | undefined {
473
+ const missing: string[] = [];
474
+ let current = resolve(path);
475
+ while (true) {
476
+ try { return resolve(realpathSync.native(current), ...missing); }
477
+ catch (error) {
478
+ if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return undefined;
479
+ const parent = dirname(current);
480
+ if (parent === current) return undefined;
481
+ missing.unshift(basename(current));
482
+ current = parent;
483
+ }
484
+ }
485
+ }
486
+
487
+ function captureRemovalIdentity(path: string): RemovalIdentity {
488
+ return { lexical: resolve(path), canonical: canonicalRemovalPath(path) };
489
+ }
490
+
491
+ function pathIsAtOrBelow(target: string, root: string): boolean {
492
+ const suffix = relative(root, target);
493
+ return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
494
+ }
495
+
496
+ function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity): boolean {
497
+ const target = entry.identity;
498
+ // Failure at capture stays conservative, even if the alias later becomes readable.
499
+ if (target.canonical === undefined || root.canonical === undefined) return true;
500
+ const current = canonicalRemovalPath(target.lexical);
501
+ if (current === undefined) return true;
502
+ const targets = [target.lexical, target.canonical, current];
503
+ return [root.lexical, root.canonical].some(rootPath =>
504
+ targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath)));
452
505
  }
453
506
 
454
507
  /** True while an async icacls runner still owns this exact path after its caller's belt fired. */
@@ -458,22 +511,25 @@ export function windowsSecretAclReapPendingForPath(targetPath: string): boolean
458
511
 
459
512
  /** Non-blocking removal guard for callers that must refuse rather than wait for a stuck child. */
460
513
  export function windowsSecretAclReapPendingAtOrBelow(rootPath: string): boolean {
461
- return [...pendingAsyncIcaclsReaps.keys()]
462
- .some(targetPath => pathIsAtOrBelow(targetPath, rootPath));
514
+ const root = captureRemovalIdentity(rootPath);
515
+ return [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()]
516
+ .some(entries => [...entries].some(entry => retainedWorkIsAtOrBelow(entry, root)));
463
517
  }
464
518
 
465
519
  /**
466
- * Removal barrier for a file or tree that may still be held by a timed-out icacls child.
520
+ * Removal barrier for a file or tree held by a normal or timed-out async icacls child.
467
521
  *
468
522
  * This wait is deliberately separate from ordinary startup and shutdown: a genuinely stuck child
469
523
  * must not defeat the caller-facing belt. Code that chooses to remove the target has the stricter
470
524
  * contract and must not proceed until every registered runner at or below it has actually reaped.
471
525
  */
472
526
  export async function flushWindowsSecretAclReapsBeforeRemoval(rootPath: string): Promise<void> {
527
+ const root = captureRemovalIdentity(rootPath);
473
528
  while (true) {
474
- const pending = [...pendingAsyncIcaclsReaps]
475
- .filter(([targetPath]) => pathIsAtOrBelow(targetPath, rootPath))
476
- .flatMap(([, reaps]) => [...reaps]);
529
+ const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()]
530
+ .flatMap(entries => [...entries])
531
+ .filter(entry => retainedWorkIsAtOrBelow(entry, root))
532
+ .map(entry => entry.reap);
477
533
  if (pending.length === 0) return;
478
534
  await Promise.all(pending);
479
535
  }
@@ -1058,17 +1114,28 @@ function hardenEntry(
1058
1114
  return { ok: false, diagnostics };
1059
1115
  }
1060
1116
 
1117
+ /** Retain removal ownership across principal lookup, every ACL command and diagnostics. */
1118
+ function hardenEntryAsync(
1119
+ targetPath: string,
1120
+ directory: boolean,
1121
+ opts: HardenOptions,
1122
+ cache: Map<string, HardenedIdentity>,
1123
+ extraReadAces: readonly string[],
1124
+ ): Promise<HardenResult> {
1125
+ if (!existsSync(targetPath)) { cache.delete(targetPath); return Promise.resolve({ ok: true }); }
1126
+ if (effectivePlatform() !== "win32" || memoSatisfied(cache, targetPath)) return Promise.resolve({ ok: true });
1127
+ return startTrackedAsyncAclWork(targetPath,
1128
+ () => runHardenEntryAsync(targetPath, directory, opts, cache, extraReadAces)).work;
1129
+ }
1130
+
1061
1131
  /** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */
1062
- async function hardenEntryAsync(
1132
+ async function runHardenEntryAsync(
1063
1133
  targetPath: string,
1064
1134
  directory: boolean,
1065
1135
  opts: HardenOptions,
1066
1136
  cache: Map<string, HardenedIdentity>,
1067
1137
  extraReadAces: readonly string[],
1068
1138
  ): Promise<HardenResult> {
1069
- if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
1070
- if (effectivePlatform() !== "win32") return { ok: true };
1071
- if (memoSatisfied(cache, targetPath)) return { ok: true };
1072
1139
  const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
1073
1140
  if (extraReadAces.length === 0 && await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
1074
1141
  const memoKey = timeoutMemoKey(targetPath, opts);
package/src/lib/winsw.ts CHANGED
@@ -20,7 +20,7 @@ import { homedir } from "node:os";
20
20
  import { join, resolve, win32 } from "node:path";
21
21
  import { expandUserPath, getConfigDir, loadConfig } from "../config";
22
22
  import { recordOwnedConfigPath } from "./config-ownership";
23
- import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime";
23
+ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "./bun-runtime";
24
24
  import type { BunRuntimeSource } from "./bun-runtime";
25
25
  import { serviceApiTokenFilePath } from "./service-secrets";
26
26
  import { filterTransientServicePath } from "./transient-service-path";
@@ -408,7 +408,6 @@ export function winswStatusSummary(): string {
408
408
  }
409
409
 
410
410
  /** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */
411
- export function defaultWinswEntry(cliDir: string): WinswEntry {
412
- const runtime = durableBunRuntime();
411
+ export function defaultWinswEntry(cliDir: string, runtime: DurableBunRuntime = durableBunRuntime()): WinswEntry {
413
412
  return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") };
414
413
  }