@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
@@ -0,0 +1,140 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+
6
+ // Node >=18 also imports this module from bin/ocx.mjs; do not import TS or Bun APIs.
7
+ function run(command, args) {
8
+ return execFileSync(command, args, {
9
+ encoding: "utf8", timeout: 750, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"],
10
+ }).trim();
11
+ }
12
+
13
+ // Mirror src/config/paths.ts getConfigDir/expandUserPath and process-state.ts paths.
14
+ // Keep this Node-safe copy in sync; the focused startup test pins both implementations.
15
+ export function desktopSupervisionPaths() {
16
+ const raw = process.env.OPENCODEX_HOME?.trim();
17
+ const expanded = raw === "~" ? homedir()
18
+ : raw?.startsWith("~/") || raw?.startsWith("~\\") ? join(homedir(), raw.slice(2)) : raw;
19
+ const home = expanded ? resolve(expanded) : join(homedir(), ".opencodex");
20
+ return { pid: join(home, "ocx.pid"), runtimePort: join(home, "runtime-port.json") };
21
+ }
22
+
23
+ function validPid(pid) {
24
+ return Number.isSafeInteger(pid) && pid > 0;
25
+ }
26
+
27
+ function readState(path, parse) {
28
+ try { return parse(readFileSync(path, "utf8")); }
29
+ catch (error) {
30
+ if (error?.code === "ENOENT") return null;
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ function readPidFile() {
36
+ return readState(desktopSupervisionPaths().pid, raw => {
37
+ const text = raw.trim();
38
+ if (!/^\d+$/.test(text) || !validPid(Number(text))) throw new Error("invalid pid");
39
+ return Number(text);
40
+ });
41
+ }
42
+
43
+ function readRuntimePortPid() {
44
+ return readState(desktopSupervisionPaths().runtimePort, raw => {
45
+ // Only project pid: no credential validation, serialization or returned port state.
46
+ const pid = JSON.parse(raw)?.pid;
47
+ if (!validPid(pid)) throw new Error("invalid runtime pid");
48
+ return pid;
49
+ });
50
+ }
51
+
52
+ /** Linux identity fields count from the end of comm, which may contain spaces or ')'. */
53
+ export const procfs = {
54
+ exe: pid => readlinkSync(`/proc/${pid}/exe`),
55
+ parent: pid => {
56
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
57
+ return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
58
+ },
59
+ };
60
+
61
+ export function processIdentity(pid, execute = run, onExecutable) {
62
+ const text = execute("/bin/ps", ["-p", String(pid), "-o", "ppid=,comm="]).trim();
63
+ const row = /^(\d+)\s+(.+)$/.exec(text);
64
+ if (!row) {
65
+ if (text) throw new Error("invalid process identity");
66
+ return null;
67
+ }
68
+ onExecutable?.(row[2]);
69
+ return { parent: Number(row[1]), executable: realpathSync(row[2]) };
70
+ }
71
+
72
+ /** Live supervision is evidence, never a durable ownership claim. */
73
+ export function inspectDesktopSupervision(deps = {}) {
74
+ const platform = deps.platform ?? process.platform;
75
+ if (platform !== "darwin" && platform !== "linux") return { kind: "unsupported" };
76
+ let desktopSeen = false;
77
+ const unknown = reason => ({ kind: "unknown", reason, desktopSeen });
78
+ const seen = executable => { if (basename(executable) === "opencodex-desktop") desktopSeen = true; };
79
+ const readPid = deps.readPid ?? readPidFile;
80
+ const readPortPid = deps.readRuntimePortPid ?? readRuntimePortPid;
81
+ const proc = deps.proc ?? procfs;
82
+ const execute = deps.run ?? run;
83
+ function identity(pid, parent = false) {
84
+ if (platform === "darwin") return processIdentity(pid, execute, parent ? seen : undefined);
85
+ const executable = proc.exe(pid);
86
+ if (parent) seen(executable);
87
+ const parentPid = proc.parent(pid);
88
+ if (!Number.isSafeInteger(parentPid) || parentPid < 0) throw new Error("invalid parent pid");
89
+ return { parent: parentPid, executable: realpathSync(executable) };
90
+ }
91
+ function capture() {
92
+ const pidFile = readPid();
93
+ const portPid = readPortPid();
94
+ const pids = [deps.targetPid, pidFile, portPid].filter(pid => pid !== undefined && pid !== null);
95
+ if (pids.some(pid => !validPid(pid))) return { error: "invalid-pid" };
96
+ if (pids.some(pid => pid !== pids[0])) return { error: "pid-mismatch" };
97
+ const runtimePid = pids[0] ?? null;
98
+ const child = runtimePid !== null ? identity(runtimePid) : null;
99
+ const parent = child && child.parent > 1 ? identity(child.parent, true) : null;
100
+ const snapshot = { pidFile, portPid, runtimePid, child, parent };
101
+ if (!runtimePid || !child || !parent || basename(parent.executable) !== "opencodex-desktop") {
102
+ return { ...snapshot, kind: "none" };
103
+ }
104
+ seen(parent.executable);
105
+ const app = parent.executable;
106
+ if (platform === "darwin" && (basename(dirname(app)) !== "MacOS"
107
+ || basename(dirname(dirname(app))) !== "Contents")) return { error: "app-mismatch" };
108
+ const proxy = realpathSync(join(dirname(app), "ocx"));
109
+ if (child.executable !== proxy || (platform === "linux"
110
+ && (dirname(proxy) !== dirname(app) || basename(proxy) !== "ocx"))) return { error: "proxy-mismatch" };
111
+ accessSync(app, constants.X_OK);
112
+ accessSync(proxy, constants.X_OK);
113
+ return { ...snapshot, kind: "desktop", app, proxy };
114
+ }
115
+ try {
116
+ const first = capture();
117
+ if (first.error) return unknown(first.error);
118
+ const final = capture();
119
+ if (final.error) return unknown(final.error);
120
+ if (JSON.stringify(first) !== JSON.stringify(final)) return unknown("snapshot-changed");
121
+ return final.kind === "desktop"
122
+ ? { kind: "desktop", runtimePid: final.runtimePid, supervisorPid: final.child.parent, app: final.app, proxy: final.proxy }
123
+ : { kind: "none" };
124
+ } catch {
125
+ // Error messages can contain paths or file contents; expose only the bounded reason.
126
+ return unknown("probe-failed");
127
+ }
128
+ }
129
+
130
+ /** One operation retains its Desktop veto through inconclusive later probes. */
131
+ export function createSupervisionLatch() {
132
+ let blocked = false;
133
+ return {
134
+ observe(evidence) {
135
+ if (evidence.kind === "none") blocked = false;
136
+ else if (evidence.kind === "desktop" || (evidence.kind === "unknown" && evidence.desktopSeen)) blocked = true;
137
+ return blocked;
138
+ },
139
+ };
140
+ }
@@ -81,6 +81,7 @@ function probeVersionOnce(
81
81
  windowsShim ? ["/c", shimCommand] : [...args, "--version"],
82
82
  {
83
83
  timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe",
84
+ env: { ...deps.env, OCX_NO_DESKTOP_HANDOFF: "1" },
84
85
  windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}),
85
86
  },
86
87
  ) as SpawnSyncReturns<string>;
