@iislee/opencodex 2.11.0 → 2.35.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 (733) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +117 -22
  3. package/bin/ocx.mjs +245 -88
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  6. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +21 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +475 -62
  44. package/src/adapters/base.ts +82 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +637 -0
  48. package/src/adapters/cursor/call-id.ts +44 -0
  49. package/src/adapters/cursor/catalog.ts +541 -0
  50. package/src/adapters/cursor/checkpoint-store.ts +308 -0
  51. package/src/adapters/cursor/cursor-errors.ts +144 -6
  52. package/src/adapters/cursor/discovery.ts +122 -14
  53. package/src/adapters/cursor/effort-map.ts +106 -4
  54. package/src/adapters/cursor/envelope-echo.ts +290 -0
  55. package/src/adapters/cursor/framing.ts +39 -0
  56. package/src/adapters/cursor/h2-pool.ts +123 -0
  57. package/src/adapters/cursor/http1-bidi.ts +361 -0
  58. package/src/adapters/cursor/images.ts +704 -0
  59. package/src/adapters/cursor/live-models.ts +180 -59
  60. package/src/adapters/cursor/live-transport.ts +623 -170
  61. package/src/adapters/cursor/message-mapper.ts +4 -1
  62. package/src/adapters/cursor/native-exec-common.ts +23 -2
  63. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  64. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  65. package/src/adapters/cursor/native-exec-network.ts +1 -1
  66. package/src/adapters/cursor/native-exec-shell.ts +5 -3
  67. package/src/adapters/cursor/native-exec.ts +121 -14
  68. package/src/adapters/cursor/protobuf-events.ts +829 -11
  69. package/src/adapters/cursor/protobuf-request.ts +447 -75
  70. package/src/adapters/cursor/request-builder.ts +271 -35
  71. package/src/adapters/cursor/tool-definitions.ts +252 -12
  72. package/src/adapters/cursor/tool-result-normalize.ts +115 -0
  73. package/src/adapters/cursor/transport.ts +22 -0
  74. package/src/adapters/cursor/types.ts +48 -1
  75. package/src/adapters/cursor.ts +336 -39
  76. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  77. package/src/adapters/google-antigravity-replay.ts +713 -41
  78. package/src/adapters/google-antigravity-wire.ts +48 -10
  79. package/src/adapters/google-errors.ts +44 -12
  80. package/src/adapters/google-http.ts +38 -10
  81. package/src/adapters/google-tool-schema.ts +80 -15
  82. package/src/adapters/google-truncation.ts +11 -0
  83. package/src/adapters/google.ts +742 -86
  84. package/src/adapters/identity.ts +39 -6
  85. package/src/adapters/image.ts +1 -1
  86. package/src/adapters/kiro-constants.ts +27 -0
  87. package/src/adapters/kiro-errors.ts +11 -0
  88. package/src/adapters/kiro-events.ts +19 -1
  89. package/src/adapters/kiro-thinking.ts +18 -2
  90. package/src/adapters/kiro-tools.ts +65 -17
  91. package/src/adapters/kiro.ts +270 -73
  92. package/src/adapters/mimo-free.ts +17 -0
  93. package/src/adapters/openai-chat-url.ts +11 -0
  94. package/src/adapters/openai-chat.ts +1377 -344
  95. package/src/adapters/openai-responses-url.ts +16 -0
  96. package/src/adapters/openai-responses.ts +1164 -64
  97. package/src/adapters/registry.ts +175 -0
  98. package/src/adapters/responses-tool-schema.ts +67 -0
  99. package/src/adapters/run-turn-queue.ts +36 -1
  100. package/src/adapters/tool-call-id.ts +119 -0
  101. package/src/adapters/tool-catalog-nudge.ts +105 -21
  102. package/src/adapters/xai-tool-schema.ts +436 -0
  103. package/src/adapters/xai-web-search.ts +186 -0
  104. package/src/bridge.ts +512 -92
  105. package/src/chat/inbound.ts +57 -20
  106. package/src/chat/outbound.ts +97 -33
  107. package/src/claude/agents-inject.ts +40 -10
  108. package/src/claude/context-windows.ts +37 -14
  109. package/src/claude/desktop-3p.ts +243 -9
  110. package/src/claude/gateway-cache.ts +41 -4
  111. package/src/claude/inbound.ts +72 -3
  112. package/src/claude/model-info.ts +38 -15
  113. package/src/claude/outbound.ts +88 -21
  114. package/src/cli/access.ts +46 -3
  115. package/src/cli/account-api.ts +150 -19
  116. package/src/cli/account-auth.ts +33 -6
  117. package/src/cli/account-catalog-refresh.ts +14 -0
  118. package/src/cli/account-extended.ts +648 -28
  119. package/src/cli/account-main.ts +317 -0
  120. package/src/cli/account.ts +97 -19
  121. package/src/cli/agent.ts +53 -2
  122. package/src/cli/alias.ts +66 -0
  123. package/src/cli/capabilities-command.ts +94 -0
  124. package/src/cli/capabilities.ts +496 -0
  125. package/src/cli/claude-agent-startup-sync.ts +73 -0
  126. package/src/cli/claude-desktop.ts +60 -15
  127. package/src/cli/claude.ts +129 -35
  128. package/src/cli/codex-log-guard-doctor.ts +103 -0
  129. package/src/cli/codex-shim-autorestore.ts +2 -0
  130. package/src/cli/codex-shim-readiness.ts +76 -0
  131. package/src/cli/combo.ts +8 -0
  132. package/src/cli/config-command.ts +74 -10
  133. package/src/cli/dispatch.ts +821 -0
  134. package/src/cli/doctor.ts +440 -56
  135. package/src/cli/ensure-desired-integrations.ts +152 -0
  136. package/src/cli/export-command.ts +46 -20
  137. package/src/cli/help.ts +34 -274
  138. package/src/cli/index.ts +417 -528
  139. package/src/cli/init.ts +5 -17
  140. package/src/cli/inspect.ts +230 -0
  141. package/src/cli/integrations.ts +120 -2
  142. package/src/cli/lab.ts +607 -0
  143. package/src/cli/launcher-context.ts +77 -0
  144. package/src/cli/minimax.ts +497 -0
  145. package/src/cli/models-runtime.ts +130 -2
  146. package/src/cli/models.ts +118 -16
  147. package/src/cli/observe.ts +123 -11
  148. package/src/cli/opencode.ts +4 -2
  149. package/src/cli/provider-runtime.ts +34 -3
  150. package/src/cli/provider.ts +25 -3
  151. package/src/cli/ready.ts +301 -0
  152. package/src/cli/registry.ts +476 -0
  153. package/src/cli/root.ts +86 -0
  154. package/src/cli/route-policy.ts +92 -0
  155. package/src/cli/runtime-api.ts +57 -10
  156. package/src/cli/star-prompt.ts +71 -15
  157. package/src/cli/status.ts +51 -4
  158. package/src/cli/storage.ts +234 -0
  159. package/src/cli/system-command.ts +16 -0
  160. package/src/cli/system-restart-client.ts +146 -0
  161. package/src/cli/tray-proxy.ts +153 -6
  162. package/src/cli/usage-report.ts +184 -0
  163. package/src/cli/v2.ts +137 -18
  164. package/src/cli/version-skew.ts +46 -0
  165. package/src/cli.ts +1 -1
  166. package/src/clients/config-export.ts +1442 -23
  167. package/src/clients/effective-status.ts +30 -2
  168. package/src/clients/probes/cline.ts +81 -0
  169. package/src/codex/account-label.ts +35 -1
  170. package/src/codex/account-lifecycle.ts +130 -13
  171. package/src/codex/account-namespaces.ts +49 -3
  172. package/src/codex/account-pause.ts +2 -1
  173. package/src/codex/account-priority.ts +84 -0
  174. package/src/codex/account-store.ts +29 -2
  175. package/src/codex/account-usability.ts +25 -2
  176. package/src/codex/admission.ts +256 -0
  177. package/src/codex/affinity-debug.ts +162 -0
  178. package/src/codex/app-server-processes.ts +570 -107
  179. package/src/codex/app-server-restart-service.ts +232 -0
  180. package/src/codex/auth-api.ts +893 -246
  181. package/src/codex/auth-collision.ts +5 -3
  182. package/src/codex/auth-context.ts +348 -32
  183. package/src/codex/autostart-health.ts +24 -1
  184. package/src/codex/catalog/account-models.ts +67 -0
  185. package/src/codex/catalog/aggregation.ts +86 -10
  186. package/src/codex/catalog/bundled.ts +331 -33
  187. package/src/codex/catalog/effort.ts +138 -32
  188. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  189. package/src/codex/catalog/kinds.ts +2 -0
  190. package/src/codex/catalog/metadata.ts +555 -45
  191. package/src/codex/catalog/native-models.ts +75 -0
  192. package/src/codex/catalog/parsing.ts +269 -30
  193. package/src/codex/catalog/provider-fetch.ts +1626 -144
  194. package/src/codex/catalog/sync.ts +1576 -187
  195. package/src/codex/catalog-admission.ts +199 -0
  196. package/src/codex/catalog-refresh-status.ts +105 -0
  197. package/src/codex/catalog-write-serialization.ts +242 -0
  198. package/src/codex/catalog.ts +6 -3
  199. package/src/codex/codex-write-lock.ts +386 -0
  200. package/src/codex/convergence-types.ts +614 -0
  201. package/src/codex/convergence.ts +676 -0
  202. package/src/codex/coordinator-doctor.ts +332 -0
  203. package/src/codex/custom-model-catalog-migration.ts +176 -0
  204. package/src/codex/desired-state.ts +230 -0
  205. package/src/codex/desktop-app-restart.ts +355 -0
  206. package/src/codex/features.ts +636 -39
  207. package/src/codex/generation.ts +202 -0
  208. package/src/codex/history-job.ts +436 -0
  209. package/src/codex/history-lock.ts +242 -0
  210. package/src/codex/history-manifest.ts +112 -0
  211. package/src/codex/history-migration-guardian.ts +30 -24
  212. package/src/codex/history-provider.ts +1016 -235
  213. package/src/codex/history-transition.ts +105 -0
  214. package/src/codex/history-worker.ts +223 -0
  215. package/src/codex/inject-coordination.ts +373 -0
  216. package/src/codex/inject.ts +1114 -152
  217. package/src/codex/injected-marker.ts +37 -3
  218. package/src/codex/integration-record.ts +266 -0
  219. package/src/codex/internal/catalog-writer.ts +203 -0
  220. package/src/codex/internal/history-writer.ts +81 -0
  221. package/src/codex/journal.ts +66 -4
  222. package/src/codex/log-guard/inspect.ts +524 -0
  223. package/src/codex/log-guard/lock.ts +150 -0
  224. package/src/codex/log-guard/maintenance.ts +403 -0
  225. package/src/codex/log-guard/path-safety.ts +88 -0
  226. package/src/codex/log-guard/policy.ts +44 -0
  227. package/src/codex/log-guard/processes.ts +205 -0
  228. package/src/codex/log-guard/protection.ts +489 -0
  229. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  230. package/src/codex/main-account-cache.ts +24 -0
  231. package/src/codex/main-account.ts +29 -1
  232. package/src/codex/management-convergence.ts +167 -0
  233. package/src/codex/model-cache.ts +56 -10
  234. package/src/codex/model-entitlements.ts +360 -0
  235. package/src/codex/native-main-admission.ts +47 -0
  236. package/src/codex/native-main-auth-temp.ts +187 -0
  237. package/src/codex/native-main-claim.ts +178 -0
  238. package/src/codex/native-main-lock-file.ts +162 -0
  239. package/src/codex/native-main-owner.ts +329 -0
  240. package/src/codex/native-profile-api.ts +247 -0
  241. package/src/codex/native-profile-manager.ts +1531 -0
  242. package/src/codex/native-profile-processes.ts +121 -0
  243. package/src/codex/native-profile-recovery.ts +99 -0
  244. package/src/codex/native-profile-stage-store.ts +387 -0
  245. package/src/codex/native-profile-startup.ts +622 -0
  246. package/src/codex/native-profile-store.ts +855 -0
  247. package/src/codex/native-profile-types.ts +120 -0
  248. package/src/codex/native-residue.ts +675 -0
  249. package/src/codex/paths.ts +80 -1
  250. package/src/codex/plan-from-token.ts +140 -0
  251. package/src/codex/plan.ts +40 -0
  252. package/src/codex/plugins-doctor.ts +1 -1
  253. package/src/codex/pool-rotation.ts +74 -4
  254. package/src/codex/project-config-warnings.ts +20 -6
  255. package/src/codex/prompt-journal.ts +352 -0
  256. package/src/codex/prompt-layers.ts +1313 -0
  257. package/src/codex/prompt-lock.ts +143 -0
  258. package/src/codex/prompt-text-probe.ts +238 -0
  259. package/src/codex/quota-rejection.ts +298 -0
  260. package/src/codex/quota.ts +264 -21
  261. package/src/codex/refresh.ts +11 -2
  262. package/src/codex/reset-credit-recovery.ts +1044 -0
  263. package/src/codex/routing.ts +514 -94
  264. package/src/codex/runtime.ts +159 -38
  265. package/src/codex/shim.ts +1065 -31
  266. package/src/codex/subagent-model-fallback.ts +437 -43
  267. package/src/codex/sync.ts +191 -2
  268. package/src/codex/transition-state.ts +720 -0
  269. package/src/codex/upstream-host-health.ts +368 -0
  270. package/src/codex/user-identity.ts +557 -0
  271. package/src/codex/warmup.ts +187 -81
  272. package/src/codex/write-coordination.ts +114 -0
  273. package/src/combos/failover.ts +47 -0
  274. package/src/combos/index.ts +4 -0
  275. package/src/combos/request.ts +32 -0
  276. package/src/combos/types.ts +91 -9
  277. package/src/compatibility/index.ts +26 -0
  278. package/src/compatibility/manifest.ts +253 -0
  279. package/src/compatibility/openai-responses.ts +121 -0
  280. package/src/config/atomic-write.ts +219 -0
  281. package/src/config/paths.ts +40 -0
  282. package/src/config/process-state.ts +309 -0
  283. package/src/config/provider-name.ts +24 -0
  284. package/src/config/provider-validation.ts +177 -0
  285. package/src/config/rebase-provenance.ts +68 -0
  286. package/src/config.ts +1713 -709
  287. package/src/generated/compatibility-version.json +3324 -0
  288. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  289. package/src/grok/inject.ts +16 -5
  290. package/src/grok/inspect.ts +45 -0
  291. package/src/grok/sync.ts +2 -2
  292. package/src/images/loop.ts +157 -33
  293. package/src/images/plan.ts +24 -13
  294. package/src/integrations/config-io.ts +269 -0
  295. package/src/integrations/journal.ts +315 -0
  296. package/src/integrations/merge.ts +135 -0
  297. package/src/integrations/mutation-flight.ts +71 -0
  298. package/src/integrations/native/ownership-preflight.ts +202 -0
  299. package/src/integrations/omp-yaml-source.ts +358 -0
  300. package/src/integrations/owned-refresh.ts +74 -0
  301. package/src/integrations/ownership-policy.ts +160 -0
  302. package/src/integrations/ownership.ts +155 -0
  303. package/src/integrations/registry.ts +166 -0
  304. package/src/integrations/serialize.ts +314 -0
  305. package/src/integrations/state.ts +433 -0
  306. package/src/integrations/store.ts +103 -0
  307. package/src/integrations/writer-lock.ts +98 -0
  308. package/src/integrations/writer.ts +715 -0
  309. package/src/lab/artifacts/sanitize.ts +586 -0
  310. package/src/lab/artifacts/secure-fs.ts +475 -0
  311. package/src/lab/artifacts/store.ts +310 -0
  312. package/src/lab/automation/budgets.ts +78 -0
  313. package/src/lab/automation/config-persistence.ts +256 -0
  314. package/src/lab/automation/constants.ts +39 -0
  315. package/src/lab/automation/cooldown.ts +103 -0
  316. package/src/lab/automation/dispatch.ts +211 -0
  317. package/src/lab/automation/index.ts +13 -0
  318. package/src/lab/automation/orchestrator.ts +499 -0
  319. package/src/lab/automation/persistence.ts +512 -0
  320. package/src/lab/automation/planner.ts +371 -0
  321. package/src/lab/automation/policy.ts +136 -0
  322. package/src/lab/automation/queue.ts +191 -0
  323. package/src/lab/automation/recovery.ts +24 -0
  324. package/src/lab/automation/route-context.ts +21 -0
  325. package/src/lab/automation/run-key.ts +44 -0
  326. package/src/lab/automation/runs-query.ts +34 -0
  327. package/src/lab/automation/types.ts +160 -0
  328. package/src/lab/conformance/assertion.ts +325 -0
  329. package/src/lab/conformance/digest.ts +22 -0
  330. package/src/lab/conformance/executor.ts +741 -0
  331. package/src/lab/conformance/fixture-provider.ts +27 -0
  332. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  333. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  334. package/src/lab/conformance/harness-budget.ts +47 -0
  335. package/src/lab/conformance/index.ts +5 -0
  336. package/src/lab/conformance/jcs.ts +64 -0
  337. package/src/lab/conformance/json-pointer.ts +39 -0
  338. package/src/lab/conformance/manifest.ts +180 -0
  339. package/src/lab/conformance/mcp-stub.ts +179 -0
  340. package/src/lab/conformance/negative-controls.ts +164 -0
  341. package/src/lab/conformance/observation.ts +355 -0
  342. package/src/lab/conformance/runner.ts +68 -0
  343. package/src/lab/conformance/sse-normalize.ts +59 -0
  344. package/src/lab/conformance/suite-manifest.ts +78 -0
  345. package/src/lab/conformance/types.ts +214 -0
  346. package/src/lab/constants.ts +126 -0
  347. package/src/lab/digest.ts +64 -0
  348. package/src/lab/events/errors.ts +9 -0
  349. package/src/lab/events/limits.ts +117 -0
  350. package/src/lab/events/types.ts +229 -0
  351. package/src/lab/events/validate.ts +781 -0
  352. package/src/lab/fabric/constants.ts +40 -0
  353. package/src/lab/fabric/executor.ts +492 -0
  354. package/src/lab/fabric/index.ts +80 -0
  355. package/src/lab/fabric/manifest.ts +222 -0
  356. package/src/lab/fabric/observe.ts +489 -0
  357. package/src/lab/fabric/patch.ts +79 -0
  358. package/src/lab/fabric/producer-child.ts +139 -0
  359. package/src/lab/fabric/producer-isolate.ts +276 -0
  360. package/src/lab/fabric/producer-protocol.ts +61 -0
  361. package/src/lab/fabric/scratch.ts +439 -0
  362. package/src/lab/fabric/subject.ts +106 -0
  363. package/src/lab/fabric/types.ts +134 -0
  364. package/src/lab/fabric/verifier.ts +98 -0
  365. package/src/lab/index.ts +54 -0
  366. package/src/lab/ledger/artifact-refs.ts +127 -0
  367. package/src/lab/ledger/invalidation.ts +136 -0
  368. package/src/lab/ledger/purge.ts +310 -0
  369. package/src/lab/ledger/store.ts +532 -0
  370. package/src/lab/live/credential-lease.ts +53 -0
  371. package/src/lab/live/destination.ts +155 -0
  372. package/src/lab/live/executor.ts +336 -0
  373. package/src/lab/live/inert-tools.ts +56 -0
  374. package/src/lab/live/manifest.ts +85 -0
  375. package/src/lab/live/mcp-loopback.ts +57 -0
  376. package/src/lab/live/runner.ts +19 -0
  377. package/src/lab/live/sandbox.ts +61 -0
  378. package/src/lab/live/suite-manifest.ts +41 -0
  379. package/src/lab/live/transport.ts +118 -0
  380. package/src/lab/live/types.ts +197 -0
  381. package/src/lab/observe/from-conformance.ts +301 -0
  382. package/src/lab/observe/from-live.ts +117 -0
  383. package/src/lab/paths.ts +153 -0
  384. package/src/lab/projection/rebuild.ts +495 -0
  385. package/src/lab/projection/schema.ts +135 -0
  386. package/src/lab/projection/verdicts.ts +474 -0
  387. package/src/lab/projection/verification.ts +412 -0
  388. package/src/lab/public/bundle.ts +217 -0
  389. package/src/lab/public/community-authority.ts +175 -0
  390. package/src/lab/public/community-files.ts +29 -0
  391. package/src/lab/public/community.ts +479 -0
  392. package/src/lab/public/file-safety.ts +155 -0
  393. package/src/lab/public/ids.ts +26 -0
  394. package/src/lab/public/index.ts +16 -0
  395. package/src/lab/public/mutation-lock.ts +424 -0
  396. package/src/lab/public/operator.ts +353 -0
  397. package/src/lab/public/origin-purge.ts +79 -0
  398. package/src/lab/public/origin.ts +203 -0
  399. package/src/lab/public/privacy.ts +143 -0
  400. package/src/lab/public/private-file.ts +261 -0
  401. package/src/lab/public/project.ts +124 -0
  402. package/src/lab/public/purge-test-fault.ts +21 -0
  403. package/src/lab/public/purge.ts +223 -0
  404. package/src/lab/public/registry.ts +44 -0
  405. package/src/lab/public/revocation.ts +252 -0
  406. package/src/lab/public/signature.ts +243 -0
  407. package/src/lab/public/storage.ts +105 -0
  408. package/src/lab/public/strict-json.ts +206 -0
  409. package/src/lab/public/time.ts +26 -0
  410. package/src/lab/public/types.ts +172 -0
  411. package/src/lab/public/validate.ts +391 -0
  412. package/src/lab/query/catalog.ts +101 -0
  413. package/src/lab/query/connection.ts +107 -0
  414. package/src/lab/query/constants.ts +4 -0
  415. package/src/lab/query/cursor.ts +132 -0
  416. package/src/lab/query/dto-map.ts +277 -0
  417. package/src/lab/query/errors.ts +22 -0
  418. package/src/lab/query/freshness.ts +53 -0
  419. package/src/lab/query/index.ts +45 -0
  420. package/src/lab/query/latest-observation.ts +59 -0
  421. package/src/lab/query/passive-production.ts +159 -0
  422. package/src/lab/query/queries.ts +444 -0
  423. package/src/lab/query/types.ts +266 -0
  424. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  425. package/src/lab/subject/installation-salt.ts +112 -0
  426. package/src/lab/subject/protocol-subject.ts +80 -0
  427. package/src/lab/subject/route-subject.ts +74 -0
  428. package/src/lib/admin-secrets.ts +24 -0
  429. package/src/lib/app-owned-memory-stores.ts +22 -0
  430. package/src/lib/bounded-body.ts +167 -11
  431. package/src/lib/bun-runtime.ts +125 -12
  432. package/src/lib/bun-stream-caps.ts +13 -9
  433. package/src/lib/codex-restart-contract.ts +120 -0
  434. package/src/lib/config-ownership.ts +26 -2
  435. package/src/lib/destination-policy.ts +65 -1
  436. package/src/lib/errors.ts +80 -5
  437. package/src/lib/fabric-task-execution-authority.ts +7 -0
  438. package/src/lib/fabric-task-host.ts +29 -0
  439. package/src/lib/lab-activation.ts +223 -0
  440. package/src/lib/lab-live-execution-authority.ts +13 -0
  441. package/src/lib/lab-live-host.ts +30 -0
  442. package/src/lib/lab-live-pinned-sender.ts +56 -0
  443. package/src/lib/lab-live-route-production.ts +130 -0
  444. package/src/lib/lab-passive-linker-registration.ts +26 -0
  445. package/src/lib/local-management-attestation.ts +51 -0
  446. package/src/lib/local-management-capability.ts +100 -0
  447. package/src/lib/local-provider-reload-contract.ts +100 -0
  448. package/src/lib/optional-shutdown-hooks.ts +57 -0
  449. package/src/lib/package-tree-integrity.ts +101 -0
  450. package/src/lib/pinned-http.ts +145 -26
  451. package/src/lib/process-control.ts +6 -2
  452. package/src/lib/provider-outbound.ts +49 -9
  453. package/src/lib/redact.ts +419 -3
  454. package/src/lib/self-launch-argv.ts +15 -0
  455. package/src/lib/server-resource-ownership.ts +71 -0
  456. package/src/lib/service-secrets.ts +15 -0
  457. package/src/lib/shadow-call.ts +35 -4
  458. package/src/lib/sse-decoder.ts +41 -0
  459. package/src/lib/state-store-registrations.ts +10 -2
  460. package/src/lib/system-restart-contract.ts +73 -0
  461. package/src/lib/token-estimate.ts +19 -2
  462. package/src/lib/tool-argument-integers.ts +253 -0
  463. package/src/lib/translator-budget.ts +44 -0
  464. package/src/lib/upstream-http-version.ts +57 -0
  465. package/src/lib/upstream-reachability.ts +95 -0
  466. package/src/lib/upstream-retry.ts +156 -3
  467. package/src/lib/windows-atomic-replace.ts +155 -0
  468. package/src/lib/windows-elevation.ts +70 -2
  469. package/src/lib/windows-secret-acl.ts +409 -69
  470. package/src/lib/windows-service-wrappers.ts +72 -0
  471. package/src/lib/windows-text.ts +106 -0
  472. package/src/lib/windows-user-principal.ts +341 -0
  473. package/src/lib/winsw.ts +33 -5
  474. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  475. package/src/oauth/account-import/index.ts +15 -0
  476. package/src/oauth/account-import/parser.ts +83 -0
  477. package/src/oauth/account-import/registry.ts +18 -0
  478. package/src/oauth/account-import/service.ts +75 -0
  479. package/src/oauth/account-import/types.ts +91 -0
  480. package/src/oauth/anthropic.ts +12 -1
  481. package/src/oauth/callback-server.ts +30 -4
  482. package/src/oauth/chatgpt.ts +12 -1
  483. package/src/oauth/cline.ts +203 -0
  484. package/src/oauth/command-code.ts +239 -0
  485. package/src/oauth/cursor.ts +46 -5
  486. package/src/oauth/generic-account-failover.ts +231 -0
  487. package/src/oauth/google-antigravity.ts +35 -3
  488. package/src/oauth/health.ts +20 -12
  489. package/src/oauth/index.ts +462 -71
  490. package/src/oauth/key-providers.ts +25 -0
  491. package/src/oauth/kimi.ts +25 -3
  492. package/src/oauth/kiro.ts +95 -6
  493. package/src/oauth/local-token-detect.ts +13 -2
  494. package/src/oauth/log.ts +3 -1
  495. package/src/oauth/login-cli.ts +88 -28
  496. package/src/oauth/nous.ts +798 -0
  497. package/src/oauth/open-browser-choice.ts +26 -0
  498. package/src/oauth/store.ts +133 -26
  499. package/src/oauth/token-guardian.ts +9 -3
  500. package/src/oauth/types.ts +15 -0
  501. package/src/pi/models.ts +2 -2
  502. package/src/providers/alibaba-region-migration.ts +1 -1
  503. package/src/providers/antigravity-models.ts +521 -31
  504. package/src/providers/auto-compact-budget.ts +65 -0
  505. package/src/providers/base-url-choices.ts +10 -0
  506. package/src/providers/codex-capacity.ts +292 -0
  507. package/src/providers/command-code-efforts.ts +176 -0
  508. package/src/providers/context-cap.ts +26 -8
  509. package/src/providers/cursor-pool.ts +72 -0
  510. package/src/providers/default-aliases.ts +65 -0
  511. package/src/providers/derive.ts +281 -6
  512. package/src/providers/fastwire.ts +507 -0
  513. package/src/providers/free-directory.ts +10 -7
  514. package/src/providers/google-vertex-location.ts +14 -0
  515. package/src/providers/key-failover.ts +71 -3
  516. package/src/providers/label.ts +35 -2
  517. package/src/providers/model-discovery-limits.ts +16 -0
  518. package/src/providers/model-discovery.ts +115 -22
  519. package/src/providers/model-presets.ts +119 -0
  520. package/src/providers/model-rename-migration.ts +255 -0
  521. package/src/providers/model-rename-startup.ts +28 -0
  522. package/src/providers/new-model-policy.ts +146 -0
  523. package/src/providers/openai-sidecar.ts +72 -4
  524. package/src/providers/openai-tier-startup.ts +31 -2
  525. package/src/providers/openai-tiers.ts +119 -4
  526. package/src/providers/openai-virtual-models.ts +1 -0
  527. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  528. package/src/providers/provider-id-rewrite.ts +30 -0
  529. package/src/providers/quota.ts +1379 -40
  530. package/src/providers/registry.ts +1545 -112
  531. package/src/providers/request-pacing.ts +310 -0
  532. package/src/providers/service-tier.ts +277 -0
  533. package/src/providers/slug-codec.ts +94 -6
  534. package/src/providers/static-model-discovery.ts +86 -0
  535. package/src/providers/xai-responses-opt-in.ts +15 -0
  536. package/src/providers/xai-transport.ts +32 -4
  537. package/src/reasoning-effort.ts +68 -3
  538. package/src/responses/apply-patch-envelope.ts +63 -0
  539. package/src/responses/code-mode-helper-compat.ts +50 -0
  540. package/src/responses/compaction.ts +26 -1
  541. package/src/responses/custom-tool-compat.ts +384 -0
  542. package/src/responses/hosted-tool-policy.ts +9 -0
  543. package/src/responses/namespace-tool-compat.ts +435 -0
  544. package/src/responses/parser.ts +225 -38
  545. package/src/responses/provider-continuation.ts +98 -0
  546. package/src/responses/provider-opaque-metadata.ts +73 -0
  547. package/src/responses/reasoning-envelope.ts +9 -1
  548. package/src/responses/reasoning-replay-cache.ts +504 -0
  549. package/src/responses/schema.ts +12 -2
  550. package/src/responses/spill-store.ts +75 -10
  551. package/src/responses/state.ts +659 -31
  552. package/src/responses/thought-signature-replay.ts +364 -0
  553. package/src/responses/tool-search-compat.ts +301 -0
  554. package/src/responses/truncated-stop-reason.ts +60 -0
  555. package/src/responses/turn-termination.ts +107 -0
  556. package/src/router.ts +405 -31
  557. package/src/routing/analytics.ts +379 -0
  558. package/src/routing/capability.ts +244 -0
  559. package/src/routing/compatibility/assemble.ts +73 -0
  560. package/src/routing/compatibility/behavior.ts +278 -0
  561. package/src/routing/compatibility/catalog.ts +99 -0
  562. package/src/routing/compatibility/endpoint.ts +52 -0
  563. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  564. package/src/routing/compatibility/policy.ts +181 -0
  565. package/src/routing/compatibility/provider-slot.ts +56 -0
  566. package/src/routing/compatibility/reader.ts +110 -0
  567. package/src/routing/compatibility/subject.ts +191 -0
  568. package/src/routing/compatibility/types.ts +64 -0
  569. package/src/routing/compatibility/version.ts +104 -0
  570. package/src/routing/cost.ts +77 -0
  571. package/src/routing/evaluator.ts +495 -0
  572. package/src/routing/health.ts +412 -0
  573. package/src/routing/history/cursor.ts +43 -0
  574. package/src/routing/history/indexer.ts +605 -0
  575. package/src/routing/history/schema.ts +72 -0
  576. package/src/routing/profile-namespace.ts +15 -0
  577. package/src/routing/profile.ts +547 -0
  578. package/src/routing/quota.ts +155 -0
  579. package/src/routing/request-evidence.ts +45 -0
  580. package/src/routing/trace.ts +776 -0
  581. package/src/server/adapter-resolve.ts +2 -29
  582. package/src/server/auth-cors.ts +289 -48
  583. package/src/server/background-lifecycle.ts +182 -0
  584. package/src/server/chat-completions.ts +154 -72
  585. package/src/server/chat-native-sse.ts +331 -0
  586. package/src/server/chat-native.ts +445 -0
  587. package/src/server/claude-messages.ts +160 -44
  588. package/src/server/direct-local-http.ts +347 -0
  589. package/src/server/effort-policy.ts +18 -0
  590. package/src/server/github-copilot-responses-repair.ts +338 -0
  591. package/src/server/gui-static.ts +39 -10
  592. package/src/server/images.ts +94 -12
  593. package/src/server/index.ts +1021 -190
  594. package/src/server/lifecycle.ts +336 -14
  595. package/src/server/live.ts +136 -17
  596. package/src/server/local-management-read-client.ts +90 -0
  597. package/src/server/local-provider-reload-client.ts +137 -0
  598. package/src/server/management/agent-settings-routes.ts +433 -116
  599. package/src/server/management/api-key-usage.ts +31 -5
  600. package/src/server/management/body.ts +6 -0
  601. package/src/server/management/codex-prompt-routes.ts +570 -0
  602. package/src/server/management/combo-routes.ts +64 -25
  603. package/src/server/management/config-routes.ts +511 -51
  604. package/src/server/management/context.ts +89 -2
  605. package/src/server/management/integration-routes.ts +498 -0
  606. package/src/server/management/lab-automation-routes.ts +206 -0
  607. package/src/server/management/lab-routes.ts +563 -0
  608. package/src/server/management/logs-usage-routes.ts +127 -52
  609. package/src/server/management/model-routes.ts +461 -133
  610. package/src/server/management/model-rows.ts +167 -0
  611. package/src/server/management/native-integration-routes.ts +770 -0
  612. package/src/server/management/oauth-account-routes.ts +90 -8
  613. package/src/server/management/provider-capability-config.ts +48 -0
  614. package/src/server/management/provider-routes.ts +836 -159
  615. package/src/server/management/request-history-routes.ts +191 -0
  616. package/src/server/management/route-registry.ts +332 -0
  617. package/src/server/management/routing-analytics-routes.ts +74 -0
  618. package/src/server/management/routing-profile-routes.ts +380 -0
  619. package/src/server/management/shared.ts +27 -11
  620. package/src/server/management/sidebar-routes.ts +47 -31
  621. package/src/server/management/storage-log-guard-routes.ts +186 -0
  622. package/src/server/management/sync-response.ts +69 -0
  623. package/src/server/management/system-restart.ts +289 -33
  624. package/src/server/management/system-routes.ts +77 -0
  625. package/src/server/management/usage-summary-cache.ts +9 -1
  626. package/src/server/management/vision-sidecar-options.ts +167 -0
  627. package/src/server/management/web-search-sidecar-options.ts +120 -0
  628. package/src/server/management-api.ts +126 -23
  629. package/src/server/management-auth.ts +220 -5
  630. package/src/server/passive-route-linker.ts +66 -0
  631. package/src/server/port-reclaim.ts +1 -1
  632. package/src/server/ports.ts +41 -1
  633. package/src/server/proxy-liveness.ts +161 -10
  634. package/src/server/readiness.ts +99 -0
  635. package/src/server/relay-eager.ts +221 -54
  636. package/src/server/relay.ts +479 -87
  637. package/src/server/request-decompress.ts +113 -6
  638. package/src/server/request-log-conversation.ts +51 -0
  639. package/src/server/request-log.ts +298 -31
  640. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  641. package/src/server/responses/agent-task-recovery.ts +465 -0
  642. package/src/server/responses/codex-auth-error.ts +55 -0
  643. package/src/server/responses/collaboration.ts +239 -36
  644. package/src/server/responses/combo-stream-preflight.ts +178 -0
  645. package/src/server/responses/compact.ts +444 -72
  646. package/src/server/responses/core.ts +3579 -436
  647. package/src/server/responses/empty-completion-guard.ts +333 -0
  648. package/src/server/responses/encrypted-payload.ts +62 -39
  649. package/src/server/responses/fetch-helpers.ts +89 -99
  650. package/src/server/responses/input-admission.ts +187 -0
  651. package/src/server/responses/pacing-overload.ts +13 -0
  652. package/src/server/responses/passthrough-error.ts +33 -9
  653. package/src/server/responses/policy-fallback.ts +178 -0
  654. package/src/server/responses/responses-field-backfill.ts +343 -0
  655. package/src/server/responses/terminal-guard.ts +26 -5
  656. package/src/server/responses/upstream-error.ts +5 -0
  657. package/src/server/responses/ws-upstream.ts +381 -0
  658. package/src/server/responses-custom-tool-repair.ts +363 -0
  659. package/src/server/responses-item-id-repair.ts +54 -6
  660. package/src/server/responses-json-events.ts +90 -0
  661. package/src/server/responses-model-rewrite.ts +29 -0
  662. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  663. package/src/server/responses-snapshot-repair.ts +621 -0
  664. package/src/server/responses-terminal-repair.ts +363 -0
  665. package/src/server/responses-tool-search-repair.ts +267 -0
  666. package/src/server/responses-undeclared-tool-guard.ts +376 -0
  667. package/src/server/responses.ts +18 -2
  668. package/src/server/search.ts +78 -13
  669. package/src/server/sse-frame-buffer.ts +319 -0
  670. package/src/server/sse-payload-rewrite.ts +110 -22
  671. package/src/server/startup-action-control.ts +8 -1
  672. package/src/server/startup-health-cache.ts +19 -1
  673. package/src/server/system-env.ts +80 -9
  674. package/src/server/ws-bridge.ts +53 -40
  675. package/src/service-manager-probe.ts +892 -0
  676. package/src/service.ts +1153 -94
  677. package/src/sidecar/auth.ts +92 -0
  678. package/src/sidecar/candidates.ts +83 -0
  679. package/src/storage/cleanup.ts +2 -2
  680. package/src/storage/policy-job.ts +14 -4
  681. package/src/storage/policy.ts +88 -23
  682. package/src/storage/scanner.ts +1 -1
  683. package/src/storage/worker-lifecycle.ts +14 -14
  684. package/src/tray/windows-tray.ps1 +83 -9
  685. package/src/tray/windows.ts +43 -16
  686. package/src/types/accounts.ts +37 -0
  687. package/src/types/config.ts +911 -0
  688. package/src/types/provider.ts +614 -0
  689. package/src/types/request.ts +397 -0
  690. package/src/types/tools.ts +236 -0
  691. package/src/types/wire.ts +80 -0
  692. package/src/types.ts +107 -1237
  693. package/src/update/index.ts +37 -23
  694. package/src/update/job.ts +445 -68
  695. package/src/update/notify.ts +12 -6
  696. package/src/update/npm-cache-preflight.d.mts +47 -0
  697. package/src/update/npm-cache-preflight.mjs +201 -0
  698. package/src/update/transactional-install.d.mts +22 -0
  699. package/src/update/transactional-install.mjs +266 -0
  700. package/src/usage/cost.ts +0 -0
  701. package/src/usage/expected-prices.ts +287 -16
  702. package/src/usage/log.ts +666 -50
  703. package/src/usage/summary.ts +388 -17
  704. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  705. package/src/usage/user-cost-overlays.ts +314 -0
  706. package/src/vision/anthropic-describe.ts +10 -6
  707. package/src/vision/backends.ts +97 -0
  708. package/src/vision/describe.ts +27 -16
  709. package/src/vision/eligibility.ts +250 -0
  710. package/src/vision/index.ts +238 -24
  711. package/src/vision/reasoning.ts +55 -0
  712. package/src/vision/routed-describe.ts +175 -0
  713. package/src/vision/timeout-bounds.ts +9 -0
  714. package/src/web-search/anthropic-executor.ts +13 -7
  715. package/src/web-search/backends.ts +108 -0
  716. package/src/web-search/exa-executor.ts +119 -0
  717. package/src/web-search/executor.ts +21 -6
  718. package/src/web-search/gemini-executor.ts +141 -0
  719. package/src/web-search/index.ts +150 -15
  720. package/src/web-search/loop.ts +284 -54
  721. package/src/web-search/parse.ts +125 -30
  722. package/src/web-search/sources.ts +60 -0
  723. package/src/web-search/xai-executor.ts +219 -0
  724. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  725. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  726. package/gui/dist/provider-icons/antigravity.svg +0 -1
  727. package/gui/dist/provider-icons/claude.svg +0 -1
  728. package/gui/dist/provider-icons/copilot.svg +0 -1
  729. package/gui/dist/provider-icons/cursor.svg +0 -2
  730. package/gui/dist/provider-icons/gemini.svg +0 -1
  731. package/gui/dist/provider-icons/grok-color.svg +0 -1
  732. package/gui/dist/provider-icons/kiro.svg +0 -14
  733. package/src/cli/internal-dispatch.ts +0 -20