@@ -1,5 +1,7 @@
1
1
  import { findLiveProxy, SERVICE_STOP_LIVENESS } from "../server/proxy-liveness";
2
2
  import { existsSync, unlinkSync } from "node:fs";
3
+ import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
4
+ import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight";
3
5
  import { getConfigDir } from "../config";
4
6
  import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "../config/process-state";
5
7
  import { isWslRuntime } from "../codex/home";
@@ -14,16 +16,17 @@ import { assertSchedulerRegistrationBeforeStart } from "./repair";
14
16
  import { SERVICE_MANAGED_ENV, TASK, plistPath, removeServiceInstallStateRecords, writeServiceInstallState } from "./state";
15
17
  import type { ServiceBackend } from "./state";
16
18
  import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd";
17
- import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops";
19
+ import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, commitWindowsInstall, commitWindowsNativeInstall, type WindowsServiceInstallDeps, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops";
18
20
  import { schtasks, probeWindowsSchedulerTask, rollbackWindowsSchedulerTaskOwnedByAttempt, settleDelay } from "./windows-scheduler";
19
21
  import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
20
22
  import { windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml";
21
23
  import { win32 } from "node:path";
22
24
  import { launchdGuiDomain } from "./launchd";
23
25
  import { LABEL } from "./state";
26
+ import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionInspector, type SupervisionLatch } from "./desktop-command-guard";
24
27
 