package/src/service.ts CHANGED
@@ -7,35 +7,47 @@
7
7
  */
8
8
  import { execFileSync, execSync, spawnSync } from "node:child_process";
9
9
  import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
10
- import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
- import { homedir } from "node:os";
12
- import { dirname, join, resolve } from "node:path";
13
- import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
14
- import { loadConfig } from "./config";
15
- import { restoreNativeCodex } from "./codex/inject";
10
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { dirname, join, posix, resolve, win32 } from "node:path";
13
+ import { expandUserPath, getConfigDir, loadConfig } from "./config";
14
+ import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config/process-state";
15
+ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
16
16
  import { stripGrokConfig } from "./grok/inject";
17
- import { isWslRuntime } from "./codex/home";
18
- import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
17
+ import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
18
+ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
19
+ import type { BunRuntimeSource } from "./lib/bun-runtime";
19
20
  import { isProcessAlive, stopProxy } from "./lib/process-control";
20
21
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
22
+ import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
23
+ import { PROXY_ENV_KEYS } from "./lib/proxy-env";
21
24
  import { randomUUID } from "node:crypto";
22
25
  import {
23
26
  ELEVATION_REQUEST_TIMEOUT_MS,
24
27
  OCX_ELEVATED_PROTOCOL_FAILED,
25
28
  raceWithTimeout,
29
+ resolveTrustedWindowsPowerShellExe,
26
30
  resolveTrustedWindowsSchtasksExe,
27
31
  startElevatedSchtasksCreateAndRun,
28
32
  runWindowsElevated,
33
+ runWindowsElevatedScheduledTaskRegistration,
29
34
  toWindowsSchtasksError,
30
35
  WindowsElevationError,
36
+ WindowsSchtasksError,
31
37
  type ElevatedSchedulerOutcome,
32
38
  type ElevatedSchtasksCreateAndRunExecution,
33
39
  type ElevatedSchtasksCreateAndRunResult,
34
40
  } from "./lib/windows-elevation";
35
- import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
36
- import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
41
+ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION, type WinswStatus } from "./lib/winsw";
42
+ import {
43
+ forgetEphemeralSecretDir,
44
+ forgetEphemeralSecretPath,
45
+ hardenSecretDir,
46
+ hardenSecretPath,
47
+ } from "./lib/windows-secret-acl";
37
48
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
38
49
  import { recordOwnedConfigPath } from "./lib/config-ownership";
50
+ import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
39
51
  import { maybeShowStarPrompt } from "./cli/star-prompt";
40
52
 
41
53
  const LABEL = "com.opencodex.proxy";
@@ -43,11 +55,15 @@ const TASK = "opencodex-proxy";
43
55
 
44
56
  export type ServiceBackend = "scheduler" | "native";
45
57
 
46
- function cliEntry(): { bun: string; cli: string } {
58
+ function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
47
59
  // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
48
60
  // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
49
61
  // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
50
- return { bun: durableBunPath(), cli: join(import.meta.dir, "cli", "index.ts") };
62
+ //
63
+ // Path and provenance come from ONE resolution so the marker can never describe a
64
+ // different binary than the one actually baked.
65
+ const runtime = durableBunRuntime();
66
+ return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "cli", "index.ts") };
51
67
  }
52
68
 
53
69
  function plistPath(): string {
@@ -82,16 +98,41 @@ function defaultOpenCodexHome(): string {
82
98
  return resolve(join(homedir(), ".opencodex"));
83
99
  }
84
100
 
85
- function serviceStatePaths(): string[] {
86
- const paths = [serviceStatePath()];
101
+ export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] {
102
+ const paths = [join(opencodexHome, "service-state.json")];
87
103
  const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
88
104
  if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
89
105
  return paths;
90
106
  }
91
107
 
92
- function currentCodexHome(): string {
93
- const raw = process.env.CODEX_HOME?.trim();
94
- return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
108
+ function serviceStatePaths(): string[] {
109
+ return serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
110
+ }
111
+
112
+ function currentCodexHome(deps: CodexHomeDeps = {}): string {
113
+ // Service ownership must identify the same home as the runtime. In WSL an
114
+ // unset CODEX_HOME can resolve to the single Windows Desktop home rather than
115
+ // Linux ~/.codex; recording the fallback here creates a false foreign owner.
116
+ return resolveCodexHomeDir(deps);
117
+ }
118
+
119
+ function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined {
120
+ const raw = process.env.CODEX_SQLITE_HOME?.trim();
121
+ if (!raw) return undefined;
122
+ const expanded = expandUserPath(raw);
123
+ // Service artifacts can be rendered by cross-platform tests and repair tooling, so an
124
+ // already-absolute path for the TARGET platform is preserved rather than re-anchored
125
+ // against the writing host. `resolve()` is host-relative in both directions: on a POSIX
126
+ // host it turns `C:\data` into `<cwd>/C:\data`, and on a Windows host it turns `/tmp/x`
127
+ // into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves,
128
+ // because a service unit has no meaningful working directory.
129
+ //
130
+ // CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same
131
+ // generated file disagreed with itself about two variables holding the same kind of value.
132
+ if (target === "windows") {
133
+ return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded);
134
+ }
135
+ return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded);
95
136
  }