25
28
  type ServiceOps = {
26
- install: () => void | Promise<void>; start: () => void; stop: () => void;
29
+ install: (runtime: DurableBunRuntime) => void | Promise<void>; start: () => void; stop: () => void;
27
30
  status: () => string; uninstall: () => void;
28
31
  };
29
32
 
@@ -32,18 +35,19 @@ export type ServiceInstallCleanupOps = {
32
35
  stop: () => void;
33
36
  };
34
37
 
35
- export function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
36
- if (process.platform === "darwin")
38
+ export function platformOps(backend: ServiceBackend = "scheduler", deps: { platform?: NodeJS.Platform; windowsInstall?: WindowsServiceInstallDeps } = {}): ServiceOps | null {
39
+ const platform = deps.platform ?? process.platform;
40
+ if (platform === "darwin")
37
41
  // Wrapped, not passed: `installLaunchd` reports whether it reloaded launchd, and only
38
42
  // `repairService` (for the `restart` verb) has any use for that. `ServiceOps.install` is
39
43
  // the generic install seam and deliberately promises nothing about a return value.
40
44
  return { install: () => { installLaunchd(); }, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
41
- if (process.platform === "win32") {
45
+ if (platform === "win32") {
42
46
  if (backend === "native")
43
- return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
44
- return { install: installWindows, start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows };
47
+ return { install: runtime => commitWindowsNativeInstall(runtime, deps.windowsInstall), start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
48
+ return { install: runtime => commitWindowsInstall(runtime, deps.windowsInstall), start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows };
45
49
  }
46
- if (process.platform === "linux") {
50
+ if (platform === "linux") {
47
51
  if (existsSync("/.dockerenv")) {
48
52
  console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
49
53
  process.exit(1);
@@ -229,7 +233,9 @@ export async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyC
229
233
  }
230
234
  }
231
235
 
232
- export interface ServiceInstallPreparationDeps {
236
+ export interface ServiceInstallPreparationDeps extends RuntimePreflightDeps {
237
+ inspectSupervision?: SupervisionInspector;
238
+ supervisionLatch?: SupervisionLatch;
233
239
  diagnose?: () => ServiceDiagnostic;
234
240
  managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
235
241
  stopTrackedProxy?: () => Promise<unknown>;
@@ -245,6 +251,8 @@ export async function prepareServiceInstall(
245
251
  requestedBackend: ServiceBackend,
246
252
  deps: ServiceInstallPreparationDeps = {},
247
253
  ): Promise<void> {
254
+ const latch = deps.supervisionLatch ?? createSupervisionLatch();
255
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
248
256
  const diagnostic = (deps.diagnose ?? diagnoseService)();
249
257
  const platform = deps.platform ?? process.platform;
250
258
  const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
@@ -276,27 +284,35 @@ export async function prepareServiceInstall(
276
284
 
277
285
  export async function installServiceSafely(
278
286
  requestedBackend: ServiceBackend,
279
- install: () => void | Promise<void>,
287
+ install: (runtime: DurableBunRuntime) => void | Promise<void>,
280
288
  deps: ServiceInstallPreparationDeps = {},
281
289
  ): Promise<void> {
282
- await prepareServiceInstall(requestedBackend, deps);
283
- await install();
290
+ const latch = deps.supervisionLatch ?? createSupervisionLatch();
291
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
292
+ const runtime = Object.freeze({ ...(deps.selectRuntime ?? durableBunRuntime)() });
293
+ const configDir = (deps.configDir ?? getConfigDir)();
294
+ (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: !existsSync(configDir) });
295
+ await prepareServiceInstall(requestedBackend, { ...deps, supervisionLatch: latch });
296
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
297
+ await install(runtime);
284
298
  }
285
299
 
286
- export interface FreshWindowsSchedulerInstallDeps {
300
+ export interface FreshWindowsSchedulerInstallDeps extends RuntimePreflightDeps {
301
+ inspectSupervision?: SupervisionInspector;
302
+ supervisionLatch?: SupervisionLatch;
287
303
  stageRegistrationXml?: (attemptNonce: string) => string;
288
304
  register?: (xmlPath: string, attemptNonce: string) => Promise<void>;
289
305
  recordOwnership?: () => boolean;
290
306
  prepare?: () => Promise<void>;
291
307
  removeNativeService?: () => void;
292
- publishAssets?: () => void;
308
+ publishAssets?: (runtime: DurableBunRuntime) => void;
293
309
  verifyBeforeRun?: (attemptNonce: string) => void | Promise<void>;
294
310
  /** Reads the newly registered task; empty or throwing reads are retried before rollback. */
295
311
  readSchedulerXml?: () => string;
296
312
  /** Bounded wait before retrying an unreadable fresh-install registration. */
297
313
  settleSchedulerRead?: (delayMs: number) => void | Promise<void>;
298
314
  runTask?: () => void;
299
- writeState?: () => void;
315
+ writeState?: (runtime: DurableBunRuntime) => void;
300
316
  rollbackTask?: (attemptNonce: string) => Promise<string | null>;
301
317
  removeStagedXml?: (xmlPath: string) => void;
302
318
  }
@@ -312,10 +328,14 @@ export interface FreshWindowsSchedulerInstallDeps {
312
328
  export async function installFreshWindowsSchedulerSafely(
313
329
  deps: FreshWindowsSchedulerInstallDeps = {},
314
330
  ): Promise<void> {
331
+ const latch = deps.supervisionLatch ?? createSupervisionLatch();
332
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
315
333
  const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
316
334
  const register = deps.register ?? registerFreshWindowsSchedulerTask;
317
335
  const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership;
318
- const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
336
+ const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler", {
337
+ inspectSupervision: deps.inspectSupervision, supervisionLatch: latch,
338
+ }));
319
339
  const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
320
340
  const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
321
341
  const verifyBeforeRun = deps.verifyBeforeRun ?? ((nonce: string) => (
@@ -331,7 +351,7 @@ export async function installFreshWindowsSchedulerSafely(
331
351
  )
332
352
  ));
333
353
  const runTask = deps.runTask ?? startWindows;
334
- const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
354
+ const writeState = deps.writeState ?? ((runtime: DurableBunRuntime) => writeServiceInstallState("scheduler", undefined, {}, runtime));
335
355
  const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
336
356
  rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)
337
357
  ));
@@ -341,13 +361,16 @@ export async function installFreshWindowsSchedulerSafely(
341
361
 
342
362
  let stagedXml: string | null = null;
343
363
  const attemptNonce = randomUUID();
344
- const configRootWasAbsent = !existsSync(getConfigDir());
364
+ const configDir = (deps.configDir ?? getConfigDir)();
365
+ const configRootWasAbsent = !existsSync(configDir);
366
+ const runtime = Object.freeze({ ...(deps.selectRuntime ?? durableBunRuntime)() });
345
367
  let registered = false;
346
368
  let started = false;
347
369
  try {
348
370
  stagedXml = stage(attemptNonce);
349
371
  await register(stagedXml, attemptNonce);
350
372
  registered = true;
373
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
351
374
 
352
375
  // The destructive boundary begins only after Task Scheduler accepted the definition.
353
376
  // The registration has consumed its temporary XML. Remove it before claiming a newly
@@ -361,13 +384,16 @@ export async function installFreshWindowsSchedulerSafely(
361
384
  + "aborting before service-manager cleanup or asset publication.",
362
385
  );
363
386
  }
387
+ (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: false });
364
388
  await prepare();
389
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
365
390
  removeNativeService();
366
- publishAssets();
391
+ publishAssets(runtime);
367
392
  await verifyBeforeRun(attemptNonce);
393
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
368
394
  runTask();
369
395
  started = true;
370
- writeState();
396
+ writeState(runtime);
371
397
  } catch (error) {
372
398
  const detail = error instanceof Error ? error.message : String(error);
373
399
  if (registered && !started) {
@@ -1,3 +1,7 @@
1
+ import { existsSync } from "node:fs";
2
+ import { getConfigDir } from "../config";
3
+ import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
4
+ import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight";
1
5
  import { randomUUID } from "node:crypto";
2
6
  import { defaultWinswEntry, installWinswService } from "../lib/winsw";
3
7
  import { resolveWindowsTaskDiagnosticUserId, diagnoseService } from "./diagnostics";
@@ -14,6 +18,7 @@ import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
14
18
  import { taskXmlSection, taskXmlWithoutCommentsAndCdata, taskXmlElementCount, taskXmlOptionalValueEquals, windowsTaskRegistrationOwnedByAttempt, windowsTaskHasSessionRecoveryTriggers, windowsTaskRegistrationHealthy, windowsTaskRegistrationRefreshableLegacy } from "./windows-taskxml";
15
19
  import type { ExpectedWindowsTaskUserId } from "./windows-taskxml";
16
20
  import { win32 } from "node:path";
21
+ import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionInspector, type SupervisionLatch } from "./desktop-command-guard";
17
22
 
18
23
  /**
19
24
  * The two CLI verbs `repairService` serves. They differ on ONE platform and ONE case: a
@@ -22,16 +27,18 @@ import { win32 } from "node:path";
22
27
  */
23
28
  export type ServiceRepairVerb = "repair" | "restart";
24
29
 
25
- export interface RepairServiceDeps {
30
+ export interface RepairServiceDeps extends RuntimePreflightDeps {
31
+ inspectSupervision?: SupervisionInspector;
32
+ supervisionLatch?: SupervisionLatch;
26
33
  diagnose?: () => ServiceDiagnostic;
27
34
  assertEnv?: () => void;
28
35
  assertAuth?: () => void;
29
- writeSchedulerAssets?: () => void;
36
+ writeSchedulerAssets?: (runtime: DurableBunRuntime) => void;
30
37
  stopScheduler?: () => void;
31
38
  startScheduler?: () => void;
32
- writeSchedulerState?: () => void;
33
- writeNativeState?: () => void;
34
- repairNative?: () => void | Promise<void>;
39
+ writeSchedulerState?: (runtime: DurableBunRuntime) => void;
40
+ writeNativeState?: (runtime: DurableBunRuntime) => void;
41
+ repairNative?: (runtime: DurableBunRuntime) => void | Promise<void>;
35
42
  repairLaunchd?: () => LaunchdInstallOutcome | void;
36
43
  repairSystemd?: () => void;
37
44
  /** Restarts a launchd job the install path deliberately left alone. `restart` only. */
@@ -148,6 +155,8 @@ export async function assertSchedulerRegistrationBeforeStart(
148
155
  * macOS/Linux: re-run the user-level install/reload path.
149
156
  */
150
157
  export async function repairService(deps: RepairServiceDeps = {}): Promise<void> {
158
+ const latch = deps.supervisionLatch ?? createSupervisionLatch();
159
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
151
160
  const diagnose = deps.diagnose ?? diagnoseService;
152
161
  const platform = deps.platform ?? process.platform;
153
162
  const diag = diagnose();
@@ -176,14 +185,18 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
176
185
 
177
186
  (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
178
187
  (deps.assertAuth ?? assertServiceAuthEnvironment)();
188
+ const runtime = Object.freeze({ ...(deps.selectRuntime ?? durableBunRuntime)() });
189
+ const configDir = (deps.configDir ?? getConfigDir)();
190
+ (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform, rootWasAbsent: !existsSync(configDir) });
179
191
 
180
192
  const temporaryLauncher = (deps.launcherPathDiagnostic ?? (() => serviceLauncherPathDiagnostic(undefined, platform, true)))();
181
193
  if (temporaryLauncher) console.warn(`⚠️ ${temporaryLauncher}`);
182
194
 
183
195
  if (platform === "win32") {
184
196
  if (diag.backend === "native") {
185
- await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(serviceSourceDir))))();
186
- (deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
197
+ await (deps.repairNative ?? ((selected: DurableBunRuntime) => installWinswService(defaultWinswEntry(serviceSourceDir, selected))))(runtime);
198
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
199
+ (deps.writeNativeState ?? ((selected: DurableBunRuntime) => writeServiceInstallState("native", undefined, {}, selected)))(runtime);
187
200
  return;
188
201
  }
189
202
  const readSchedulerXml = deps.readSchedulerXml ?? statusWindowsXml;
@@ -243,7 +256,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
243
256
  );
244
257
  }
245
258
  try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
246
- (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
259
+ (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(runtime);
247
260
  // Rewriting the on-disk assets does not touch the definition Task Scheduler holds, so a
248
261
  // task registered by an older version keeps its old triggers forever: status reports it
249
262
  // stale, tells the user to run repair, and repair changes nothing it complains about.
@@ -311,6 +324,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
311
324
  }
312
325
  if (probe.status === "absent") {
313
326
  try {
327
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
314
328
  await (deps.restoreSchedulerIfAbsent ?? restoreWindowsSchedulerTaskIfAbsent)(registeredXml);
315
329
  restartExpectedXml = registeredXml;
316
330
  } catch (error) {
@@ -333,6 +347,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
333
347
  "The Task Scheduler registration changed again before restart; the newer definition was preserved and not started.",
334
348
  "Task Scheduler state remained unreadable before restart; the registration was preserved and not started.",
335
349
  );
350
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
336
351
  (deps.startScheduler ?? startWindows)();
337
352
  } catch (error) {
338
353
  recoveryErrors.push(error);
@@ -358,8 +373,9 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
358
373
  "Task Scheduler registration changed before restart; the current definition was preserved and not started.",
359
374
  "Task Scheduler registration became unreadable before restart; it was preserved and not started.",
360
375
  );
376
+ assertNoDesktopSupervision(deps.inspectSupervision, latch);
361
377
  (deps.startScheduler ?? startWindows)();
362
- (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
378
+ (deps.writeSchedulerState ?? ((selected: DurableBunRuntime) => writeServiceInstallState("scheduler", undefined, {}, selected)))(runtime);
363
379
  return;
364
380
  }
365
381
  if (platform === "darwin") {
@@ -360,8 +360,8 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState |
360
360
  * it describes the install that just ran. {@link ServiceInstallState.ownership} deliberately
361
361
  * is not part of it.
362
362
  */
363
- function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string | null): ServiceInstallState {
364
- const { bun, cli } = cliEntry();
363
+ function installProvenanceRecord(backend: ServiceBackend, launcherPath: string | null | undefined, runtime: DurableBunRuntime): ServiceInstallState {
364
+ const { bun, cli } = cliEntry(runtime);
365
365
  const codexHome = currentCodexHome();
366
366
  return {
367
367
  version: 2,
@@ -391,9 +391,10 @@ export function writeServiceInstallState(
391
391
  backend: ServiceBackend = "scheduler",
392
392
  launcherPath?: string | null,
393
393
  deps: ServiceStateSwapDeps = {},
394
+ runtime: DurableBunRuntime = durableBunRuntime(),
394
395
  ): void {
395
396
  swapServiceInstallState(current => ({
396
- ...installProvenanceRecord(backend, launcherPath),
397
+ ...installProvenanceRecord(backend, launcherPath, runtime),
397
398
  ...preservedConsent(current),
398
399
  }), deps);
399
400
  }
@@ -102,13 +102,22 @@ WantedBy=default.target
102
102
 
103
103
  /** The per-user runtime dir systemd creates (holds the user-bus socket), or null. */
104
104
  function userRuntimeDir(): string | null {
105
- const fromEnv = process.env.XDG_RUNTIME_DIR;
106
- if (fromEnv && existsSync(fromEnv)) return fromEnv;
107
- if (typeof process.getuid === "function") {
108
- const candidate = `/run/user/${process.getuid()}`;
109
- if (existsSync(candidate)) return candidate;
105
+ const env = { ...process.env };
106
+ if (env.XDG_RUNTIME_DIR && !existsSync(env.XDG_RUNTIME_DIR)) delete env.XDG_RUNTIME_DIR;
107
+ return systemdUserBusEnvironment(env).XDG_RUNTIME_DIR || null;
108
+ }
109
+
110
+ /** Return explicit SSH user-bus discovery without changing the caller's environment. */
111
+ export function systemdUserBusEnvironment(environment: NodeJS.ProcessEnv = process.env, deps: {
112
+ uid?: number; exists?: (path: string) => boolean;
113
+ } = {}): NodeJS.ProcessEnv {
114
+ const env = { ...environment };
115
+ if (!env.XDG_RUNTIME_DIR) {
116
+ const uid = deps.uid ?? process.getuid?.();
117
+ const candidate = uid === undefined ? null : `/run/user/${uid}`;
118
+ if (candidate && (deps.exists ?? existsSync)(candidate)) env.XDG_RUNTIME_DIR = candidate;
110
119
  }
111
- return null;
120
+ return env;
112
121
  }
113
122
 
114
123
  /**
@@ -119,8 +128,8 @@ function userRuntimeDir(): string | null {
119
128
  */
120
129
  function ensureUserBusEnv(): void {
121
130
  if (process.env.XDG_RUNTIME_DIR) return;
122
- const dir = userRuntimeDir();
123
- if (dir) process.env.XDG_RUNTIME_DIR = dir;
131
+ const env = systemdUserBusEnvironment();
132
+ if (env.XDG_RUNTIME_DIR) process.env.XDG_RUNTIME_DIR = env.XDG_RUNTIME_DIR;
124
133
  }
125
134
 
126
135
  export function isSystemd(): boolean {
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { win32 } from "node:path";
4
4
  import { winswXmlPath } from "../lib/winsw";
5
5
  import { hardenSecretPath } from "../lib/windows-secret-acl";
6
+ import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
7
+ import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight";
6
8
  import { parseBakedListenPort } from "./health";
7
9
  import { windowsServiceScriptPath } from "./state";
8
10
  import { execFileSync } from "node:child_process";
@@ -18,7 +20,7 @@ import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers";
18
20
  import { isTestHomeGuardArmed } from "../lib/test-home-guard";
19
21
  import { writeServiceApiTokenFile } from "./guards";
20
22
  import type { ServiceStopOutcome } from "./orchestration";
21
- import { TASK, windowsLauncherVbsPath, windowsTaskXmlPath, serviceStatePath, writeServiceInstallState, serviceSourceDir } from "./state";
23
+ import { TASK, windowsLauncherVbsPath, windowsTaskXmlPath, serviceStatePath, writeServiceInstallState, serviceSourceDir, cliEntry } from "./state";
22
24
  import { decodeSchtasksOutput, querySchtasks, schtasks, type WindowsSchedulerTaskProbe, schtasksErrorDetail, probeWindowsSchedulerTask, rollbackWindowsSchedulerTaskOwnedByAttempt } from "./windows-scheduler";
23
25
  import { buildWindowsServiceScript, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsLauncherVbs, resolvedWindowsTaskSid, buildWindowsTaskXmlDocument, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml";
24
26
 
@@ -108,11 +110,11 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
108
110
  * Rewrite on-disk scheduler assets (script/VBS/XML) without itself registering the task.
109
111
  * Fresh install creates it afterwards; repair does so only when the live definition is stale.
110
112
  */
111
- export function writeWindowsSchedulerAssets(): void {
113
+ export function writeWindowsSchedulerAssets(runtime: DurableBunRuntime = durableBunRuntime()): void {
112
114
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
113
115
  writeServiceApiTokenFile();
114
116
  const script = windowsServiceScriptPath();
115
- writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
117
+ writeServiceAssetWithRetry(script, buildWindowsServiceScript(cliEntry(runtime)), "utf8");
116
118
  // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
117
119
  // paths on some WSH/codepage combinations — same contract as the task XML below.
118
120
  writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
@@ -597,16 +599,40 @@ export function removeNativeWindowsServiceForScheduler(
597
599
  }
598
600
  }
599
601
 
600
- export function installWindows(): void {
601
- recordWindowsSchedulerOwnership();
602
- removeNativeWindowsServiceForScheduler();
602
+ /** External Windows effects shared by the gated entrypoints and admitted commit paths. */
603
+ export interface WindowsServiceInstallDeps extends RuntimePreflightDeps {
604
+ recordSchedulerOwnership?: () => boolean;
605
+ removeNativeService?: () => void;
606
+ stopScheduler?: () => void;
607
+ writeSchedulerAssets?: (runtime: DurableBunRuntime) => void;
608
+ schedulerCommand?: typeof schtasks;
609
+ writeState?: (backend: "scheduler" | "native", runtime: DurableBunRuntime) => void;
610
+ assertNativeAccount?: () => void;
611
+ prepareNativeConfig?: () => void;
612
+ uninstallScheduler?: () => void;
613
+ installNativeService?: typeof installWinswService;
614
+ }
615
+
616
+ export function installWindows(selected?: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): void {
617
+ const runtime = Object.freeze({ ...(selected ?? (deps.selectRuntime ?? durableBunRuntime)()) });
618
+ const configDir = (deps.configDir ?? getConfigDir)();
619
+ (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: !existsSync(configDir) });
620
+ commitWindowsInstall(runtime, deps);
621
+ }
622
+
623
+ /** Internal commit boundary: the caller has admitted this frozen runtime before cleanup. */
624
+ export function commitWindowsInstall(runtime: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): void {
625
+ (deps.recordSchedulerOwnership ?? recordWindowsSchedulerOwnership)();
626
+ (deps.removeNativeService ?? removeNativeWindowsServiceForScheduler)();
603
627
  // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
604
628
  // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
605
- try { stopWindows(); } catch { /* not running */ }
606
- writeWindowsSchedulerAssets();
607
- schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
608
- schtasks(["/run", "/tn", TASK]);
609
- writeServiceInstallState("scheduler");
629
+ try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
630
+ (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(runtime);
631
+ const command = deps.schedulerCommand ?? schtasks;
632
+ command(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
633
+ command(["/run", "/tn", TASK]);
634
+ if (deps.writeState) deps.writeState("scheduler", runtime);
635
+ else writeServiceInstallState("scheduler", undefined, {}, runtime);
610
636
  }
611
637
 
612
638
  /**
@@ -748,26 +774,38 @@ function readWindowsPrincipalSource(): string | null {
748
774
  }
749
775
  }
750
776
 
751
- export async function installWindowsNative(): Promise<void> {
752
- assertWindowsNativeServiceAccountSupported();
753
- recordOwnedConfigPath(getConfigDir(), serviceStatePath());
754
- if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
755
- writeServiceApiTokenFile();
777
+ export async function installWindowsNative(selected?: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): Promise<void> {
778
+ const runtime = Object.freeze({ ...(selected ?? (deps.selectRuntime ?? durableBunRuntime)()) });
779
+ const configDir = (deps.configDir ?? getConfigDir)();
780
+ (deps.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { platform: deps.platform, rootWasAbsent: !existsSync(configDir) });
781
+ await commitWindowsNativeInstall(runtime, deps);
782
+ }
783
+
784
+ /** Internal commit boundary; direct native installs go through the public admission gate. */
785
+ export async function commitWindowsNativeInstall(runtime: DurableBunRuntime, deps: WindowsServiceInstallDeps = {}): Promise<void> {
786
+ (deps.assertNativeAccount ?? assertWindowsNativeServiceAccountSupported)();
787
+ if (deps.prepareNativeConfig) deps.prepareNativeConfig();
788
+ else {
789
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
790
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
791
+ writeServiceApiTokenFile();
792
+ }
793
+ const command = deps.schedulerCommand ?? schtasks;
756
794
  let hadScheduler = false;
757
795
  try {
758
- hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK);
796
+ hadScheduler = command(["/query", "/tn", TASK]).includes(TASK);
759
797
  } catch { /* task absent */ }
760
798
  if (hadScheduler) {
761
799
  console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service...");
762
- try { stopWindows(); } catch { /* not running */ }
800
+ try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
763
801
  try {
764
- uninstallWindows();
802
+ (deps.uninstallScheduler ?? uninstallWindows)();
765
803
  } catch (err) {
766
804
  throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`);
767
805
  }
768
806
  // Verify removal — schtasks /delete can silently fail if UAC or policy blocks it.
769
807
  try {
770
- if (schtasks(["/query", "/tn", TASK]).includes(TASK)) {
808
+ if (command(["/query", "/tn", TASK]).includes(TASK)) {
771
809
  throw new Error("Task Scheduler backend still present after removal — aborting switch.");
772
810
  }
773
811
  } catch (e) {
@@ -776,12 +814,13 @@ export async function installWindowsNative(): Promise<void> {
776
814
  }
777
815
  }
778
816
  try {
779
- await installWinswService(defaultWinswEntry(serviceSourceDir));
817
+ await (deps.installNativeService ?? installWinswService)(defaultWinswEntry(serviceSourceDir, runtime));
780
818
  } catch (err) {
781
819
  if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`.");
782
820
  throw err;
783
821
  }
784
- writeServiceInstallState("native");
822
+ if (deps.writeState) deps.writeState("native", runtime);
823
+ else writeServiceInstallState("native", undefined, {}, runtime);
785
824
  }
786
825
 
787
826
  export function startWindows(): void { schtasks(["/run", "/tn", TASK]); }