96
137
 
97
138
  function currentOpenCodexHome(): string {
@@ -169,13 +210,80 @@ function readServiceInstallState(): ServiceInstallState | null {
169
210
  return null;
170
211
  }
171
212
 
172
- /** Single accessor for update/reinstall code v1/legacy state maps to scheduler. */
213
+ /** What ONE state path said. Absent, unreadable and invalid are different answers. */
214
+ export type ServiceStateEvidence =
215
+ | { readonly path: string; readonly kind: "absent" }
216
+ | { readonly path: string; readonly kind: "unreadable"; readonly reason: string }
217
+ | { readonly path: string; readonly kind: "invalid" }
218
+ | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState };
219
+
220
+ /**
221
+ * Every state path, with what each one said.
222
+ *
223
+ * `readServiceInstallState` returns the FIRST path that parsed and discards the
224
+ * rest, so a valid mirror beside a corrupt one reads as clean. That is the right
225
+ * behavior for callers that just need the install state; it is the wrong input
226
+ * for deciding ownership, where a disagreement between mirrors is exactly the
227
+ * evidence that matters.
228
+ */
229
+ export function inspectServiceStateEvidence(
230
+ paths: readonly string[] = serviceStatePaths(),
231
+ ): readonly ServiceStateEvidence[] {
232
+ return paths.map((path): ServiceStateEvidence => {
233
+ let raw: string;
234
+ try {
235
+ raw = readFileSync(path, "utf8");
236
+ } catch (error) {
237
+ const code = error && typeof error === "object" && "code" in error
238
+ ? String((error as { code?: unknown }).code)
239
+ : "";
240
+ // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask,
241
+ // and collapsing them into absence is how a locked-down state file would
242
+ // become permission to write.
243
+ if (code === "ENOENT") return { path, kind: "absent" };
244
+ return { path, kind: "unreadable", reason: code || String(error) };
245
+ }
246
+ let parsed: ServiceInstallState | null;
247
+ try {
248
+ parsed = parseServiceInstallState(JSON.parse(raw));
249
+ } catch {
250
+ return { path, kind: "invalid" };
251
+ }
252
+ return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" };
253
+ });
254
+ }
255
+
256
+ /** The homes this process is actually using, for comparison against a claim. */
257
+ export function currentServiceHomes(deps: CodexHomeDeps = {}): { codexHome: string; opencodexHome: string } {
258
+ return { codexHome: currentCodexHome(deps), opencodexHome: currentOpenCodexHome() };
259
+ }
260
+
261
+ export function serviceHomeMatches(a: string, b: string): boolean {
262
+ return normalizePathForCompare(a) === normalizePathForCompare(b);
263
+ }
264
+
265
+ /** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */
173
266
  export function readServiceBackend(): ServiceBackend {
174
267
  return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
175
268
  }
176
269
 
177
- /** The `ocx` argv that reinstalls the currently-chosen service backend (update paths). */
270
+ /**
271
+ * The `ocx` argv that refreshes an already-installed service after an update.
272
+ *
273
+ * `repair` discovers the installed backend itself and, on Windows scheduler installs,
274
+ * rewrites the wrapper assets and restarts the existing task WITHOUT `schtasks /create`
275
+ * (see repairService below). `install` always reaches `/create`, which requires
276
+ * elevation — so an ordinary non-elevated `ocx update` used to stop a working proxy and
277
+ * then fail to bring its service back.
278
+ *
279
+ * The historical export name is kept for callers outside this module.
280
+ */
178
281
  export function serviceReinstallArgs(): string[] {
282
+ return ["service", "repair"];
283
+ }
284
+
285
+ /** The `ocx` argv that registers a service from scratch, preserving the chosen backend. */
286
+ export function serviceInstallArgs(): string[] {
179
287
  return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
180
288
  }
181
289
 
@@ -211,11 +319,12 @@ export function serviceEnvironmentOwnedHere(): boolean {
211
319
  export function assertServiceEnvironmentMatchesInstall(): void {
212
320
  const state = readServiceInstallState();
213
321
  if (!state) return;
322
+ const actualCodexHome = currentCodexHome();
214
323
  const expected = normalizePathForCompare(state.codexHome);
215
- const actual = normalizePathForCompare(currentCodexHome());
324
+ const actual = normalizePathForCompare(actualCodexHome);
216
325
  if (expected !== actual) {
217
326
  throw new ServiceOwnershipError(
218
- `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
327
+ `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
219
328
  "Run the service command from the same Codex home so native Codex restore updates the correct config.",
220
329
  );
221
330
  }
@@ -229,6 +338,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
229
338
  }
230
339
  }
231
340
 
341
+
232
342
  function plistString(value: string): string {
233
343
  return value
234
344
  .replace(/&/g, "&amp;")
@@ -243,19 +353,70 @@ function isLoopbackHostname(hostname: string | undefined): boolean {
243
353
  return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
244
354
  }
245
355
 
356
+ /**
357
+ * The `ocx` command a user should rerun for the service state they actually have.
358
+ *
359
+ * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW
360
+ * conflict outright, so recommending repair there names a command guaranteed to fail.
361
+ * Install IS the valid conflict recovery, because `installWindows` removes the native
362
+ * backend first. Exported so the guard tests the real selector rather than a copy of it.
363
+ */
364
+ export function serviceRetryCommand(
365
+ diag: Pick<ServiceDiagnostic, "installed" | "conflict"> = diagnoseService(),
366
+ ): string {
367
+ return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
368
+ }
369
+
370
+ /**
371
+ * Refuse a management (admin) token as the data-plane secret.
372
+ *
373
+ * The service exports the contents of the service token file as
374
+ * `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin
375
+ * token, the server treats the management credential as a data-plane admission secret
376
+ * and fails the ENTIRE management plane closed at boot, so every `/api/*` request
377
+ * returns 503 — even on a loopback install that never needed a data-plane secret.
378
+ * Exporting the admin token in the CLI cannot recover it, because the fence is decided
379
+ * server-side at startup (#2696).
380
+ *
381
+ * Nothing in this codebase puts an admin token in that env var; it arrives from the
382
+ * installing shell. This function is the chokepoint that should refuse it rather than
383
+ * writing a file that produces a broken service. Comparison is the same helper doctor
384
+ * uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
385
+ */
386
+ export function assertNotAdminToken(token: string, env: NodeJS.ProcessEnv = process.env): void {
387
+ if (!tokenCollidesWithAdmin(token, env)) return;
388
+ throw new Error(
389
+ "OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
390
+ + "as the data-plane secret, which fences the whole management API closed and makes "
391
+ + "every ocx management command fail with 503. Unset OPENCODEX_API_AUTH_TOKEN, or set "
392
+ + "it to a distinct data-plane key, then rerun the install.",
393
+ );
394
+ }
395
+
246
396
  export function assertServiceAuthEnvironment(): void {
247
397
  const config = loadConfig();
398
+ // Check the collision before the loopback short-circuit: a loopback install writes
399
+ // the token file too, so returning early here is what let the broken state through.
400
+ const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
401
+ if (present) assertNotAdminToken(present);
248
402
  if (isLoopbackHostname(config.hostname)) return;
249
403
  if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
404
+ // Reached from `service repair` as well as `install`, so name a command that can
405
+ // actually succeed (see serviceRetryCommand).
406
+ const diag = diagnoseService();
407
+ const retry = serviceRetryCommand(diag);
250
408
  throw new Error(
251
- "OPENCODEX_API_AUTH_TOKEN is required before installing a service for non-loopback hostname. " +
252
- "Set it in the same shell, then rerun `ocx service install`.",
409
+ `OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service `
410
+ + `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`,
253
411
  );
254
412
  }
255
413
 
256
414
  function writeServiceApiTokenFile(): string | null {
257
415
  const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
258
416
  if (!token) return null;
417
+ // Last line of defence: every install/repair path funnels through here, so a
418
+ // collision cannot reach disk regardless of which caller ran (#2696).
419
+ assertNotAdminToken(token);
259
420
  const path = serviceApiTokenFilePath();
260
421
  const dir = getConfigDir();
261
422
  recordOwnedConfigPath(dir, path);
@@ -267,17 +428,23 @@ function writeServiceApiTokenFile(): string | null {
267
428
  return path;
268
429
  }
269
430
 
270
- export function buildPlist(): string {
271
- const { bun, cli } = cliEntry();
431
+ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
432
+ const { bun, bunRuntimeSource, cli } = cliEntry();
272
433
  const log = logPath();
273
434
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
274
435
  const codexHome = process.env.CODEX_HOME?.trim();
436
+ const codexSqliteHome = currentCodexSqliteHomeAbsolute();
275
437
  const opencodexHome = process.env.OPENCODEX_HOME?.trim();
276
438
  const envLines = [
277
439
  ` <key>OCX_SERVICE</key><string>1</string>`,
440
+ ` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
441
+ ` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
278
442
  ` <key>PATH</key><string>${plistString(path)}</string>`,
279
443
  codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
444
+ codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
280
445
  opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
446
+ ...proxyEnv.map(({ name, value }) =>
447
+ ` <key>${name}</key><string>${plistString(value)}</string>`),
281
448
  ].filter((line): line is string => Boolean(line)).join("\n");
282
449
  const command = buildServiceShellCommand(bun, cli);
283
450
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -491,17 +658,14 @@ async function reportServiceServing(
491
658
  }
492
659
 
493
660
  /**
494
- * The reinstall command for the CURRENTLY INSTALLED backend.
661
+ * The command that repairs the CURRENTLY INSTALLED backend without re-registering it.
495
662
  *
496
- * Plain `ocx service install` on a native/WinSW install runs installWindows's
497
- * transactional backend switch, which tears down WinSW and replaces it with the Task
498
- * Scheduler backend. Advising it in a repair hint would silently change the user's
499
- * backend, so the hint has to carry `--native` when that is what is installed.
663
+ * `ocx service repair` reads the recorded backend itself, so it cannot silently switch a
664
+ * WinSW install to Task Scheduler the way a plain `ocx service install` would, and on
665
+ * Windows it needs no elevation because it never calls `schtasks /create`.
500
666
  */
501
667
  function serviceRepairCommand(): string {
502
- return process.platform === "win32" && readServiceBackend() === "native"
503
- ? "ocx service install --native"
504
- : "ocx service install";
668
+ return "ocx service repair";
505
669
  }
506
670
 
507
671
  function systemdQuote(value: string): string {
@@ -517,6 +681,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
517
681
  return `Environment=${systemdQuote(`${name}=${value}`)}`;
518
682
  }
519
683
 
684
+ /**
685
+ * Outbound proxy settings the installing shell had, resolved for baking into a service
686
+ * definition.
687
+ *
688
+ * A service manager does not inherit the environment of the shell that installed it, and
689
+ * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
690
+ * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
691
+ * reach the upstream got a service that dialed direct: the socket was reset, the retry
692
+ * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
693
+ * same install driven through `ocx codex-shim` worked, because that path spawns with
694
+ * `{ ...process.env }`.
695
+ *
696
+ * Lower-case variants are honored because curl-style tooling sets them and the runtime's
697
+ * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
698
+ * upper-case name is baked, so a definition never carries two spellings of one setting.
699
+ */
700
+ export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
701
+ const resolved: { name: string; value: string }[] = [];
702
+ for (const key of PROXY_ENV_KEYS) {
703
+ const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
704
+ if (value) resolved.push({ name: key, value });
705
+ }
706
+ return resolved;
707
+ }
708
+
520
709
  function systemdOutputTarget(value: string): string {
521
710
  // StandardOutput/StandardError use output specifiers such as append:/path.
522
711
  // Quoting the full specifier makes systemd reject it as an invalid output target.
@@ -544,16 +733,28 @@ function sh(cmd: string): string {
544
733
  export function runLaunchctl(
545
734
  args: string[],
546
735
  deps: { run?: typeof spawnSync } = {},
547
- ): { ok: boolean; stdout: string; stderr: string } {
736
+ ): { ok: boolean; stdout: string; stderr: string; status: number | null } {
548
737
  const run = deps.run ?? spawnSync;
549
738
  const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
550
739
  // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
551
740
  // null for a signalled child; neither may be reported as success.
552
- if (result.error) return { ok: false, stdout: "", stderr: String(result.error.message ?? "") };
741
+ if (result.error) {
742
+ return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
743
+ }
553
744
  return {
554
745
  ok: result.status === 0,
555
746
  stdout: String(result.stdout ?? "").trim(),
556
747
  stderr: String(result.stderr ?? "").trim(),
748
+ /*
749
+ * The NUMBER, not just its zero-ness.
750
+ *
751
+ * `launchctl print` distinguishes "that domain does not exist" (112) from
752
+ * "the domain answered and has no such service" (113), and an ownership
753
+ * probe needs that difference: the second proves absence, the first only
754
+ * proves we could not look. Collapsing both into `ok: false` forced callers
755
+ * to parse stderr, which Apple does not treat as a stable interface.
756
+ */
757
+ status: result.status ?? null,
557
758
  };
558
759
  }
559
760
 
@@ -788,6 +989,10 @@ export function windowsSchedulerTaskInstalled(taskName = TASK): boolean {
788
989
  export interface WindowsSchedulerInstallVerification {
789
990
  taskInstalled: boolean;
790
991
  registrationHealthy: boolean;
992
+ /** Well-formed XML that is PUBLISHED but policy-violating — permanent, never
993
+ * worth a settle retry (vs an empty/unreadable view, which is publication
994
+ * lag and transient). */
995
+ registrationInvalid: boolean;
791
996
  assetsHealthy: boolean;
792
997
  nativeServiceAbsent: boolean;
793
998
  /** True when SCM probe failed; not a proven WinSW presence. */
@@ -808,6 +1013,10 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
808
1013
  }): WindowsSchedulerInstallVerification {
809
1014
  const registrationHealthy = inputs.xml.length > 0
810
1015
  && windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher);
1016
+ // Permanent invalidity: the XML IS published but violates the registration
1017
+ // contract — no amount of settling changes it. Empty/unreadable XML stays
1018
+ // transient (publication lag).
1019
+ const registrationInvalid = inputs.taskInstalled && inputs.xml.length > 0 && !registrationHealthy;
811
1020
  const assetsHealthy = inputs.assetsExist;
812
1021
  const nativeServiceAbsent = inputs.nativeStatus === "nonexistent";
813
1022
  const nativeStatusUnknown = inputs.nativeStatus === "unknown";
@@ -831,6 +1040,7 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
831
1040
  return {
832
1041
  taskInstalled: inputs.taskInstalled,
833
1042
  registrationHealthy,
1043
+ registrationInvalid,
834
1044
  assetsHealthy,
835
1045
  nativeServiceAbsent,
836
1046
  nativeStatusUnknown,
@@ -870,6 +1080,48 @@ async function elevateSchtasks(args: string[]): Promise<void> {
870
1080
  }
871
1081
  }
872
1082
 
1083
+ export interface WindowsSchedulerRollbackDeps {
1084
+ queryXml?: () => string;
1085
+ deleteTask?: () => Promise<void>;
1086
+ probe?: () => WindowsSchedulerTaskProbe;
1087
+ }
1088
+
1089
+ export async function rollbackWindowsSchedulerTaskOwnedByAttempt(
1090
+ attemptNonce: string,
1091
+ taskName = TASK,
1092
+ deps: WindowsSchedulerRollbackDeps = {},
1093
+ ): Promise<string | null> {
1094
+ let registeredXml = "";
1095
+ try {
1096
+ registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", taskName, "/xml"])))();
1097
+ } catch (error) {
1098
+ const detail = error instanceof Error ? error.message : String(error);
1099
+ return `Task Scheduler task ${taskName} ownership could not be proven: ${detail}. Residual scheduler state: task ${taskName} presence is unknown; no rollback deletion was attempted.`;
1100
+ }
1101
+ if (!registeredXml.trim()) {
1102
+ return `Task Scheduler task ${taskName} ownership could not be proven because its live XML was empty. Residual scheduler state: task ${taskName} presence is unknown; no rollback deletion was attempted.`;
1103
+ }
1104
+ if (!windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)) {
1105
+ return `Task Scheduler task ${taskName} ownership could not be proven because its attempt nonce does not match. Residual scheduler state: task ${taskName} remains registered; no rollback deletion was attempted.`;
1106
+ }
1107
+
1108
+ try {
1109
+ await (deps.deleteTask ?? (() => elevateSchtasks(["/delete", "/tn", taskName, "/f"])))();
1110
+ } catch (error) {
1111
+ const detail = error instanceof Error ? error.message : String(error);
1112
+ return `Rollback deletion failed: ${detail}. Residual scheduler state: task ${taskName} may remain registered.`;
1113
+ }
1114
+ const probe = (deps.probe ?? (() => resolveWindowsSchedulerTaskProbe(taskName)))();
1115
+ if (probe.status === "absent") return null;
1116
+ if (probe.status === "unknown") {
1117
+ return `Task Scheduler task ${taskName} presence could not be verified after rollback: ${probe.detail}. Residual scheduler state: task presence is unknown.`;
1118
+ }
1119
+ return `Residual scheduler state: task ${taskName} is still present after rollback.`;
1120
+ }
1121
+
1122
+ // Legacy dashboard finalization creates and runs in one elevated child, whose protocol
1123
+ // performs its own rollback before returning. This fallback remains for indeterminate
1124
+ // protocol outcomes that predate the staged CLI transaction.
873
1125
  async function rollbackElevatedSchedulerTask(taskName = TASK): Promise<string | null> {
874
1126
  try {
875
1127
  await elevateSchtasks(["/delete", "/tn", taskName, "/f"]);
@@ -909,6 +1161,8 @@ type FinalizeHooks = {
909
1161
  /** Defense-in-depth: late reconciliation must still own this attempt. */
910
1162
  stillOwnsAttempt?: (attemptId: string) => boolean;
911
1163
  requestTimeoutMs?: number;
1164
+ /** Test-only seam for the post-create settle backoff; real installs use a timer. */
1165
+ settleDelay?: (ms: number) => Promise<void>;
912
1166
  };
913
1167
 
914
1168
  let finalizeHooks: FinalizeHooks | null = null;
@@ -973,6 +1227,68 @@ function attemptStillOwned(options: ApplyElevatedOptions): boolean {
973
1227
  return !check || check(options.attemptId);
974
1228
  }
975
1229
 
1230
+ /**
1231
+ * Bounded post-create backoff, 1.1s total. Task Scheduler's non-elevated view can
1232
+ * lag an elevated `/create` by a few hundred milliseconds, so a single verification
1233
+ * would roll back a task that is merely not visible yet.
1234
+ */
1235
+ const SCHEDULER_SETTLE_DELAYS_MS = [50, 150, 300, 600] as const;
1236
+
1237
+ /**
1238
+ * Whether a failed verification is still worth re-checking after a short delay.
1239
+ *
1240
+ * Retrying is confined to states that a lagging scheduler view actually produces:
1241
+ * the task is not visible yet, or it is visible but its registration has not been
1242
+ * published in full. Everything else keeps its existing fail-closed meaning and is
1243
+ * rejected here so no delay can turn it into a pass:
1244
+ *
1245
+ * - a proven conflict (both backends present) is a real dual-backend install;
1246
+ * - missing assets are missing on disk, which no amount of waiting creates;
1247
+ * - a WinSW service that is proven present (`started`/`stopped`) is never absent
1248
+ * later. This is checked independently of `conflict`, which only becomes true
1249
+ * once the task itself is visible — while the task is still invisible the pair
1250
+ * is `conflict: false` with `nativeServiceAbsent: false`, and that must not retry;
1251
+ * - unknown SCM status is unproven rather than transient, and has its own
1252
+ * task-preserving branch below.
1253
+ */
1254
+ /** Exported for tests: the transient-vs-permanent settle decision. */
1255
+ export function schedulerVerificationMaySettle(v: WindowsSchedulerInstallVerification): boolean {
1256
+ if (v.ok) return false;
1257
+ if (v.conflict) return false;
1258
+ if (!v.assetsHealthy) return false;
1259
+ if (!v.nativeServiceAbsent) return false;
1260
+ // A published-but-invalid registration is permanent: no delay repairs it.
1261
+ if (v.registrationInvalid) return false;
1262
+ return !v.taskInstalled || !v.registrationHealthy;
1263
+ }
1264
+
1265
+ function settleDelay(ms: number): Promise<void> {
1266
+ const hook = finalizeHooks?.settleDelay;
1267
+ if (hook) return hook(ms);
1268
+ return new Promise(resolve => setTimeout(resolve, ms));
1269
+ }
1270
+
1271
+ /**
1272
+ * Verify the elevated install, re-checking only while the failure looks like a
1273
+ * scheduler view that has not caught up yet. Returns `null` when this attempt lost
1274
+ * ownership mid-settle: a newer attempt owns the task, so this one must neither
1275
+ * write install state nor roll anything back.
1276
+ */
1277
+ async function verifyWindowsSchedulerInstallAfterSettle(
1278
+ options: ApplyElevatedOptions,
1279
+ ): Promise<WindowsSchedulerInstallVerification | null> {
1280
+ const verify = finalizeHooks?.verify ?? verifyWindowsSchedulerInstall;
1281
+ let verification = verify();
1282
+ for (const delayMs of SCHEDULER_SETTLE_DELAYS_MS) {
1283
+ if (!schedulerVerificationMaySettle(verification)) break;
1284
+ if (!attemptStillOwned(options)) return null;
1285
+ await settleDelay(delayMs);
1286
+ if (!attemptStillOwned(options)) return null;
1287
+ verification = verify();
1288
+ }
1289
+ return verification;
1290
+ }
1291
+
976
1292
  async function applyElevatedSchedulerResult(
977
1293
  result: ElevatedSchtasksCreateAndRunResult,
978
1294
  options: ApplyElevatedOptions,
@@ -1002,7 +1318,9 @@ async function applyElevatedSchedulerResult(
1002
1318
  await reconcileUnknownElevatedOutcome(result.exitCode);
1003
1319
  }
1004
1320
 
1005
- const verification = (finalizeHooks?.verify ?? verifyWindowsSchedulerInstall)();
1321
+ const verification = await verifyWindowsSchedulerInstallAfterSettle(options);
1322
+ // Ownership moved to a newer attempt while settling; that attempt owns the outcome.
1323
+ if (!verification) return;
1006
1324
  if (!verification.ok) {
1007
1325
  // Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status).
1008
1326
  // Unknown is not a confirmed dual-backend conflict; install state is still withheld.
@@ -1019,6 +1337,9 @@ async function applyElevatedSchedulerResult(
1019
1337
  "Installation state was not written.",
1020
1338
  ]);
1021
1339
  }
1340
+ // Rollback deletes a real task, so it needs the same ownership fence as the
1341
+ // state write below: a stale attempt must never delete a newer attempt's task.
1342
+ if (!attemptStillOwned(options)) return;
1022
1343
  const rollbackError = await rollbackElevatedSchedulerTask();
1023
1344
  const parts = [
1024
1345
  "Elevated Task Scheduler registration did not produce a conflict-free install.",
@@ -1258,9 +1579,14 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
1258
1579
  return value === "leastprivilege" || value === "highestavailable";
1259
1580
  }
1260
1581
 
1261
- export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
1262
- const { bun, cli } = entry;
1263
- const bunRuntime = durableBunRuntime();
1582
+ export function buildWindowsServiceScript(
1583
+ entry = cliEntry(),
1584
+ port = resolveServiceListenPort(),
1585
+ proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
1586
+ ): string {
1587
+ // Provenance rides along with the entry: a second durableBunRuntime() call here could
1588
+ // resolve differently from the binary the caller actually baked.
1589
+ const { bun, bunRuntimeSource, cli } = entry;
1264
1590
  const path = process.env.PATH ?? "";
1265
1591
  const lines = [
1266
1592
  "@echo off",
@@ -1269,24 +1595,45 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
1269
1595
  // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
1270
1596
  "chcp 65001 >nul",
1271
1597
  windowsBatchSet("OCX_SERVICE", "1"),
1598
+ windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
1599
+ windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
1272
1600
  windowsBatchSet("PATH", path, "pathList"),
1273
1601
  windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
1602
+ windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
1274
1603
  windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
1604
+ ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
1275
1605
  windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
1276
1606
  windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
1277
1607
  windowsBatchSet("OCX_BUN", bun, "path"),
1278
1608
  windowsBatchSet("OCX_CLI", cli, "path"),
1609
+ // Package root for the transactional-update restore path (#1942): cli is
1610
+ // <pkg>\src\cli\index.ts, so the package dir is three levels up.
1611
+ 'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"',
1279
1612
  'if exist "%OCX_API_TOKEN_FILE%" (',
1280
1613
  ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
1281
1614
  ")",
1282
1615
  ":loop",
1283
1616
  '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
1284
1617
  '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
1285
- `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntime.source}"`,
1618
+ `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
1286
1619
  '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
1287
1620
  '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
1288
1621
  '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
1289
1622
  '>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
1623
+ 'if not exist "%OCX_BUN%" (',
1624
+ " call :restore_backup",
1625
+ ")",
1626
+ 'if not exist "%OCX_BUN%" (',
1627
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
1628
+ " exit /b 3",
1629
+ ")",
1630
+ 'if not exist "%OCX_CLI%" (',
1631
+ " call :restore_backup",
1632
+ ")",
1633
+ 'if not exist "%OCX_CLI%" (',
1634
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair',
1635
+ " exit /b 3",
1636
+ ")",
1290
1637
  `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
1291
1638
  "if %ERRORLEVEL% NEQ 0 (",
1292
1639
  ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
@@ -1296,6 +1643,26 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
1296
1643
  " goto loop",
1297
1644
  ")",
1298
1645
  "endlocal",
1646
+ "goto :eof",
1647
+ "",
1648
+ // #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
1649
+ // a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
1650
+ // the package tree, so it can restore when the launcher itself is gone — the exact
1651
+ // window the in-launcher boot probe cannot reach.
1652
+ ":restore_backup",
1653
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore',
1654
+ 'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (',
1655
+ ' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (',
1656
+ ' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
1657
+ ' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
1658
+ ' if exist "%OCX_PKG_DIR%\\package.json" (',
1659
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
1660
+ " goto :eof",
1661
+ " )",
1662
+ " )",
1663
+ ")",
1664
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
1665
+ "goto :eof",
1299
1666
  ].filter((line): line is string => Boolean(line));
1300
1667
  return `${lines.join("\r\n")}\r\n`;
1301
1668
  }
@@ -1305,6 +1672,11 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
1305
1672
  return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1306
1673
  }
1307
1674
 
1675
+ /** Build the fixed scheduler-create command from an explicit staged XML document. */
1676
+ export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] {
1677
+ return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1678
+ }
1679
+
1308
1680
  /**
1309
1681
  * VBS launcher that starts the batch wrapper with a hidden window (style 0).
1310
1682
  * bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
@@ -1325,7 +1697,17 @@ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): st
1325
1697
  return `${lines.join("\r\n")}\r\n`;
1326
1698
  }
1327
1699
 
1328
- export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launcher = windowsLauncherVbsPath()): string {
1700
+ function windowsTaskDescription(attemptNonce?: string): string {
1701
+ return attemptNonce
1702
+ ? `OpenCodex proxy service wrapper; install-attempt=${attemptNonce}`
1703
+ : "OpenCodex proxy service wrapper";
1704
+ }
1705
+
1706
+ export function buildWindowsTaskXml(
1707
+ script = windowsServiceScriptPath(),
1708
+ launcher = windowsLauncherVbsPath(),
1709
+ attemptNonce?: string,
1710
+ ): string {
1329
1711
  const escapedWscript = taskXmlString(windowsWscript());
1330
1712
  // Escape the launcher path independently for the <Arguments> element; quoting it
1331
1713
  // keeps spaces intact, and /b (batch mode) suppresses script error popups.
@@ -1333,7 +1715,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launche
1333
1715
  return `<?xml version="1.0" encoding="UTF-16"?>
1334
1716
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1335
1717
  <RegistrationInfo>
1336
- <Description>OpenCodex proxy service wrapper</Description>
1718
+ <Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
1337
1719
  </RegistrationInfo>
1338
1720
  <Triggers>
1339
1721
  <LogonTrigger>
@@ -1453,6 +1835,21 @@ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string):
1453
1835
  return value?.trim().toLowerCase() === expected.toLowerCase();
1454
1836
  }
1455
1837
 
1838
+ /** True only when the exported live task carries this install attempt's nonce. */
1839
+ export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: string): boolean {
1840
+ if (!attemptNonce) return false;
1841
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
1842
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
1843
+ if (taskXmlHasPrefixedTag(scrubbed, "RegistrationInfo")) return false;
1844
+ if (taskXmlElementCount(scrubbed, "RegistrationInfo") !== 1) return false;
1845
+ const registrationInfo = taskXmlSection(scrubbed, "RegistrationInfo");
1846
+ return taskXmlDecodedValueEquals(
1847
+ registrationInfo,
1848
+ "Description",
1849
+ windowsTaskDescription(attemptNonce),
1850
+ );
1851
+ }
1852
+
1456
1853
  /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
1457
1854
  export function windowsTaskRegistrationHealthy(
1458
1855
  xml: string,
@@ -1523,7 +1920,10 @@ function installLaunchd(): void {
1523
1920
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1524
1921
  writeServiceApiTokenFile();
1525
1922
  const p = plistPath();
1526
- writeFileSync(p, buildPlist(), "utf8");
1923
+ // Capture this BEFORE writing: the write below makes the plist exist unconditionally,
1924
+ // so a post-write existsSync would call every fresh install an "installed" service.
1925
+ const wasInstalled = existsSync(p);
1926
+ writeServiceDefinitionFile(p, buildPlist(), "utf8");
1527
1927
  // Best-effort: an absent job is fine here, and a failed unload is caught by the
1528
1928
  // load verification below with a better message than a raw unload error.
1529
1929
  runLaunchctl(["unload", p]);
@@ -1535,7 +1935,9 @@ function installLaunchd(): void {
1535
1935
  `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1536
1936
  + "A previous job may still be bootstrapped. Try:\n"
1537
1937
  + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
1538
- + "then re-run 'ocx service install'.",
1938
+ // macOS `service repair` delegates straight to installLaunchd, so this fires for
1939
+ // an already-installed service too; repair reloads it without re-registering.
1940
+ + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
1539
1941
  );
1540
1942
  }
1541
1943
  writeServiceInstallState();
@@ -1572,8 +1974,8 @@ export function startLaunchd(deps: {
1572
1974
  throw new Error(
1573
1975
  `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1574
1976
  + (live.loaded
1575
- ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service install`
1576
- : "The job is not loaded. Run 'ocx service install' to re-register it."),
1977
+ ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
1978
+ : "The job is not loaded. Run 'ocx service repair' to reload it."),
1577
1979
  );
1578
1980
  }
1579
1981
  function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
@@ -1584,6 +1986,52 @@ function uninstallLaunchd(): void {
1584
1986
  if (existsSync(p)) unlinkSync(p);
1585
1987
  }
1586
1988
 
1989
+ /**
1990
+ * Write a service definition with owner-only permissions.
1991
+ *
1992
+ * These files carry the outbound proxy environment (#2107), and a proxy URL routinely
1993
+ * carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
1994
+ * umask, so the credential would be world-readable on a shared host. Every other
1995
+ * secret-bearing write in this file already uses 0600 — the service API token and the
1996
+ * install state — and a service definition holding a proxy credential belongs in the same
1997
+ * class.
1998
+ *
1999
+ * The explicit `chmodSync` is not redundant: `mode` only applies when the file is
2000
+ * created, so an install over a definition left at 0644 by an earlier version would keep
2001
+ * the loose mode.
2002
+ *
2003
+ * On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
2004
+ * may soft-fail depends on what the definition actually contains. A definition carrying a
2005
+ * proxy credential is a secret publication and fails closed like the API token and the
2006
+ * install state do; one carrying only paths and a port is not worth refusing an install
2007
+ * over, since before #2107 these files had no hardening at all and a failure here would
2008
+ * regress a user who has no credential to protect.
2009
+ */
2010
+ export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
2011
+ writeFileSync(path, content, { encoding, mode: 0o600 });
2012
+ try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
2013
+ if (process.platform === "win32") {
2014
+ hardenSecretPath(path, { required: definitionCarriesCredential(content) });
2015
+ }
2016
+ }
2017
+
2018
+ /**
2019
+ * Does this service definition embed a credential-bearing proxy URL?
2020
+ *
2021
+ * Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
2022
+ * variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
2023
+ * make an icacls stall fail an install that had nothing to protect.
2024
+ *
2025
+ * The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
2026
+ * because the three formats render differently — systemd writes `Environment="K=V"`, the
2027
+ * plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
2028
+ * `set "K=V"`. Keying on the assignment syntax silently missed the plist.
2029
+ */
2030
+ export function definitionCarriesCredential(content: string): boolean {
2031
+ // A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
2032
+ return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
2033
+ }
2034
+
1587
2035
  // ── Windows (Task Scheduler) ──
1588
2036
  /**
1589
2037
  * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
@@ -1592,7 +2040,7 @@ function uninstallLaunchd(): void {
1592
2040
  function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
1593
2041
  for (let attempt = 0; ; attempt++) {
1594
2042
  try {
1595
- writeFileSync(path, content, encoding);
2043
+ writeServiceDefinitionFile(path, content, encoding);
1596
2044
  return;
1597
2045
  } catch (err) {
1598
2046
  const code = (err as NodeJS.ErrnoException).code;
@@ -1617,21 +2065,236 @@ function writeWindowsSchedulerAssets(): void {
1617
2065
  writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1618
2066
  }
1619
2067
 
1620
- function installWindows(): void {
1621
- recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2068
+ const WINDOWS_SCHEDULER_STAGE_PREFIX = "opencodex-service-stage-";
2069
+ const ownedWindowsSchedulerStages = new Set<string>();
2070
+
2071
+ export interface WindowsSchedulerRegistrationStageDeps {
2072
+ createStageDir?: () => string;
2073
+ hardenDir?: (path: string) => void;
2074
+ writeXml?: (path: string, contents: string) => void;
2075
+ hardenPath?: (path: string) => void;
2076
+ removeStageDir?: (path: string) => void;
2077
+ }
2078
+
2079
+ function cleanupWindowsSchedulerStage(
2080
+ stageDir: string,
2081
+ xmlPath: string,
2082
+ removeStageDir: (path: string) => void,
2083
+ ): void {
2084
+ let cleanupError: unknown;
2085
+ try {
2086
+ unlinkSync(xmlPath);
2087
+ forgetEphemeralSecretPath(xmlPath);
2088
+ } catch (error) {
2089
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
2090
+ forgetEphemeralSecretPath(xmlPath);
2091
+ } else {
2092
+ cleanupError = error;
2093
+ }
2094
+ }
2095
+ try {
2096
+ removeStageDir(stageDir);
2097
+ forgetEphemeralSecretDir(stageDir);
2098
+ } catch (error) {
2099
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
2100
+ forgetEphemeralSecretDir(stageDir);
2101
+ } else if (cleanupError) {
2102
+ throw new AggregateError([cleanupError, error], "Task Scheduler staging cleanup failed.");
2103
+ } else {
2104
+ cleanupError = error;
2105
+ }
2106
+ }
2107
+ if (cleanupError) throw cleanupError;
2108
+ }
2109
+
2110
+ export function stageWindowsSchedulerRegistrationXml(
2111
+ attemptNonce: string,
2112
+ deps: WindowsSchedulerRegistrationStageDeps = {},
2113
+ ): string {
2114
+ const createStageDir = deps.createStageDir
2115
+ ?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
2116
+ const hardenDir = deps.hardenDir
2117
+ ?? ((path: string) => { hardenSecretDir(path, { required: true }); });
2118
+ const writeXml = deps.writeXml ?? ((path: string, contents: string) => {
2119
+ writeFileSync(path, contents, { encoding: "utf16le", flag: "wx", mode: 0o600 });
2120
+ });
2121
+ const hardenPath = deps.hardenPath
2122
+ ?? ((path: string) => { hardenSecretPath(path, { required: true }); });
2123
+ const removeStageDir = deps.removeStageDir
2124
+ ?? ((path: string) => { rmdirSync(path); });
2125
+
2126
+ let stageDir: string | null = null;
2127
+ let xmlPath: string | null = null;
2128
+ try {
2129
+ stageDir = createStageDir();
2130
+ try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
2131
+ hardenDir(stageDir);
2132
+ xmlPath = join(stageDir, "task.xml");
2133
+ // This document points at the canonical launcher but does not publish or rewrite it.
2134
+ // The hardened private directory prevents another local account from replacing the
2135
+ // document while UAC is pending; the file harden independently proves its identity.
2136
+ writeXml(
2137
+ xmlPath,
2138
+ `\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath(), attemptNonce)}`,
2139
+ );
2140
+ hardenPath(xmlPath);
2141
+ ownedWindowsSchedulerStages.add(xmlPath);
2142
+ return xmlPath;
2143
+ } catch (error) {
2144
+ if (stageDir) {
2145
+ try {
2146
+ cleanupWindowsSchedulerStage(stageDir, xmlPath ?? join(stageDir, "task.xml"), removeStageDir);
2147
+ } catch (cleanupError) {
2148
+ throw new AggregateError(
2149
+ [error, cleanupError],
2150
+ "Task Scheduler staging failed and its private temporary directory could not be removed.",
2151
+ );
2152
+ }
2153
+ }
2154
+ throw error;
2155
+ }
2156
+ }
2157
+
2158
+ function removeWindowsSchedulerRegistrationStage(xmlPath: string): void {
2159
+ if (!ownedWindowsSchedulerStages.has(xmlPath)) {
2160
+ throw new Error("Refusing to remove an unrecognized Task Scheduler staging path.");
2161
+ }
2162
+ const stageDir = dirname(xmlPath);
2163
+ cleanupWindowsSchedulerStage(
2164
+ stageDir,
2165
+ xmlPath,
2166
+ path => { rmdirSync(path); },
2167
+ );
2168
+ if (existsSync(stageDir)) {
2169
+ throw new Error("The private Task Scheduler staging directory still exists after cleanup.");
2170
+ }
2171
+ ownedWindowsSchedulerStages.delete(xmlPath);
2172
+ }
2173
+
2174
+ export interface FreshWindowsSchedulerRegistrationDeps {
2175
+ create?: (args: string[]) => void;
2176
+ elevate?: (taskName: string, xml: string) => Promise<void>;
2177
+ probe?: () => WindowsSchedulerTaskProbe;
2178
+ queryXml?: () => string;
2179
+ rollback?: () => Promise<string | null>;
2180
+ }
2181
+
2182
+ export async function registerFreshWindowsSchedulerTask(
2183
+ xmlPath: string,
2184
+ attemptNonce: string,
2185
+ deps: FreshWindowsSchedulerRegistrationDeps = {},
2186
+ ): Promise<void> {
2187
+ const args = buildWindowsSchtasksCreateArgsForXml(xmlPath);
2188
+ // Capture and validate the exact definition before an access-denied attempt can
2189
+ // cross the UAC boundary. The elevated fallback receives these immutable bytes,
2190
+ // never the caller-writable staging pathname.
2191
+ const expectedXml = decodeSchtasksOutput(readFileSync(xmlPath));
2192
+ if (
2193
+ !windowsTaskRegistrationHealthy(expectedXml)
2194
+ || !windowsTaskRegistrationOwnedByAttempt(expectedXml, attemptNonce)
2195
+ ) {
2196
+ throw new Error("The staged Task Scheduler registration failed OpenCodex ownership or shape validation.");
2197
+ }
2198
+ try {
2199
+ (deps.create ?? schtasks)(args);
2200
+ } catch (error) {
2201
+ if (
2202
+ !(error instanceof WindowsSchtasksError)
2203
+ || error.operation !== "create"
2204
+ || error.reason !== "access-denied"
2205
+ ) {
2206
+ throw error;
2207
+ }
2208
+ // Register from the captured XML string inside the elevated process. Another
2209
+ // same-user process can mutate its own temp files, but cannot change this command.
2210
+ const elevate = deps.elevate ?? (async (taskName: string, xml: string) => {
2211
+ const exitCode = await runWindowsElevatedScheduledTaskRegistration(taskName, xml);
2212
+ if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
2213
+ });
2214
+ await elevate(TASK, expectedXml);
2215
+ }
2216
+
2217
+ const rollbackTask = deps.rollback ?? (() => rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK));
2218
+ const probe = (deps.probe ?? (() => probeWindowsSchedulerTask(TASK)))();
2219
+ if (probe.status === "absent") {
2220
+ throw new Error("Task Scheduler reported success, but the new registration is absent; no service cleanup was started.");
2221
+ }
2222
+ if (probe.status === "unknown") {
2223
+ const rollback = await rollbackTask();
2224
+ throw new Error(
2225
+ `Task Scheduler registration was not verifiably present after create (${probe.detail}).`
2226
+ + (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
2227
+ );
2228
+ }
2229
+
2230
+ let registeredXml = "";
2231
+ let queryDetail: string | null = null;
2232
+ try {
2233
+ registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", TASK, "/xml"])))();
2234
+ } catch (error) {
2235
+ queryDetail = error instanceof Error ? error.message : String(error);
2236
+ }
2237
+ if (!registeredXml.trim()) {
2238
+ const rollback = await rollbackTask();
2239
+ throw new Error(
2240
+ "Task Scheduler registration was created, but its live XML could not be verified."
2241
+ + (queryDetail ? ` Query failed: ${queryDetail}` : " The query returned an empty document.")
2242
+ + (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
2243
+ );
2244
+ }
2245
+ if (
2246
+ !windowsTaskRegistrationHealthy(registeredXml)
2247
+ || !windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)
2248
+ ) {
2249
+ const rollback = await rollbackTask();
2250
+ throw new Error(
2251
+ "Task Scheduler registration was created but failed the OpenCodex action/trigger or attempt-ownership verification."
2252
+ + (rollback ? ` Cleanup also failed: ${rollback}` : " The invalid registration was rolled back."),
2253
+ );
2254
+ }
2255
+ }
2256
+
2257
+ function recordWindowsSchedulerOwnership(): boolean {
2258
+ // Ownership claiming is deliberately conservative: a legacy non-empty config root
2259
+ // without metadata stays unclaimed, but that must not turn a service reinstall into
2260
+ // an outage after prepareServiceInstall has stopped the previous manager.
2261
+ return recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2262
+ }
2263
+
2264
+ export interface RemoveNativeWindowsServiceDeps {
2265
+ status?: () => WinswStatus;
2266
+ uninstall?: () => void;
2267
+ sleep?: (ms: number) => void;
2268
+ settleChecks?: number;
2269
+ }
2270
+
2271
+ export function removeNativeWindowsServiceForScheduler(
2272
+ deps: RemoveNativeWindowsServiceDeps = {},
2273
+ ): void {
2274
+ const status = deps.status ?? statusWinswRaw;
2275
+ const uninstall = deps.uninstall ?? uninstallWinswService;
2276
+ const sleep = deps.sleep ?? Bun.sleepSync;
2277
+ const settleChecks = Math.max(1, deps.settleChecks ?? 20);
1622
2278
  // Transactional backend switch: installing the scheduler backend removes a native
1623
2279
  // service first — two live managers would both respawn the proxy (conflict).
1624
- if (statusWinswRaw() !== "nonexistent") {
2280
+ if (status() !== "nonexistent") {
1625
2281
  console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
1626
2282
  try {
1627
- uninstallWinswService();
2283
+ uninstall();
1628
2284
  } catch (err) {
1629
2285
  throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`);
1630
2286
  }
1631
- if (statusWinswRaw() !== "nonexistent") {
1632
- throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`);
2287
+ for (let check = 0; check < settleChecks; check++) {
2288
+ if (status() === "nonexistent") return;
2289
+ if (check + 1 < settleChecks) sleep(250);
1633
2290
  }
2291
+ throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`);
1634
2292
  }
2293
+ }
2294
+
2295
+ function installWindows(): void {
2296
+ recordWindowsSchedulerOwnership();
2297
+ removeNativeWindowsServiceForScheduler();
1635
2298
  // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
1636
2299
  // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
1637
2300
  try { stopWindows(); } catch { /* not running */ }
@@ -1803,6 +2466,22 @@ export function stopWindows(): void {
1803
2466
  }
1804
2467
  function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
1805
2468
  function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
2469
+
2470
+ /**
2471
+ * Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
2472
+ * `schtasks /end` ends the task instance but often leaves wscript/cmd running the
2473
+ * `:loop` batch, which brings the proxy back during a stop or restart.
2474
+ *
2475
+ * The matching rule — canonical paths of THIS installation, as complete
2476
+ * command-line tokens — lives in lib/windows-service-wrappers so the update job
2477
+ * cannot drift away from it again.
2478
+ */
2479
+ function killWindowsServiceWrapperProcesses(): void {
2480
+ killWindowsSchedulerWrappers({
2481
+ scriptPath: windowsServiceScriptPath(),
2482
+ launcherPath: windowsLauncherVbsPath(),
2483
+ });
2484
+ }
1806
2485
  function uninstallWindows(): void {
1807
2486
  const probe = probeWindowsSchedulerTask(TASK);
1808
2487
  if (probe.status === "present") {
@@ -1836,7 +2515,7 @@ export function bakedServicePathsDiagnostic(): string | null {
1836
2515
  if (!state?.bunPath || !state?.cliPath) return null;
1837
2516
  const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
1838
2517
  if (missing.length === 0) return null;
1839
- return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service install' to re-bake`;
2518
+ return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
1840
2519
  }
1841
2520
 
1842
2521
  function serviceDiagnosticsSummary(): string {
@@ -1853,17 +2532,22 @@ function unitPath(): string {
1853
2532
  return join(unitDir(), `${TASK}.service`);
1854
2533
  }
1855
2534
 
1856
- export function buildUnit(): string {
1857
- const { bun, cli } = cliEntry();
2535
+ export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
2536
+ const { bun, bunRuntimeSource, cli } = cliEntry();
1858
2537
  const log = logPath();
1859
2538
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1860
2539
  const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
2540
+ const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
1861
2541
  const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
1862
2542
  const envLines = [
1863
2543
  systemdEnvironmentAssignment("OCX_SERVICE", "1"),
2544
+ systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
2545
+ systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
1864
2546
  systemdEnvironmentAssignment("PATH", path),
1865
2547
  codexHome,
2548
+ codexSqliteHome,
1866
2549
  opencodexHome,
2550
+ ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
1867
2551
  ].filter((line): line is string => Boolean(line)).join("\n");
1868
2552
  return `[Unit]
1869
2553
  Description=OpenCodex Proxy Server
@@ -1924,7 +2608,7 @@ function installSystemd(): void {
1924
2608
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1925
2609
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1926
2610
  writeServiceApiTokenFile();
1927
- writeFileSync(unitPath(), buildUnit(), "utf8");
2611
+ writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
1928
2612
  sh("systemctl --user daemon-reload");
1929
2613
  sh(`systemctl --user enable ${TASK}`);
1930
2614
  sh(`systemctl --user restart ${TASK}`);
@@ -1987,6 +2671,11 @@ type ServiceOps = {
1987
2671
  status: () => string; uninstall: () => void;
1988
2672
  };
1989
2673
 
2674
+ type ServiceInstallCleanupOps = {
2675
+ status: () => string | null;
2676
+ stop: () => void;
2677
+ };
2678
+
1990
2679
  function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
1991
2680
  if (process.platform === "darwin")
1992
2681
  return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
@@ -2012,6 +2701,67 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
2012
2701
  return null;
2013
2702
  }
2014
2703
 
2704
+ /**
2705
+ * Install-only manager operations. Unlike the ordinary status/stop helpers, these
2706
+ * distinguish confirmed absence from a failed manager query and propagate every
2707
+ * non-benign stop failure. Installing new assets is unsafe while either answer is
2708
+ * unknown because an old manager may still respawn a listener on the target port.
2709
+ */
2710
+ function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
2711
+ if (process.platform === "darwin") {
2712
+ return {
2713
+ status: () => {
2714
+ const listing = sh("launchctl list");
2715
+ return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null;
2716
+ },
2717
+ stop: () => { sh(`launchctl unload "${plistPath()}"`); },
2718
+ };
2719
+ }
2720
+ if (process.platform === "win32") {
2721
+ if (backend === "native") {
2722
+ return {
2723
+ status: () => {
2724
+ const status = statusWinswRaw();
2725
+ if (status === "unknown") throw new Error("Native service status could not be verified.");
2726
+ return status === "nonexistent" ? null : status;
2727
+ },
2728
+ stop: stopWinswService,
2729
+ };
2730
+ }
2731
+ return {
2732
+ status: () => {
2733
+ const probe = probeWindowsSchedulerTask(TASK);
2734
+ if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`);
2735
+ return probe.status === "present" ? "present" : null;
2736
+ },
2737
+ stop: () => {
2738
+ try {
2739
+ schtasks(["/end", "/tn", TASK]);
2740
+ } catch (error) {
2741
+ if (!isWindowsSchedulerEndBenign(error)) throw error;
2742
+ }
2743
+ },
2744
+ };
2745
+ }
2746
+ if (process.platform === "linux") {
2747
+ return {
2748
+ status: () => {
2749
+ // `list-unit-files <name>` exits non-zero when the unit has never been
2750
+ // installed, which made a clean first install look like an unknown manager
2751
+ // failure. `show LoadState` gives us the tri-state we actually need: a
2752
+ // healthy user manager returns `not-found` for a missing unit, while an
2753
+ // unreachable/permission-denied manager still makes `sh()` throw and the
2754
+ // caller therefore fails closed.
2755
+ const loadState = sh(`systemctl --user show ${TASK} --property=LoadState --value`).trim().toLowerCase();
2756
+ if (!loadState) throw new Error("systemd service status could not be verified.");
2757
+ return loadState === "not-found" ? null : loadState;
2758
+ },
2759
+ stop: () => { sh(`systemctl --user stop ${TASK}`); },
2760
+ };
2761
+ }
2762
+ return null;
2763
+ }
2764
+
2015
2765
  type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
2016
2766
 
2017
2767
  function verifiedKillTarget(pid: number | null | undefined): number | null {
@@ -2107,6 +2857,159 @@ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupR
2107
2857
  }
2108
2858
  }
2109
2859
 
2860
+ export interface ServiceInstallPreparationDeps {
2861
+ diagnose?: () => ServiceDiagnostic;
2862
+ managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
2863
+ stopTrackedProxy?: () => Promise<unknown>;
2864
+ platform?: NodeJS.Platform;
2865
+ }
2866
+
2867
+ /**
2868
+ * Stop every manager that could own the install port, then stop the tracked
2869
+ * standalone listener. Any unknown status or cleanup failure rejects, so callers
2870
+ * cannot write assets or report success over a surviving old listener.
2871
+ */
2872
+ export async function prepareServiceInstall(
2873
+ requestedBackend: ServiceBackend,
2874
+ deps: ServiceInstallPreparationDeps = {},
2875
+ ): Promise<void> {
2876
+ const diagnostic = (deps.diagnose ?? diagnoseService)();
2877
+ const platform = deps.platform ?? process.platform;
2878
+ const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
2879
+ const backends: ServiceBackend[] = [];
2880
+ const addBackend = (backend: ServiceBackend) => {
2881
+ if (!backends.includes(backend)) backends.push(backend);
2882
+ };
2883
+
2884
+ if (platform === "win32") {
2885
+ // The recorded backend owns the old installation and must be stopped first.
2886
+ // A conflicting diagnostic means both managers exist, so stop both even when
2887
+ // the requested backend happens to match the recorded one.
2888
+ if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") {
2889
+ addBackend(diagnostic.backend);
2890
+ if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler");
2891
+ }
2892
+ addBackend(requestedBackend);
2893
+ } else {
2894
+ addBackend(requestedBackend);
2895
+ }
2896
+
2897
+ for (const backend of backends) {
2898
+ const manager = resolveOps(backend);
2899
+ if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
2900
+ if (manager.status() !== null) manager.stop();
2901
+ }
2902
+ await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
2903
+ }
2904
+
2905
+ export async function installServiceSafely(
2906
+ requestedBackend: ServiceBackend,
2907
+ install: () => void | Promise<void>,
2908
+ deps: ServiceInstallPreparationDeps = {},
2909
+ ): Promise<void> {
2910
+ await prepareServiceInstall(requestedBackend, deps);
2911
+ await install();
2912
+ }
2913
+
2914
+ export interface FreshWindowsSchedulerInstallDeps {
2915
+ stageRegistrationXml?: (attemptNonce: string) => string;
2916
+ register?: (xmlPath: string, attemptNonce: string) => Promise<void>;
2917
+ recordOwnership?: () => boolean;
2918
+ prepare?: () => Promise<void>;
2919
+ removeNativeService?: () => void;
2920
+ publishAssets?: () => void;
2921
+ runTask?: () => void;
2922
+ writeState?: () => void;
2923
+ rollbackTask?: (attemptNonce: string) => Promise<string | null>;
2924
+ removeStagedXml?: (xmlPath: string) => void;
2925
+ }
2926
+
2927
+ /**
2928
+ * Fresh Windows scheduler install with UAC before the destructive commit.
2929
+ *
2930
+ * The registration is created but never run before `prepare`: UAC cancellation and
2931
+ * create failure therefore cannot stop the existing proxy or trigger its native-routing
2932
+ * cleanup. Rollback proves ownership from the live registration's attempt nonce before
2933
+ * deleting, because the fixed task name can be replaced by another process at any time.
2934
+ */
2935
+ export async function installFreshWindowsSchedulerSafely(
2936
+ deps: FreshWindowsSchedulerInstallDeps = {},
2937
+ ): Promise<void> {
2938
+ const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
2939
+ const register = deps.register ?? registerFreshWindowsSchedulerTask;
2940
+ const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership;
2941
+ const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
2942
+ const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
2943
+ const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
2944
+ const runTask = deps.runTask ?? startWindows;
2945
+ const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
2946
+ const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
2947
+ rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)
2948
+ ));
2949
+ const removeStagedXml = deps.removeStagedXml ?? ((path: string) => {
2950
+ removeWindowsSchedulerRegistrationStage(path);
2951
+ });
2952
+
2953
+ let stagedXml: string | null = null;
2954
+ const attemptNonce = randomUUID();
2955
+ const configRootWasAbsent = !existsSync(getConfigDir());
2956
+ let registered = false;
2957
+ let started = false;
2958
+ try {
2959
+ stagedXml = stage(attemptNonce);
2960
+ await register(stagedXml, attemptNonce);
2961
+ registered = true;
2962
+
2963
+ // The destructive boundary begins only after Task Scheduler accepted the definition.
2964
+ // The registration has consumed its temporary XML. Remove it before claiming a newly
2965
+ // created config root, because ownership initialization intentionally requires emptiness.
2966
+ removeStagedXml(stagedXml);
2967
+ stagedXml = null;
2968
+ const ownershipRecorded = recordOwnership();
2969
+ if (!ownershipRecorded && configRootWasAbsent) {
2970
+ throw new Error(
2971
+ "The fresh OpenCodex config root could not be claimed for safe uninstall; "
2972
+ + "aborting before service-manager cleanup or asset publication.",
2973
+ );
2974
+ }
2975
+ await prepare();
2976
+ removeNativeService();
2977
+ publishAssets();
2978
+ runTask();
2979
+ started = true;
2980
+ writeState();
2981
+ } catch (error) {
2982
+ const detail = error instanceof Error ? error.message : String(error);
2983
+ if (registered && !started) {
2984
+ const rollback = await rollbackTask(attemptNonce);
2985
+ throw new Error(
2986
+ `${detail}\n`
2987
+ + (rollback
2988
+ ? `The new Task Scheduler registration may remain: ${rollback}`
2989
+ : "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."),
2990
+ );
2991
+ }
2992
+ if (started) {
2993
+ throw new Error(
2994
+ `${detail}\nThe scheduler task started, but install state was not published. `
2995
+ + "The task was left in place; inspect `ocx service status` before retrying.",
2996
+ );
2997
+ }
2998
+ throw error;
2999
+ } finally {
3000
+ if (stagedXml) {
3001
+ try { removeStagedXml(stagedXml); } catch (error) {
3002
+ const code = error && typeof error === "object" && "code" in error
3003
+ ? String((error as NodeJS.ErrnoException).code)
3004
+ : "";
3005
+ console.error(
3006
+ `⚠️ Failed to remove the private Task Scheduler staging directory${code ? ` (${code})` : ""}.`,
3007
+ );
3008
+ }
3009
+ }
3010
+ }
3011
+ }
3012
+
2110
3013
  /**
2111
3014
  * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
2112
3015
  * Returns true if a service was found and stopped.
@@ -2128,6 +3031,10 @@ export function stopServiceIfInstalled(): boolean {
2128
3031
  if (statusWinswRaw() !== "nonexistent") {
2129
3032
  try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
2130
3033
  }
3034
+ // `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
3035
+ // respawns its child seconds later (issue #764), resurrecting the proxy during a
3036
+ // stop or a tray restart. Kill the launcher/wrapper processes outright.
3037
+ killWindowsServiceWrapperProcesses();
2131
3038
  if (stopped) return true;
2132
3039
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
2133
3040
  try { stopSystemd(); return true; } catch { return false; }
@@ -2142,33 +3049,53 @@ function removeServiceInstallState(): void {
2142
3049
  }
2143
3050
  }
2144
3051
 
3052
+ type UninstallServiceHooksForTests = {
3053
+ platform: typeof process.platform;
3054
+ assertEnvironment: () => void;
3055
+ probeWindowsTask: () => WindowsSchedulerTaskProbe;
3056
+ uninstallWindowsTask: () => void;
3057
+ nativeStatus: () => WinswStatus;
3058
+ uninstallNative: () => void;
3059
+ removeInstallState: () => void;
3060
+ };
3061
+
3062
+ let uninstallServiceHooksForTests: UninstallServiceHooksForTests | null = null;
3063
+
3064
+ /** Test-only hooks for full-uninstall service removal. */
3065
+ export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksForTests | null): void {
3066
+ uninstallServiceHooksForTests = hooks;
3067
+ }
3068
+
2145
3069
  /**
2146
3070
  * Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
2147
- * when no service exists and never exits the process just because the platform has no service
2148
- * manager.
3071
+ * when no service exists or the platform has no service manager. An installed native Windows
3072
+ * service or scheduler task that cannot be removed throws so the caller cannot erase state and
3073
+ * report success.
2149
3074
  */
2150
3075
  export function uninstallServiceIfInstalled(): boolean {
2151
- assertServiceEnvironmentMatchesInstall();
2152
- if (process.platform === "darwin") {
3076
+ const hooks = uninstallServiceHooksForTests;
3077
+ (hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
3078
+ const platform = hooks?.platform ?? process.platform;
3079
+ if (platform === "darwin") {
2153
3080
  if (existsSync(plistPath())) {
2154
3081
  try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
2155
3082
  }
2156
- } else if (process.platform === "win32") {
3083
+ } else if (platform === "win32") {
2157
3084
  let removed = false;
2158
- try {
2159
- const q = schtasks(["/query", "/tn", TASK]);
2160
- if (q.includes(TASK)) { uninstallWindows(); removed = true; }
2161
- } catch { /* task not found */ }
2162
- if (statusWinswRaw() !== "nonexistent") {
2163
- try {
2164
- uninstallWinswService();
2165
- removed = true;
2166
- } catch (err) {
2167
- console.warn(`⚠️ Failed to remove native service: ${err instanceof Error ? err.message : String(err)}. Check 'sc.exe query ${WINSW_SERVICE_ID}'.`);
2168
- }
3085
+ const scheduler = (hooks?.probeWindowsTask ?? probeWindowsSchedulerTask)();
3086
+ if (scheduler.status === "unknown") {
3087
+ throw new Error(`Could not determine Task Scheduler state: ${scheduler.detail}`);
3088
+ }
3089
+ if (scheduler.status === "present") {
3090
+ (hooks?.uninstallWindowsTask ?? uninstallWindows)();
3091
+ removed = true;
2169
3092
  }
2170
- if (removed) { removeServiceInstallState(); return true; }
2171
- } else if (process.platform === "linux" && existsSync(unitPath())) {
3093
+ if ((hooks?.nativeStatus ?? statusWinswRaw)() !== "nonexistent") {
3094
+ (hooks?.uninstallNative ?? uninstallWinswService)();
3095
+ removed = true;
3096
+ }
3097
+ if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
3098
+ } else if (platform === "linux" && existsSync(unitPath())) {
2172
3099
  try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
2173
3100
  try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
2174
3101
  }
@@ -2251,7 +3178,7 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI
2251
3178
  const detail = conflict
2252
3179
  ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
2253
3180
  : stale
2254
- ? "stale or missing service assets — run 'ocx service install' to repair"
3181
+ ? "stale or missing service assets — run 'ocx service repair'"
2255
3182
  : schedulerInstalled
2256
3183
  ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
2257
3184
  : nativeInstalled
@@ -2373,7 +3300,7 @@ export async function serviceStatusReport(
2373
3300
  : null);
2374
3301
  const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
2375
3302
  ? " launchd is running an OLDER plist than the one on disk.\n"
2376
- + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service install\n`
3303
+ + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
2377
3304
  : "";
2378
3305
 
2379
3306
  return `⚠️ ${diag.summary}\n`
@@ -2385,6 +3312,7 @@ export async function serviceStatusReport(
2385
3312
  }
2386
3313
 
2387
3314
  export function normalizeServiceSubcommand(sub?: string): string {
3315
+ if (sub === "restart") return "repair";
2388
3316
  return sub ?? "install";
2389
3317
  }
2390
3318
 
@@ -2394,6 +3322,119 @@ export interface ParsedServiceArgs {
2394
3322
  invalid: string[];
2395
3323
  }
2396
3324
 
3325
+ export type ServiceInstallationState = "installed" | "absent" | "unknown";
3326
+
3327
+ export interface ServiceInstallationProbe {
3328
+ state: ServiceInstallationState;
3329
+ detail?: string;
3330
+ }
3331
+
3332
+ export interface ServiceInstallationProbeHooks {
3333
+ platform?: NodeJS.Platform;
3334
+ exists?: (path: string) => boolean;
3335
+ probeWindowsTask?: () => WindowsSchedulerTaskProbe;
3336
+ nativeStatus?: () => WinswStatus;
3337
+ }
3338
+
3339
+ /**
3340
+ * Read only enough registration state to choose between install and repair.
3341
+ * Windows must keep query failure distinct from proven absence: treating an
3342
+ * unreadable scheduler/SCM as absent would send a bare command into the
3343
+ * elevated registration path and recreate the original #2287 failure.
3344
+ */
3345
+ export function probeServiceInstallation(
3346
+ hooks: ServiceInstallationProbeHooks = {},
3347
+ ): ServiceInstallationProbe {
3348
+ const platform = hooks.platform ?? process.platform;
3349
+ const exists = hooks.exists ?? existsSync;
3350
+ if (platform === "darwin") {
3351
+ return { state: exists(plistPath()) ? "installed" : "absent" };
3352
+ }
3353
+ if (platform === "linux") {
3354
+ return { state: exists(unitPath()) ? "installed" : "absent" };
3355
+ }
3356
+ if (platform !== "win32") return { state: "absent" };
3357
+
3358
+ let scheduler: WindowsSchedulerTaskProbe;
3359
+ try {
3360
+ scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)();
3361
+ } catch (cause) {
3362
+ scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) };
3363
+ }
3364
+ let native: WinswStatus;
3365
+ try {
3366
+ native = (hooks.nativeStatus ?? statusWinswRaw)();
3367
+ } catch {
3368
+ native = "unknown";
3369
+ }
3370
+
3371
+ if (scheduler.status === "present" || native === "started" || native === "stopped") {
3372
+ return { state: "installed" };
3373
+ }
3374
+ if (scheduler.status === "unknown" || native === "unknown") {
3375
+ const parts = [
3376
+ scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null,
3377
+ native === "unknown" ? "WinSW status could not be determined" : null,
3378
+ ].filter((part): part is string => Boolean(part));
3379
+ return { state: "unknown", detail: parts.join("; ") };
3380
+ }
3381
+ return { state: "absent" };
3382
+ }
3383
+
3384
+ /**
3385
+ * A bare invocation is an idempotent "make the installed service current"
3386
+ * operation. First-time setup still installs, but an existing registration must
3387
+ * use the repair path so Windows does not re-run the elevated `schtasks /create`.
3388
+ * Backend flags remain an explicit install request because they select which
3389
+ * registration mechanism to create.
3390
+ */
3391
+ export function selectServiceSubcommand(
3392
+ parsed: ParsedServiceArgs,
3393
+ options: { hasExplicitSubcommand: boolean; installed: boolean },
3394
+ ): string {
3395
+ if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair";
3396
+ return parsed.sub;
3397
+ }
3398
+
3399
+ export type ServiceCommandPlan =
3400
+ | { ok: true; parsed: ParsedServiceArgs; command: string }
3401
+ | { ok: false; message: string };
3402
+
3403
+ export function planServiceCommand(
3404
+ args: string[],
3405
+ options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {},
3406
+ ): ServiceCommandPlan {
3407
+ const parsed = parseServiceArgs(args);
3408
+ if (parsed.invalid.length > 0) {
3409
+ return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
3410
+ }
3411
+ if (parsed.backend && parsed.sub !== "install") {
3412
+ return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
3413
+ }
3414
+ if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") {
3415
+ return { ok: false, message: "--native (WinSW) is Windows-only." };
3416
+ }
3417
+
3418
+ const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--"));
3419
+ let installed = false;
3420
+ if (!hasExplicitSubcommand && parsed.backend === null) {
3421
+ const probe = (options.probeInstallation ?? probeServiceInstallation)();
3422
+ if (probe.state === "unknown") {
3423
+ const suffix = probe.detail ? ` (${probe.detail})` : "";
3424
+ return {
3425
+ ok: false,
3426
+ message: `Could not safely determine whether the service is installed${suffix}. Run 'ocx service status' and retry; use explicit 'ocx service install' only after confirming it is absent.`,
3427
+ };
3428
+ }
3429
+ installed = probe.state === "installed";
3430
+ }
3431
+ return {
3432
+ ok: true,
3433
+ parsed,
3434
+ command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }),
3435
+ };
3436
+ }
3437
+
2397
3438
  /**
2398
3439
  * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
2399
3440
  * subcommand; backend flags are only meaningful for `install` (validated by the caller).
@@ -2419,20 +3460,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
2419
3460
  }
2420
3461
 
2421
3462
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
2422
- const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a)));
2423
- const command = parsed.sub;
2424
- if (parsed.invalid.length > 0) {
2425
- console.error(`Unknown service option: ${parsed.invalid.join(" ")}`);
2426
- process.exit(1);
2427
- }
2428
- if (parsed.backend && command !== "install") {
2429
- console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend.");
2430
- process.exit(1);
2431
- }
2432
- if (parsed.backend === "native" && process.platform !== "win32") {
2433
- console.error("--native (WinSW) is Windows-only.");
3463
+ const filteredArgs = args.filter((a): a is string => Boolean(a));
3464
+ const plan = planServiceCommand(filteredArgs);
3465
+ if (!plan.ok) {
3466
+ console.error(plan.message);
2434
3467
  process.exit(1);
2435
3468
  }
3469
+ const { parsed, command } = plan;
2436
3470
  if (command === "repair") {
2437
3471
  assertServiceEnvironmentMatchesInstall();
2438
3472
  assertServiceAuthEnvironment();
@@ -2455,7 +3489,31 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2455
3489
  case "install":
2456
3490
  assertServiceEnvironmentMatchesInstall();
2457
3491
  assertServiceAuthEnvironment();
2458
- await ops.install();
3492
+ // A manually started proxy can still own the configured port while the service
3493
+ // registration is absent or unloaded. Stop both the registered manager and any
3494
+ // tracked standalone listener before loading the freshly written service assets.
3495
+ // Otherwise launchd/Task Scheduler can register successfully while its child
3496
+ // restart-loops on EADDRINUSE, and the old standalone process makes the install
3497
+ // verification report a false success.
3498
+ try {
3499
+ if (process.platform === "win32" && backend === "scheduler") {
3500
+ const scheduler = probeWindowsSchedulerTask(TASK);
3501
+ if (scheduler.status === "unknown") {
3502
+ throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
3503
+ }
3504
+ if (scheduler.status === "absent") {
3505
+ await installFreshWindowsSchedulerSafely();
3506
+ } else {
3507
+ await installServiceSafely(backend, ops.install);
3508
+ }
3509
+ } else {
3510
+ await installServiceSafely(backend, ops.install);
3511
+ }
3512
+ } catch (error) {
3513
+ console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
3514
+ process.exitCode = 1;
3515
+ break;
3516
+ }
2459
3517
  // The wrapper was written moments ago in this process, so the configured port
2460
3518
  // and the baked one cannot have diverged yet — unlike `start`, which reads the
2461
3519
  // installed artifact instead.
@@ -2494,7 +3552,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2494
3552
  process.exitCode = 1;
2495
3553
  break;
2496
3554
  }
2497
- const restore = restoreNativeCodex();
3555
+ const restore = await restoreNativeCodexAsync();
2498
3556
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
2499
3557
  else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
2500
3558
  // The Grok fence is the other managed config this command owns. Leaving it behind
@@ -2532,7 +3590,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2532
3590
  process.exit(1);
2533
3591
  }
2534
3592
  {
2535
- const restore = restoreNativeCodex();
3593
+ const restore = await restoreNativeCodexAsync();
2536
3594
  if (!restore.success) {
2537
3595
  console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
2538
3596
  }
@@ -2545,9 +3603,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2545
3603
  console.log("✅ service uninstalled.");
2546
3604
  break;
2547
3605
  default:
2548
- console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
2549
- console.error(" With no subcommand, installs/updates and starts the background service.");
3606
+ console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
3607
+ console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
2550
3608
  console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
3609
+ console.error(" restart: alias of repair.");
2551
3610
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
2552
3611
  process.exit(1);
2553
3612
  }