@iislee/opencodex 2.11.0 → 2.31.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 (675) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +114 -19
  3. package/bin/ocx.mjs +164 -36
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-BF38heuV.js +104 -0
  6. package/gui/dist/assets/index-DMiI18Kv.css +1 -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 +19 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +376 -52
  44. package/src/adapters/base.ts +54 -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 +601 -0
  48. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  49. package/src/adapters/cursor/cursor-errors.ts +129 -6
  50. package/src/adapters/cursor/discovery.ts +61 -4
  51. package/src/adapters/cursor/effort-map.ts +27 -3
  52. package/src/adapters/cursor/framing.ts +39 -0
  53. package/src/adapters/cursor/h2-pool.ts +123 -0
  54. package/src/adapters/cursor/http1-bidi.ts +361 -0
  55. package/src/adapters/cursor/images.ts +704 -0
  56. package/src/adapters/cursor/live-models.ts +174 -58
  57. package/src/adapters/cursor/live-transport.ts +609 -170
  58. package/src/adapters/cursor/native-exec-common.ts +23 -2
  59. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  60. package/src/adapters/cursor/native-exec-network.ts +1 -1
  61. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  62. package/src/adapters/cursor/native-exec.ts +101 -14
  63. package/src/adapters/cursor/protobuf-events.ts +829 -11
  64. package/src/adapters/cursor/protobuf-request.ts +383 -65
  65. package/src/adapters/cursor/request-builder.ts +237 -31
  66. package/src/adapters/cursor/tool-definitions.ts +242 -12
  67. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  68. package/src/adapters/cursor/transport.ts +22 -0
  69. package/src/adapters/cursor/types.ts +28 -1
  70. package/src/adapters/cursor.ts +133 -7
  71. package/src/adapters/google-antigravity-replay.ts +558 -34
  72. package/src/adapters/google-antigravity-wire.ts +43 -10
  73. package/src/adapters/google-http.ts +38 -10
  74. package/src/adapters/google-tool-schema.ts +80 -15
  75. package/src/adapters/google-truncation.ts +11 -0
  76. package/src/adapters/google.ts +618 -74
  77. package/src/adapters/identity.ts +39 -6
  78. package/src/adapters/kiro-errors.ts +11 -0
  79. package/src/adapters/kiro-events.ts +19 -1
  80. package/src/adapters/kiro-thinking.ts +18 -2
  81. package/src/adapters/kiro-tools.ts +10 -1
  82. package/src/adapters/kiro.ts +92 -54
  83. package/src/adapters/mimo-free.ts +17 -0
  84. package/src/adapters/openai-chat-url.ts +11 -0
  85. package/src/adapters/openai-chat.ts +1311 -336
  86. package/src/adapters/openai-responses-url.ts +16 -0
  87. package/src/adapters/openai-responses.ts +830 -56
  88. package/src/adapters/registry.ts +175 -0
  89. package/src/adapters/responses-tool-schema.ts +67 -0
  90. package/src/adapters/tool-call-id.ts +119 -0
  91. package/src/adapters/tool-catalog-nudge.ts +104 -21
  92. package/src/adapters/xai-web-search.ts +185 -0
  93. package/src/bridge.ts +428 -61
  94. package/src/chat/inbound.ts +43 -19
  95. package/src/chat/outbound.ts +82 -26
  96. package/src/claude/agents-inject.ts +32 -9
  97. package/src/claude/context-windows.ts +21 -5
  98. package/src/claude/desktop-3p.ts +243 -9
  99. package/src/claude/gateway-cache.ts +41 -4
  100. package/src/claude/inbound.ts +72 -3
  101. package/src/claude/model-info.ts +38 -15
  102. package/src/claude/outbound.ts +70 -16
  103. package/src/cli/account-api.ts +35 -1
  104. package/src/cli/account-auth.ts +33 -6
  105. package/src/cli/account-catalog-refresh.ts +14 -0
  106. package/src/cli/account-extended.ts +389 -2
  107. package/src/cli/account-main.ts +317 -0
  108. package/src/cli/account.ts +30 -6
  109. package/src/cli/agent.ts +45 -1
  110. package/src/cli/claude-agent-startup-sync.ts +73 -0
  111. package/src/cli/claude-desktop.ts +29 -4
  112. package/src/cli/claude.ts +103 -34
  113. package/src/cli/codex-log-guard-doctor.ts +103 -0
  114. package/src/cli/codex-shim-autorestore.ts +2 -0
  115. package/src/cli/codex-shim-readiness.ts +76 -0
  116. package/src/cli/combo.ts +8 -0
  117. package/src/cli/config-command.ts +74 -10
  118. package/src/cli/dispatch.ts +593 -0
  119. package/src/cli/doctor.ts +315 -43
  120. package/src/cli/ensure-desired-integrations.ts +152 -0
  121. package/src/cli/export-command.ts +46 -20
  122. package/src/cli/help.ts +20 -271
  123. package/src/cli/index.ts +378 -519
  124. package/src/cli/init.ts +4 -17
  125. package/src/cli/integrations.ts +120 -2
  126. package/src/cli/lab.ts +607 -0
  127. package/src/cli/launcher-context.ts +77 -0
  128. package/src/cli/minimax.ts +497 -0
  129. package/src/cli/models-runtime.ts +35 -2
  130. package/src/cli/models.ts +100 -14
  131. package/src/cli/observe.ts +92 -3
  132. package/src/cli/opencode.ts +4 -2
  133. package/src/cli/provider-runtime.ts +18 -1
  134. package/src/cli/provider.ts +24 -3
  135. package/src/cli/ready.ts +301 -0
  136. package/src/cli/registry.ts +437 -0
  137. package/src/cli/root.ts +86 -0
  138. package/src/cli/route-policy.ts +92 -0
  139. package/src/cli/runtime-api.ts +6 -3
  140. package/src/cli/star-prompt.ts +71 -15
  141. package/src/cli/status.ts +10 -3
  142. package/src/cli/system-restart-client.ts +146 -0
  143. package/src/cli/tray-proxy.ts +153 -6
  144. package/src/cli/v2.ts +105 -10
  145. package/src/cli.ts +1 -1
  146. package/src/clients/config-export.ts +1358 -21
  147. package/src/codex/account-label.ts +14 -1
  148. package/src/codex/account-lifecycle.ts +130 -13
  149. package/src/codex/account-namespaces.ts +49 -3
  150. package/src/codex/account-priority.ts +83 -0
  151. package/src/codex/account-store.ts +29 -2
  152. package/src/codex/account-usability.ts +25 -2
  153. package/src/codex/admission.ts +256 -0
  154. package/src/codex/affinity-debug.ts +162 -0
  155. package/src/codex/app-server-processes.ts +493 -106
  156. package/src/codex/app-server-restart-service.ts +232 -0
  157. package/src/codex/auth-api.ts +849 -242
  158. package/src/codex/auth-collision.ts +5 -3
  159. package/src/codex/auth-context.ts +345 -32
  160. package/src/codex/autostart-health.ts +8 -1
  161. package/src/codex/catalog/account-models.ts +67 -0
  162. package/src/codex/catalog/aggregation.ts +68 -10
  163. package/src/codex/catalog/bundled.ts +331 -33
  164. package/src/codex/catalog/effort.ts +121 -30
  165. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  166. package/src/codex/catalog/kinds.ts +2 -0
  167. package/src/codex/catalog/metadata.ts +529 -45
  168. package/src/codex/catalog/native-models.ts +72 -0
  169. package/src/codex/catalog/parsing.ts +224 -30
  170. package/src/codex/catalog/provider-fetch.ts +1460 -134
  171. package/src/codex/catalog/sync.ts +1449 -186
  172. package/src/codex/catalog-admission.ts +199 -0
  173. package/src/codex/catalog-refresh-status.ts +105 -0
  174. package/src/codex/catalog-write-serialization.ts +242 -0
  175. package/src/codex/catalog.ts +6 -3
  176. package/src/codex/codex-write-lock.ts +384 -0
  177. package/src/codex/convergence-types.ts +614 -0
  178. package/src/codex/convergence.ts +651 -0
  179. package/src/codex/coordinator-doctor.ts +332 -0
  180. package/src/codex/custom-model-catalog-migration.ts +176 -0
  181. package/src/codex/desired-state.ts +230 -0
  182. package/src/codex/features.ts +636 -39
  183. package/src/codex/generation.ts +202 -0
  184. package/src/codex/history-job.ts +407 -0
  185. package/src/codex/history-lock.ts +242 -0
  186. package/src/codex/history-migration-guardian.ts +26 -20
  187. package/src/codex/history-provider.ts +231 -28
  188. package/src/codex/history-transition.ts +105 -0
  189. package/src/codex/history-worker.ts +220 -0
  190. package/src/codex/inject-coordination.ts +290 -0
  191. package/src/codex/inject.ts +1073 -152
  192. package/src/codex/injected-marker.ts +37 -3
  193. package/src/codex/integration-record.ts +266 -0
  194. package/src/codex/internal/catalog-writer.ts +203 -0
  195. package/src/codex/internal/history-writer.ts +80 -0
  196. package/src/codex/journal.ts +66 -4
  197. package/src/codex/log-guard/inspect.ts +506 -0
  198. package/src/codex/log-guard/lock.ts +150 -0
  199. package/src/codex/log-guard/maintenance.ts +403 -0
  200. package/src/codex/log-guard/path-safety.ts +88 -0
  201. package/src/codex/log-guard/policy.ts +44 -0
  202. package/src/codex/log-guard/processes.ts +205 -0
  203. package/src/codex/log-guard/protection.ts +489 -0
  204. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  205. package/src/codex/main-account-cache.ts +24 -0
  206. package/src/codex/main-account.ts +29 -1
  207. package/src/codex/management-convergence.ts +167 -0
  208. package/src/codex/model-cache.ts +56 -10
  209. package/src/codex/model-entitlements.ts +353 -0
  210. package/src/codex/native-main-admission.ts +47 -0
  211. package/src/codex/native-main-auth-temp.ts +187 -0
  212. package/src/codex/native-main-claim.ts +178 -0
  213. package/src/codex/native-main-lock-file.ts +162 -0
  214. package/src/codex/native-main-owner.ts +329 -0
  215. package/src/codex/native-profile-api.ts +247 -0
  216. package/src/codex/native-profile-manager.ts +1531 -0
  217. package/src/codex/native-profile-processes.ts +121 -0
  218. package/src/codex/native-profile-recovery.ts +99 -0
  219. package/src/codex/native-profile-stage-store.ts +387 -0
  220. package/src/codex/native-profile-startup.ts +492 -0
  221. package/src/codex/native-profile-store.ts +855 -0
  222. package/src/codex/native-profile-types.ts +120 -0
  223. package/src/codex/native-residue.ts +682 -0
  224. package/src/codex/paths.ts +80 -1
  225. package/src/codex/plan-from-token.ts +140 -0
  226. package/src/codex/plan.ts +40 -0
  227. package/src/codex/plugins-doctor.ts +1 -1
  228. package/src/codex/pool-rotation.ts +74 -4
  229. package/src/codex/project-config-warnings.ts +20 -6
  230. package/src/codex/prompt-journal.ts +352 -0
  231. package/src/codex/prompt-layers.ts +967 -0
  232. package/src/codex/prompt-lock.ts +143 -0
  233. package/src/codex/quota-rejection.ts +298 -0
  234. package/src/codex/quota.ts +175 -13
  235. package/src/codex/refresh.ts +11 -2
  236. package/src/codex/reset-credit-recovery.ts +1044 -0
  237. package/src/codex/routing.ts +505 -94
  238. package/src/codex/runtime.ts +159 -38
  239. package/src/codex/shim.ts +1009 -28
  240. package/src/codex/subagent-model-fallback.ts +350 -35
  241. package/src/codex/sync.ts +191 -2
  242. package/src/codex/transition-state.ts +612 -0
  243. package/src/codex/upstream-host-health.ts +368 -0
  244. package/src/codex/user-identity.ts +557 -0
  245. package/src/codex/warmup.ts +187 -81
  246. package/src/codex/write-coordination.ts +114 -0
  247. package/src/combos/failover.ts +20 -0
  248. package/src/combos/index.ts +4 -0
  249. package/src/combos/request.ts +32 -0
  250. package/src/combos/types.ts +81 -9
  251. package/src/config/provider-name.ts +24 -0
  252. package/src/config.ts +1762 -140
  253. package/src/generated/compatibility-version.json +3164 -0
  254. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  255. package/src/grok/inject.ts +16 -5
  256. package/src/grok/inspect.ts +45 -0
  257. package/src/grok/sync.ts +2 -2
  258. package/src/images/loop.ts +152 -29
  259. package/src/images/plan.ts +23 -13
  260. package/src/integrations/config-io.ts +269 -0
  261. package/src/integrations/journal.ts +315 -0
  262. package/src/integrations/merge.ts +135 -0
  263. package/src/integrations/mutation-flight.ts +71 -0
  264. package/src/integrations/native/ownership-preflight.ts +202 -0
  265. package/src/integrations/omp-yaml-source.ts +358 -0
  266. package/src/integrations/owned-refresh.ts +74 -0
  267. package/src/integrations/ownership.ts +111 -0
  268. package/src/integrations/registry.ts +159 -0
  269. package/src/integrations/serialize.ts +314 -0
  270. package/src/integrations/state.ts +361 -0
  271. package/src/integrations/store.ts +103 -0
  272. package/src/integrations/writer-lock.ts +98 -0
  273. package/src/integrations/writer.ts +691 -0
  274. package/src/lab/artifacts/sanitize.ts +586 -0
  275. package/src/lab/artifacts/secure-fs.ts +475 -0
  276. package/src/lab/artifacts/store.ts +310 -0
  277. package/src/lab/automation/budgets.ts +78 -0
  278. package/src/lab/automation/config-persistence.ts +256 -0
  279. package/src/lab/automation/constants.ts +39 -0
  280. package/src/lab/automation/cooldown.ts +103 -0
  281. package/src/lab/automation/dispatch.ts +211 -0
  282. package/src/lab/automation/index.ts +13 -0
  283. package/src/lab/automation/orchestrator.ts +499 -0
  284. package/src/lab/automation/persistence.ts +512 -0
  285. package/src/lab/automation/planner.ts +371 -0
  286. package/src/lab/automation/policy.ts +136 -0
  287. package/src/lab/automation/queue.ts +191 -0
  288. package/src/lab/automation/recovery.ts +24 -0
  289. package/src/lab/automation/route-context.ts +21 -0
  290. package/src/lab/automation/run-key.ts +44 -0
  291. package/src/lab/automation/runs-query.ts +34 -0
  292. package/src/lab/automation/types.ts +160 -0
  293. package/src/lab/conformance/assertion.ts +325 -0
  294. package/src/lab/conformance/digest.ts +22 -0
  295. package/src/lab/conformance/executor.ts +741 -0
  296. package/src/lab/conformance/fixture-provider.ts +27 -0
  297. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  298. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  299. package/src/lab/conformance/harness-budget.ts +47 -0
  300. package/src/lab/conformance/index.ts +5 -0
  301. package/src/lab/conformance/jcs.ts +64 -0
  302. package/src/lab/conformance/json-pointer.ts +39 -0
  303. package/src/lab/conformance/manifest.ts +180 -0
  304. package/src/lab/conformance/mcp-stub.ts +179 -0
  305. package/src/lab/conformance/negative-controls.ts +164 -0
  306. package/src/lab/conformance/observation.ts +355 -0
  307. package/src/lab/conformance/runner.ts +68 -0
  308. package/src/lab/conformance/sse-normalize.ts +59 -0
  309. package/src/lab/conformance/suite-manifest.ts +78 -0
  310. package/src/lab/conformance/types.ts +214 -0
  311. package/src/lab/constants.ts +126 -0
  312. package/src/lab/digest.ts +64 -0
  313. package/src/lab/events/errors.ts +9 -0
  314. package/src/lab/events/limits.ts +117 -0
  315. package/src/lab/events/types.ts +229 -0
  316. package/src/lab/events/validate.ts +781 -0
  317. package/src/lab/fabric/constants.ts +40 -0
  318. package/src/lab/fabric/executor.ts +492 -0
  319. package/src/lab/fabric/index.ts +80 -0
  320. package/src/lab/fabric/manifest.ts +222 -0
  321. package/src/lab/fabric/observe.ts +489 -0
  322. package/src/lab/fabric/patch.ts +79 -0
  323. package/src/lab/fabric/producer-child.ts +139 -0
  324. package/src/lab/fabric/producer-isolate.ts +276 -0
  325. package/src/lab/fabric/producer-protocol.ts +61 -0
  326. package/src/lab/fabric/scratch.ts +439 -0
  327. package/src/lab/fabric/subject.ts +106 -0
  328. package/src/lab/fabric/types.ts +134 -0
  329. package/src/lab/fabric/verifier.ts +98 -0
  330. package/src/lab/index.ts +54 -0
  331. package/src/lab/ledger/artifact-refs.ts +127 -0
  332. package/src/lab/ledger/invalidation.ts +136 -0
  333. package/src/lab/ledger/purge.ts +310 -0
  334. package/src/lab/ledger/store.ts +532 -0
  335. package/src/lab/live/credential-lease.ts +53 -0
  336. package/src/lab/live/destination.ts +155 -0
  337. package/src/lab/live/executor.ts +336 -0
  338. package/src/lab/live/inert-tools.ts +56 -0
  339. package/src/lab/live/manifest.ts +85 -0
  340. package/src/lab/live/mcp-loopback.ts +57 -0
  341. package/src/lab/live/runner.ts +19 -0
  342. package/src/lab/live/sandbox.ts +61 -0
  343. package/src/lab/live/suite-manifest.ts +41 -0
  344. package/src/lab/live/transport.ts +118 -0
  345. package/src/lab/live/types.ts +197 -0
  346. package/src/lab/observe/from-conformance.ts +301 -0
  347. package/src/lab/observe/from-live.ts +117 -0
  348. package/src/lab/paths.ts +153 -0
  349. package/src/lab/projection/rebuild.ts +495 -0
  350. package/src/lab/projection/schema.ts +135 -0
  351. package/src/lab/projection/verdicts.ts +474 -0
  352. package/src/lab/projection/verification.ts +412 -0
  353. package/src/lab/public/bundle.ts +217 -0
  354. package/src/lab/public/community-authority.ts +175 -0
  355. package/src/lab/public/community-files.ts +29 -0
  356. package/src/lab/public/community.ts +479 -0
  357. package/src/lab/public/file-safety.ts +155 -0
  358. package/src/lab/public/ids.ts +26 -0
  359. package/src/lab/public/index.ts +16 -0
  360. package/src/lab/public/mutation-lock.ts +424 -0
  361. package/src/lab/public/operator.ts +353 -0
  362. package/src/lab/public/origin-purge.ts +79 -0
  363. package/src/lab/public/origin.ts +203 -0
  364. package/src/lab/public/privacy.ts +143 -0
  365. package/src/lab/public/private-file.ts +261 -0
  366. package/src/lab/public/project.ts +124 -0
  367. package/src/lab/public/purge-test-fault.ts +21 -0
  368. package/src/lab/public/purge.ts +223 -0
  369. package/src/lab/public/registry.ts +44 -0
  370. package/src/lab/public/revocation.ts +252 -0
  371. package/src/lab/public/signature.ts +243 -0
  372. package/src/lab/public/storage.ts +105 -0
  373. package/src/lab/public/strict-json.ts +206 -0
  374. package/src/lab/public/time.ts +26 -0
  375. package/src/lab/public/types.ts +172 -0
  376. package/src/lab/public/validate.ts +391 -0
  377. package/src/lab/query/catalog.ts +101 -0
  378. package/src/lab/query/connection.ts +107 -0
  379. package/src/lab/query/constants.ts +4 -0
  380. package/src/lab/query/cursor.ts +132 -0
  381. package/src/lab/query/dto-map.ts +277 -0
  382. package/src/lab/query/errors.ts +22 -0
  383. package/src/lab/query/freshness.ts +53 -0
  384. package/src/lab/query/index.ts +45 -0
  385. package/src/lab/query/latest-observation.ts +59 -0
  386. package/src/lab/query/passive-production.ts +159 -0
  387. package/src/lab/query/queries.ts +444 -0
  388. package/src/lab/query/types.ts +266 -0
  389. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  390. package/src/lab/subject/installation-salt.ts +112 -0
  391. package/src/lab/subject/protocol-subject.ts +80 -0
  392. package/src/lab/subject/route-subject.ts +74 -0
  393. package/src/lib/app-owned-memory-stores.ts +22 -0
  394. package/src/lib/bounded-body.ts +153 -9
  395. package/src/lib/bun-runtime.ts +125 -12
  396. package/src/lib/bun-stream-caps.ts +13 -9
  397. package/src/lib/codex-restart-contract.ts +120 -0
  398. package/src/lib/config-ownership.ts +6 -2
  399. package/src/lib/destination-policy.ts +65 -1
  400. package/src/lib/errors.ts +44 -2
  401. package/src/lib/fabric-task-execution-authority.ts +7 -0
  402. package/src/lib/fabric-task-host.ts +29 -0
  403. package/src/lib/lab-activation.ts +223 -0
  404. package/src/lib/lab-live-execution-authority.ts +13 -0
  405. package/src/lib/lab-live-host.ts +30 -0
  406. package/src/lib/lab-live-pinned-sender.ts +56 -0
  407. package/src/lib/lab-live-route-production.ts +130 -0
  408. package/src/lib/lab-passive-linker-registration.ts +26 -0
  409. package/src/lib/local-management-attestation.ts +51 -0
  410. package/src/lib/local-management-capability.ts +100 -0
  411. package/src/lib/local-provider-reload-contract.ts +100 -0
  412. package/src/lib/optional-shutdown-hooks.ts +57 -0
  413. package/src/lib/pinned-http.ts +145 -26
  414. package/src/lib/process-control.ts +4 -1
  415. package/src/lib/provider-outbound.ts +49 -9
  416. package/src/lib/redact.ts +419 -3
  417. package/src/lib/self-launch-argv.ts +15 -0
  418. package/src/lib/server-resource-ownership.ts +71 -0
  419. package/src/lib/shadow-call.ts +35 -4
  420. package/src/lib/sse-decoder.ts +41 -0
  421. package/src/lib/state-store-registrations.ts +10 -2
  422. package/src/lib/system-restart-contract.ts +73 -0
  423. package/src/lib/token-estimate.ts +19 -2
  424. package/src/lib/tool-argument-integers.ts +202 -0
  425. package/src/lib/translator-budget.ts +44 -0
  426. package/src/lib/upstream-http-version.ts +57 -0
  427. package/src/lib/upstream-reachability.ts +95 -0
  428. package/src/lib/upstream-retry.ts +156 -3
  429. package/src/lib/windows-atomic-replace.ts +155 -0
  430. package/src/lib/windows-elevation.ts +70 -2
  431. package/src/lib/windows-secret-acl.ts +409 -69
  432. package/src/lib/windows-service-wrappers.ts +72 -0
  433. package/src/lib/windows-text.ts +106 -0
  434. package/src/lib/windows-user-principal.ts +341 -0
  435. package/src/lib/winsw.ts +33 -5
  436. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  437. package/src/oauth/account-import/index.ts +15 -0
  438. package/src/oauth/account-import/parser.ts +83 -0
  439. package/src/oauth/account-import/registry.ts +18 -0
  440. package/src/oauth/account-import/service.ts +75 -0
  441. package/src/oauth/account-import/types.ts +91 -0
  442. package/src/oauth/anthropic.ts +12 -1
  443. package/src/oauth/callback-server.ts +8 -2
  444. package/src/oauth/chatgpt.ts +12 -1
  445. package/src/oauth/command-code.ts +239 -0
  446. package/src/oauth/cursor.ts +46 -5
  447. package/src/oauth/google-antigravity.ts +35 -3
  448. package/src/oauth/health.ts +20 -12
  449. package/src/oauth/index.ts +398 -66
  450. package/src/oauth/key-providers.ts +16 -0
  451. package/src/oauth/kimi.ts +16 -2
  452. package/src/oauth/kiro.ts +50 -6
  453. package/src/oauth/local-token-detect.ts +11 -2
  454. package/src/oauth/log.ts +3 -1
  455. package/src/oauth/login-cli.ts +88 -28
  456. package/src/oauth/nous.ts +798 -0
  457. package/src/oauth/store.ts +119 -21
  458. package/src/oauth/token-guardian.ts +9 -3
  459. package/src/pi/models.ts +2 -2
  460. package/src/providers/alibaba-region-migration.ts +1 -1
  461. package/src/providers/antigravity-models.ts +521 -31
  462. package/src/providers/base-url-choices.ts +10 -0
  463. package/src/providers/codex-capacity.ts +292 -0
  464. package/src/providers/command-code-efforts.ts +144 -0
  465. package/src/providers/context-cap.ts +22 -5
  466. package/src/providers/cursor-pool.ts +72 -0
  467. package/src/providers/derive.ts +253 -6
  468. package/src/providers/fastwire.ts +501 -0
  469. package/src/providers/free-directory.ts +10 -7
  470. package/src/providers/google-vertex-location.ts +14 -0
  471. package/src/providers/key-failover.ts +71 -3
  472. package/src/providers/label.ts +1 -1
  473. package/src/providers/model-discovery-limits.ts +16 -0
  474. package/src/providers/model-discovery.ts +115 -22
  475. package/src/providers/model-rename-migration.ts +255 -0
  476. package/src/providers/model-rename-startup.ts +28 -0
  477. package/src/providers/openai-sidecar.ts +72 -4
  478. package/src/providers/openai-tier-startup.ts +31 -2
  479. package/src/providers/openai-tiers.ts +119 -4
  480. package/src/providers/openai-virtual-models.ts +1 -0
  481. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  482. package/src/providers/provider-id-rewrite.ts +29 -0
  483. package/src/providers/quota.ts +1319 -38
  484. package/src/providers/registry.ts +1429 -111
  485. package/src/providers/request-pacing.ts +310 -0
  486. package/src/providers/service-tier.ts +277 -0
  487. package/src/providers/slug-codec.ts +42 -6
  488. package/src/providers/static-model-discovery.ts +86 -0
  489. package/src/providers/xai-responses-opt-in.ts +15 -0
  490. package/src/providers/xai-transport.ts +11 -4
  491. package/src/reasoning-effort.ts +49 -1
  492. package/src/responses/compaction.ts +26 -1
  493. package/src/responses/custom-tool-compat.ts +266 -0
  494. package/src/responses/hosted-tool-policy.ts +9 -0
  495. package/src/responses/namespace-tool-compat.ts +355 -0
  496. package/src/responses/parser.ts +220 -38
  497. package/src/responses/provider-continuation.ts +98 -0
  498. package/src/responses/provider-opaque-metadata.ts +73 -0
  499. package/src/responses/reasoning-envelope.ts +9 -1
  500. package/src/responses/reasoning-replay-cache.ts +426 -0
  501. package/src/responses/schema.ts +7 -1
  502. package/src/responses/spill-store.ts +75 -10
  503. package/src/responses/state.ts +565 -27
  504. package/src/responses/thought-signature-replay.ts +347 -0
  505. package/src/responses/tool-search-compat.ts +301 -0
  506. package/src/responses/truncated-stop-reason.ts +60 -0
  507. package/src/router.ts +366 -30
  508. package/src/routing/analytics.ts +378 -0
  509. package/src/routing/capability.ts +244 -0
  510. package/src/routing/compatibility/assemble.ts +73 -0
  511. package/src/routing/compatibility/behavior.ts +278 -0
  512. package/src/routing/compatibility/catalog.ts +99 -0
  513. package/src/routing/compatibility/endpoint.ts +52 -0
  514. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  515. package/src/routing/compatibility/policy.ts +181 -0
  516. package/src/routing/compatibility/provider-slot.ts +56 -0
  517. package/src/routing/compatibility/reader.ts +110 -0
  518. package/src/routing/compatibility/subject.ts +191 -0
  519. package/src/routing/compatibility/types.ts +64 -0
  520. package/src/routing/compatibility/version.ts +104 -0
  521. package/src/routing/cost.ts +77 -0
  522. package/src/routing/evaluator.ts +495 -0
  523. package/src/routing/health.ts +412 -0
  524. package/src/routing/history/cursor.ts +43 -0
  525. package/src/routing/history/indexer.ts +605 -0
  526. package/src/routing/history/schema.ts +72 -0
  527. package/src/routing/profile-namespace.ts +15 -0
  528. package/src/routing/profile.ts +547 -0
  529. package/src/routing/quota.ts +145 -0
  530. package/src/routing/request-evidence.ts +45 -0
  531. package/src/routing/trace.ts +776 -0
  532. package/src/server/adapter-resolve.ts +2 -29
  533. package/src/server/auth-cors.ts +267 -46
  534. package/src/server/background-lifecycle.ts +182 -0
  535. package/src/server/chat-completions.ts +130 -56
  536. package/src/server/chat-native-sse.ts +331 -0
  537. package/src/server/chat-native.ts +426 -0
  538. package/src/server/claude-messages.ts +159 -43
  539. package/src/server/direct-local-http.ts +347 -0
  540. package/src/server/effort-policy.ts +18 -0
  541. package/src/server/github-copilot-responses-repair.ts +338 -0
  542. package/src/server/gui-static.ts +39 -10
  543. package/src/server/images.ts +94 -12
  544. package/src/server/index.ts +865 -181
  545. package/src/server/lifecycle.ts +284 -13
  546. package/src/server/live.ts +136 -17
  547. package/src/server/local-management-read-client.ts +90 -0
  548. package/src/server/local-provider-reload-client.ts +137 -0
  549. package/src/server/management/agent-settings-routes.ts +398 -103
  550. package/src/server/management/api-key-usage.ts +31 -5
  551. package/src/server/management/body.ts +6 -0
  552. package/src/server/management/combo-routes.ts +62 -24
  553. package/src/server/management/config-routes.ts +464 -51
  554. package/src/server/management/context.ts +80 -2
  555. package/src/server/management/integration-routes.ts +498 -0
  556. package/src/server/management/lab-automation-routes.ts +206 -0
  557. package/src/server/management/lab-routes.ts +563 -0
  558. package/src/server/management/logs-usage-routes.ts +101 -32
  559. package/src/server/management/model-routes.ts +189 -131
  560. package/src/server/management/model-rows.ts +163 -0
  561. package/src/server/management/native-integration-routes.ts +769 -0
  562. package/src/server/management/oauth-account-routes.ts +80 -4
  563. package/src/server/management/provider-capability-config.ts +48 -0
  564. package/src/server/management/provider-routes.ts +764 -157
  565. package/src/server/management/request-history-routes.ts +191 -0
  566. package/src/server/management/routing-analytics-routes.ts +74 -0
  567. package/src/server/management/routing-profile-routes.ts +380 -0
  568. package/src/server/management/shared.ts +27 -11
  569. package/src/server/management/sidebar-routes.ts +47 -31
  570. package/src/server/management/storage-log-guard-routes.ts +186 -0
  571. package/src/server/management/sync-response.ts +69 -0
  572. package/src/server/management/system-restart.ts +288 -32
  573. package/src/server/management/system-routes.ts +77 -0
  574. package/src/server/management/usage-summary-cache.ts +9 -1
  575. package/src/server/management/vision-sidecar-options.ts +167 -0
  576. package/src/server/management/web-search-sidecar-options.ts +120 -0
  577. package/src/server/management-api.ts +115 -14
  578. package/src/server/management-auth.ts +220 -5
  579. package/src/server/passive-route-linker.ts +66 -0
  580. package/src/server/ports.ts +41 -1
  581. package/src/server/proxy-liveness.ts +132 -5
  582. package/src/server/readiness.ts +99 -0
  583. package/src/server/relay-eager.ts +82 -42
  584. package/src/server/relay.ts +236 -76
  585. package/src/server/request-decompress.ts +113 -6
  586. package/src/server/request-log.ts +235 -22
  587. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  588. package/src/server/responses/agent-task-recovery.ts +465 -0
  589. package/src/server/responses/collaboration.ts +204 -35
  590. package/src/server/responses/compact.ts +442 -55
  591. package/src/server/responses/core.ts +2872 -331
  592. package/src/server/responses/empty-completion-guard.ts +276 -0
  593. package/src/server/responses/encrypted-payload.ts +62 -39
  594. package/src/server/responses/fetch-helpers.ts +79 -4
  595. package/src/server/responses/input-admission.ts +185 -0
  596. package/src/server/responses/pacing-overload.ts +13 -0
  597. package/src/server/responses/policy-fallback.ts +178 -0
  598. package/src/server/responses/responses-field-backfill.ts +251 -0
  599. package/src/server/responses/terminal-guard.ts +26 -5
  600. package/src/server/responses/upstream-error.ts +5 -0
  601. package/src/server/responses/ws-upstream.ts +308 -0
  602. package/src/server/responses-custom-tool-repair.ts +282 -0
  603. package/src/server/responses-item-id-repair.ts +54 -6
  604. package/src/server/responses-json-events.ts +90 -0
  605. package/src/server/responses-model-rewrite.ts +29 -0
  606. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  607. package/src/server/responses-snapshot-repair.ts +621 -0
  608. package/src/server/responses-terminal-repair.ts +342 -0
  609. package/src/server/responses-tool-search-repair.ts +267 -0
  610. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  611. package/src/server/responses.ts +18 -2
  612. package/src/server/search.ts +78 -13
  613. package/src/server/sse-frame-buffer.ts +292 -0
  614. package/src/server/sse-payload-rewrite.ts +110 -22
  615. package/src/server/startup-action-control.ts +8 -1
  616. package/src/server/startup-health-cache.ts +19 -1
  617. package/src/server/system-env.ts +80 -9
  618. package/src/server/ws-bridge.ts +39 -38
  619. package/src/service-manager-probe.ts +892 -0
  620. package/src/service.ts +1111 -90
  621. package/src/sidecar/auth.ts +92 -0
  622. package/src/sidecar/candidates.ts +83 -0
  623. package/src/storage/cleanup.ts +2 -2
  624. package/src/storage/scanner.ts +1 -1
  625. package/src/storage/worker-lifecycle.ts +14 -14
  626. package/src/tray/windows-tray.ps1 +83 -9
  627. package/src/tray/windows.ts +43 -16
  628. package/src/types/accounts.ts +37 -0
  629. package/src/types/config.ts +845 -0
  630. package/src/types/provider.ts +545 -0
  631. package/src/types/request.ts +384 -0
  632. package/src/types/tools.ts +131 -0
  633. package/src/types/wire.ts +80 -0
  634. package/src/types.ts +104 -1236
  635. package/src/update/index.ts +32 -19
  636. package/src/update/job.ts +442 -67
  637. package/src/update/notify.ts +12 -6
  638. package/src/update/npm-cache-preflight.d.mts +47 -0
  639. package/src/update/npm-cache-preflight.mjs +201 -0
  640. package/src/update/transactional-install.d.mts +22 -0
  641. package/src/update/transactional-install.mjs +259 -0
  642. package/src/usage/cost.ts +0 -0
  643. package/src/usage/expected-prices.ts +268 -16
  644. package/src/usage/log.ts +606 -41
  645. package/src/usage/summary.ts +177 -9
  646. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  647. package/src/usage/user-cost-overlays.ts +314 -0
  648. package/src/vision/anthropic-describe.ts +10 -6
  649. package/src/vision/backends.ts +97 -0
  650. package/src/vision/describe.ts +9 -3
  651. package/src/vision/eligibility.ts +250 -0
  652. package/src/vision/index.ts +238 -24
  653. package/src/vision/reasoning.ts +55 -0
  654. package/src/vision/routed-describe.ts +175 -0
  655. package/src/vision/timeout-bounds.ts +9 -0
  656. package/src/web-search/anthropic-executor.ts +13 -7
  657. package/src/web-search/backends.ts +108 -0
  658. package/src/web-search/exa-executor.ts +88 -0
  659. package/src/web-search/executor.ts +11 -3
  660. package/src/web-search/gemini-executor.ts +141 -0
  661. package/src/web-search/index.ts +150 -15
  662. package/src/web-search/loop.ts +279 -50
  663. package/src/web-search/parse.ts +125 -30
  664. package/src/web-search/sources.ts +60 -0
  665. package/src/web-search/xai-executor.ts +219 -0
  666. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  667. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  668. package/gui/dist/provider-icons/antigravity.svg +0 -1
  669. package/gui/dist/provider-icons/claude.svg +0 -1
  670. package/gui/dist/provider-icons/copilot.svg +0 -1
  671. package/gui/dist/provider-icons/cursor.svg +0 -2
  672. package/gui/dist/provider-icons/gemini.svg +0 -1
  673. package/gui/dist/provider-icons/grok-color.svg +0 -1
  674. package/gui/dist/provider-icons/kiro.svg +0 -14
  675. package/src/cli/internal-dispatch.ts +0 -20
package/src/service.ts CHANGED
@@ -7,35 +7,46 @@
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";
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
13
  import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
14
14
  import { loadConfig } from "./config";
15
- import { restoreNativeCodex } from "./codex/inject";
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 { PROXY_ENV_KEYS } from "./lib/proxy-env";
21
23
  import { randomUUID } from "node:crypto";
22
24
  import {
23
25
  ELEVATION_REQUEST_TIMEOUT_MS,
24
26
  OCX_ELEVATED_PROTOCOL_FAILED,
25
27
  raceWithTimeout,
28
+ resolveTrustedWindowsPowerShellExe,
26
29
  resolveTrustedWindowsSchtasksExe,
27
30
  startElevatedSchtasksCreateAndRun,
28
31
  runWindowsElevated,
32
+ runWindowsElevatedScheduledTaskRegistration,
29
33
  toWindowsSchtasksError,
30
34
  WindowsElevationError,
35
+ WindowsSchtasksError,
31
36
  type ElevatedSchedulerOutcome,
32
37
  type ElevatedSchtasksCreateAndRunExecution,
33
38
  type ElevatedSchtasksCreateAndRunResult,
34
39
  } 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";
40
+ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION, type WinswStatus } from "./lib/winsw";
41
+ import {
42
+ forgetEphemeralSecretDir,
43
+ forgetEphemeralSecretPath,
44
+ hardenSecretDir,
45
+ hardenSecretPath,
46
+ } from "./lib/windows-secret-acl";
37
47
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
38
48
  import { recordOwnedConfigPath } from "./lib/config-ownership";
49
+ import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
39
50
  import { maybeShowStarPrompt } from "./cli/star-prompt";
40
51
 
41
52
  const LABEL = "com.opencodex.proxy";
@@ -43,11 +54,15 @@ const TASK = "opencodex-proxy";
43
54
 
44
55
  export type ServiceBackend = "scheduler" | "native";
45
56
 
46
- function cliEntry(): { bun: string; cli: string } {
57
+ function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
47
58
  // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
48
59
  // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
49
60
  // 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") };
61
+ //
62
+ // Path and provenance come from ONE resolution so the marker can never describe a
63
+ // different binary than the one actually baked.
64
+ const runtime = durableBunRuntime();
65
+ return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "cli", "index.ts") };
51
66
  }
52
67
 
53
68
  function plistPath(): string {
@@ -89,9 +104,30 @@ function serviceStatePaths(): string[] {
89
104
  return paths;
90
105
  }
91
106
 
92
- function currentCodexHome(): string {
93
- const raw = process.env.CODEX_HOME?.trim();
94
- return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
107
+ function currentCodexHome(deps: CodexHomeDeps = {}): string {
108
+ // Service ownership must identify the same home as the runtime. In WSL an
109
+ // unset CODEX_HOME can resolve to the single Windows Desktop home rather than
110
+ // Linux ~/.codex; recording the fallback here creates a false foreign owner.
111
+ return resolveCodexHomeDir(deps);
112
+ }
113
+
114
+ function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined {
115
+ const raw = process.env.CODEX_SQLITE_HOME?.trim();
116
+ if (!raw) return undefined;
117
+ const expanded = expandUserPath(raw);
118
+ // Service artifacts can be rendered by cross-platform tests and repair tooling, so an
119
+ // already-absolute path for the TARGET platform is preserved rather than re-anchored
120
+ // against the writing host. `resolve()` is host-relative in both directions: on a POSIX
121
+ // host it turns `C:\data` into `<cwd>/C:\data`, and on a Windows host it turns `/tmp/x`
122
+ // into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves,
123
+ // because a service unit has no meaningful working directory.
124
+ //
125
+ // CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same
126
+ // generated file disagreed with itself about two variables holding the same kind of value.
127
+ if (target === "windows") {
128
+ return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded);
129
+ }
130
+ return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded);
95
131
  }
96
132
 
97
133
  function currentOpenCodexHome(): string {
@@ -169,13 +205,80 @@ function readServiceInstallState(): ServiceInstallState | null {
169
205
  return null;
170
206
  }
171
207
 
172
- /** Single accessor for update/reinstall code v1/legacy state maps to scheduler. */
208
+ /** What ONE state path said. Absent, unreadable and invalid are different answers. */
209
+ export type ServiceStateEvidence =
210
+ | { readonly path: string; readonly kind: "absent" }
211
+ | { readonly path: string; readonly kind: "unreadable"; readonly reason: string }
212
+ | { readonly path: string; readonly kind: "invalid" }
213
+ | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState };
214
+
215
+ /**
216
+ * Every state path, with what each one said.
217
+ *
218
+ * `readServiceInstallState` returns the FIRST path that parsed and discards the
219
+ * rest, so a valid mirror beside a corrupt one reads as clean. That is the right
220
+ * behavior for callers that just need the install state; it is the wrong input
221
+ * for deciding ownership, where a disagreement between mirrors is exactly the
222
+ * evidence that matters.
223
+ */
224
+ export function inspectServiceStateEvidence(
225
+ paths: readonly string[] = serviceStatePaths(),
226
+ ): readonly ServiceStateEvidence[] {
227
+ return paths.map((path): ServiceStateEvidence => {
228
+ let raw: string;
229
+ try {
230
+ raw = readFileSync(path, "utf8");
231
+ } catch (error) {
232
+ const code = error && typeof error === "object" && "code" in error
233
+ ? String((error as { code?: unknown }).code)
234
+ : "";
235
+ // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask,
236
+ // and collapsing them into absence is how a locked-down state file would
237
+ // become permission to write.
238
+ if (code === "ENOENT") return { path, kind: "absent" };
239
+ return { path, kind: "unreadable", reason: code || String(error) };
240
+ }
241
+ let parsed: ServiceInstallState | null;
242
+ try {
243
+ parsed = parseServiceInstallState(JSON.parse(raw));
244
+ } catch {
245
+ return { path, kind: "invalid" };
246
+ }
247
+ return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" };
248
+ });
249
+ }
250
+
251
+ /** The homes this process is actually using, for comparison against a claim. */
252
+ export function currentServiceHomes(deps: CodexHomeDeps = {}): { codexHome: string; opencodexHome: string } {
253
+ return { codexHome: currentCodexHome(deps), opencodexHome: currentOpenCodexHome() };
254
+ }
255
+
256
+ export function serviceHomeMatches(a: string, b: string): boolean {
257
+ return normalizePathForCompare(a) === normalizePathForCompare(b);
258
+ }
259
+
260
+ /** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */
173
261
  export function readServiceBackend(): ServiceBackend {
174
262
  return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
175
263
  }
176
264
 
177
- /** The `ocx` argv that reinstalls the currently-chosen service backend (update paths). */
265
+ /**
266
+ * The `ocx` argv that refreshes an already-installed service after an update.
267
+ *
268
+ * `repair` discovers the installed backend itself and, on Windows scheduler installs,
269
+ * rewrites the wrapper assets and restarts the existing task WITHOUT `schtasks /create`
270
+ * (see repairService below). `install` always reaches `/create`, which requires
271
+ * elevation — so an ordinary non-elevated `ocx update` used to stop a working proxy and
272
+ * then fail to bring its service back.
273
+ *
274
+ * The historical export name is kept for callers outside this module.
275
+ */
178
276
  export function serviceReinstallArgs(): string[] {
277
+ return ["service", "repair"];
278
+ }
279
+
280
+ /** The `ocx` argv that registers a service from scratch, preserving the chosen backend. */
281
+ export function serviceInstallArgs(): string[] {
179
282
  return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
180
283
  }
181
284
 
@@ -211,11 +314,12 @@ export function serviceEnvironmentOwnedHere(): boolean {
211
314
  export function assertServiceEnvironmentMatchesInstall(): void {
212
315
  const state = readServiceInstallState();
213
316
  if (!state) return;
317
+ const actualCodexHome = currentCodexHome();
214
318
  const expected = normalizePathForCompare(state.codexHome);
215
- const actual = normalizePathForCompare(currentCodexHome());
319
+ const actual = normalizePathForCompare(actualCodexHome);
216
320
  if (expected !== actual) {
217
321
  throw new ServiceOwnershipError(
218
- `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
322
+ `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
219
323
  "Run the service command from the same Codex home so native Codex restore updates the correct config.",
220
324
  );
221
325
  }
@@ -229,6 +333,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
229
333
  }
230
334
  }
231
335
 
336
+
232
337
  function plistString(value: string): string {
233
338
  return value
234
339
  .replace(/&/g, "&amp;")
@@ -243,13 +348,31 @@ function isLoopbackHostname(hostname: string | undefined): boolean {
243
348
  return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
244
349
  }
245
350
 
351
+ /**
352
+ * The `ocx` command a user should rerun for the service state they actually have.
353
+ *
354
+ * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW
355
+ * conflict outright, so recommending repair there names a command guaranteed to fail.
356
+ * Install IS the valid conflict recovery, because `installWindows` removes the native
357
+ * backend first. Exported so the guard tests the real selector rather than a copy of it.
358
+ */
359
+ export function serviceRetryCommand(
360
+ diag: Pick<ServiceDiagnostic, "installed" | "conflict"> = diagnoseService(),
361
+ ): string {
362
+ return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
363
+ }
364
+
246
365
  export function assertServiceAuthEnvironment(): void {
247
366
  const config = loadConfig();
248
367
  if (isLoopbackHostname(config.hostname)) return;
249
368
  if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
369
+ // Reached from `service repair` as well as `install`, so name a command that can
370
+ // actually succeed (see serviceRetryCommand).
371
+ const diag = diagnoseService();
372
+ const retry = serviceRetryCommand(diag);
250
373
  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`.",
374
+ `OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service `
375
+ + `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`,
253
376
  );
254
377
  }
255
378
 
@@ -267,17 +390,23 @@ function writeServiceApiTokenFile(): string | null {
267
390
  return path;
268
391
  }
269
392
 
270
- export function buildPlist(): string {
271
- const { bun, cli } = cliEntry();
393
+ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
394
+ const { bun, bunRuntimeSource, cli } = cliEntry();
272
395
  const log = logPath();
273
396
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
274
397
  const codexHome = process.env.CODEX_HOME?.trim();
398
+ const codexSqliteHome = currentCodexSqliteHomeAbsolute();
275
399
  const opencodexHome = process.env.OPENCODEX_HOME?.trim();
276
400
  const envLines = [
277
401
  ` <key>OCX_SERVICE</key><string>1</string>`,
402
+ ` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
403
+ ` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
278
404
  ` <key>PATH</key><string>${plistString(path)}</string>`,
279
405
  codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
406
+ codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
280
407
  opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
408
+ ...proxyEnv.map(({ name, value }) =>
409
+ ` <key>${name}</key><string>${plistString(value)}</string>`),
281
410
  ].filter((line): line is string => Boolean(line)).join("\n");
282
411
  const command = buildServiceShellCommand(bun, cli);
283
412
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -491,17 +620,14 @@ async function reportServiceServing(
491
620
  }
492
621
 
493
622
  /**
494
- * The reinstall command for the CURRENTLY INSTALLED backend.
623
+ * The command that repairs the CURRENTLY INSTALLED backend without re-registering it.
495
624
  *
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.
625
+ * `ocx service repair` reads the recorded backend itself, so it cannot silently switch a
626
+ * WinSW install to Task Scheduler the way a plain `ocx service install` would, and on
627
+ * Windows it needs no elevation because it never calls `schtasks /create`.
500
628
  */
501
629
  function serviceRepairCommand(): string {
502
- return process.platform === "win32" && readServiceBackend() === "native"
503
- ? "ocx service install --native"
504
- : "ocx service install";
630
+ return "ocx service repair";
505
631
  }
506
632
 
507
633
  function systemdQuote(value: string): string {
@@ -517,6 +643,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
517
643
  return `Environment=${systemdQuote(`${name}=${value}`)}`;
518
644
  }
519
645
 
646
+ /**
647
+ * Outbound proxy settings the installing shell had, resolved for baking into a service
648
+ * definition.
649
+ *
650
+ * A service manager does not inherit the environment of the shell that installed it, and
651
+ * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
652
+ * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
653
+ * reach the upstream got a service that dialed direct: the socket was reset, the retry
654
+ * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
655
+ * same install driven through `ocx codex-shim` worked, because that path spawns with
656
+ * `{ ...process.env }`.
657
+ *
658
+ * Lower-case variants are honored because curl-style tooling sets them and the runtime's
659
+ * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
660
+ * upper-case name is baked, so a definition never carries two spellings of one setting.
661
+ */
662
+ export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
663
+ const resolved: { name: string; value: string }[] = [];
664
+ for (const key of PROXY_ENV_KEYS) {
665
+ const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
666
+ if (value) resolved.push({ name: key, value });
667
+ }
668
+ return resolved;
669
+ }
670
+
520
671
  function systemdOutputTarget(value: string): string {
521
672
  // StandardOutput/StandardError use output specifiers such as append:/path.
522
673
  // Quoting the full specifier makes systemd reject it as an invalid output target.
@@ -544,16 +695,28 @@ function sh(cmd: string): string {
544
695
  export function runLaunchctl(
545
696
  args: string[],
546
697
  deps: { run?: typeof spawnSync } = {},
547
- ): { ok: boolean; stdout: string; stderr: string } {
698
+ ): { ok: boolean; stdout: string; stderr: string; status: number | null } {
548
699
  const run = deps.run ?? spawnSync;
549
700
  const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
550
701
  // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
551
702
  // null for a signalled child; neither may be reported as success.
552
- if (result.error) return { ok: false, stdout: "", stderr: String(result.error.message ?? "") };
703
+ if (result.error) {
704
+ return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
705
+ }
553
706
  return {
554
707
  ok: result.status === 0,
555
708
  stdout: String(result.stdout ?? "").trim(),
556
709
  stderr: String(result.stderr ?? "").trim(),
710
+ /*
711
+ * The NUMBER, not just its zero-ness.
712
+ *
713
+ * `launchctl print` distinguishes "that domain does not exist" (112) from
714
+ * "the domain answered and has no such service" (113), and an ownership
715
+ * probe needs that difference: the second proves absence, the first only
716
+ * proves we could not look. Collapsing both into `ok: false` forced callers
717
+ * to parse stderr, which Apple does not treat as a stable interface.
718
+ */
719
+ status: result.status ?? null,
557
720
  };
558
721
  }
559
722
 
@@ -788,6 +951,10 @@ export function windowsSchedulerTaskInstalled(taskName = TASK): boolean {
788
951
  export interface WindowsSchedulerInstallVerification {
789
952
  taskInstalled: boolean;
790
953
  registrationHealthy: boolean;
954
+ /** Well-formed XML that is PUBLISHED but policy-violating — permanent, never
955
+ * worth a settle retry (vs an empty/unreadable view, which is publication
956
+ * lag and transient). */
957
+ registrationInvalid: boolean;
791
958
  assetsHealthy: boolean;
792
959
  nativeServiceAbsent: boolean;
793
960
  /** True when SCM probe failed; not a proven WinSW presence. */
@@ -808,6 +975,10 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
808
975
  }): WindowsSchedulerInstallVerification {
809
976
  const registrationHealthy = inputs.xml.length > 0
810
977
  && windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher);
978
+ // Permanent invalidity: the XML IS published but violates the registration
979
+ // contract — no amount of settling changes it. Empty/unreadable XML stays
980
+ // transient (publication lag).
981
+ const registrationInvalid = inputs.taskInstalled && inputs.xml.length > 0 && !registrationHealthy;
811
982
  const assetsHealthy = inputs.assetsExist;
812
983
  const nativeServiceAbsent = inputs.nativeStatus === "nonexistent";
813
984
  const nativeStatusUnknown = inputs.nativeStatus === "unknown";
@@ -831,6 +1002,7 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
831
1002
  return {
832
1003
  taskInstalled: inputs.taskInstalled,
833
1004
  registrationHealthy,
1005
+ registrationInvalid,
834
1006
  assetsHealthy,
835
1007
  nativeServiceAbsent,
836
1008
  nativeStatusUnknown,
@@ -870,6 +1042,48 @@ async function elevateSchtasks(args: string[]): Promise<void> {
870
1042
  }
871
1043
  }
872
1044
 
1045
+ export interface WindowsSchedulerRollbackDeps {
1046
+ queryXml?: () => string;
1047
+ deleteTask?: () => Promise<void>;
1048
+ probe?: () => WindowsSchedulerTaskProbe;
1049
+ }
1050
+
1051
+ export async function rollbackWindowsSchedulerTaskOwnedByAttempt(
1052
+ attemptNonce: string,
1053
+ taskName = TASK,
1054
+ deps: WindowsSchedulerRollbackDeps = {},
1055
+ ): Promise<string | null> {
1056
+ let registeredXml = "";
1057
+ try {
1058
+ registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", taskName, "/xml"])))();
1059
+ } catch (error) {
1060
+ const detail = error instanceof Error ? error.message : String(error);
1061
+ return `Task Scheduler task ${taskName} ownership could not be proven: ${detail}. Residual scheduler state: task ${taskName} presence is unknown; no rollback deletion was attempted.`;
1062
+ }
1063
+ if (!registeredXml.trim()) {
1064
+ 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.`;
1065
+ }
1066
+ if (!windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)) {
1067
+ 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.`;
1068
+ }
1069
+
1070
+ try {
1071
+ await (deps.deleteTask ?? (() => elevateSchtasks(["/delete", "/tn", taskName, "/f"])))();
1072
+ } catch (error) {
1073
+ const detail = error instanceof Error ? error.message : String(error);
1074
+ return `Rollback deletion failed: ${detail}. Residual scheduler state: task ${taskName} may remain registered.`;
1075
+ }
1076
+ const probe = (deps.probe ?? (() => resolveWindowsSchedulerTaskProbe(taskName)))();
1077
+ if (probe.status === "absent") return null;
1078
+ if (probe.status === "unknown") {
1079
+ return `Task Scheduler task ${taskName} presence could not be verified after rollback: ${probe.detail}. Residual scheduler state: task presence is unknown.`;
1080
+ }
1081
+ return `Residual scheduler state: task ${taskName} is still present after rollback.`;
1082
+ }
1083
+
1084
+ // Legacy dashboard finalization creates and runs in one elevated child, whose protocol
1085
+ // performs its own rollback before returning. This fallback remains for indeterminate
1086
+ // protocol outcomes that predate the staged CLI transaction.
873
1087
  async function rollbackElevatedSchedulerTask(taskName = TASK): Promise<string | null> {
874
1088
  try {
875
1089
  await elevateSchtasks(["/delete", "/tn", taskName, "/f"]);
@@ -909,6 +1123,8 @@ type FinalizeHooks = {
909
1123
  /** Defense-in-depth: late reconciliation must still own this attempt. */
910
1124
  stillOwnsAttempt?: (attemptId: string) => boolean;
911
1125
  requestTimeoutMs?: number;
1126
+ /** Test-only seam for the post-create settle backoff; real installs use a timer. */
1127
+ settleDelay?: (ms: number) => Promise<void>;
912
1128
  };
913
1129
 
914
1130
  let finalizeHooks: FinalizeHooks | null = null;
@@ -973,6 +1189,68 @@ function attemptStillOwned(options: ApplyElevatedOptions): boolean {
973
1189
  return !check || check(options.attemptId);
974
1190
  }
975
1191
 
1192
+ /**
1193
+ * Bounded post-create backoff, 1.1s total. Task Scheduler's non-elevated view can
1194
+ * lag an elevated `/create` by a few hundred milliseconds, so a single verification
1195
+ * would roll back a task that is merely not visible yet.
1196
+ */
1197
+ const SCHEDULER_SETTLE_DELAYS_MS = [50, 150, 300, 600] as const;
1198
+
1199
+ /**
1200
+ * Whether a failed verification is still worth re-checking after a short delay.
1201
+ *
1202
+ * Retrying is confined to states that a lagging scheduler view actually produces:
1203
+ * the task is not visible yet, or it is visible but its registration has not been
1204
+ * published in full. Everything else keeps its existing fail-closed meaning and is
1205
+ * rejected here so no delay can turn it into a pass:
1206
+ *
1207
+ * - a proven conflict (both backends present) is a real dual-backend install;
1208
+ * - missing assets are missing on disk, which no amount of waiting creates;
1209
+ * - a WinSW service that is proven present (`started`/`stopped`) is never absent
1210
+ * later. This is checked independently of `conflict`, which only becomes true
1211
+ * once the task itself is visible — while the task is still invisible the pair
1212
+ * is `conflict: false` with `nativeServiceAbsent: false`, and that must not retry;
1213
+ * - unknown SCM status is unproven rather than transient, and has its own
1214
+ * task-preserving branch below.
1215
+ */
1216
+ /** Exported for tests: the transient-vs-permanent settle decision. */
1217
+ export function schedulerVerificationMaySettle(v: WindowsSchedulerInstallVerification): boolean {
1218
+ if (v.ok) return false;
1219
+ if (v.conflict) return false;
1220
+ if (!v.assetsHealthy) return false;
1221
+ if (!v.nativeServiceAbsent) return false;
1222
+ // A published-but-invalid registration is permanent: no delay repairs it.
1223
+ if (v.registrationInvalid) return false;
1224
+ return !v.taskInstalled || !v.registrationHealthy;
1225
+ }
1226
+
1227
+ function settleDelay(ms: number): Promise<void> {
1228
+ const hook = finalizeHooks?.settleDelay;
1229
+ if (hook) return hook(ms);
1230
+ return new Promise(resolve => setTimeout(resolve, ms));
1231
+ }
1232
+
1233
+ /**
1234
+ * Verify the elevated install, re-checking only while the failure looks like a
1235
+ * scheduler view that has not caught up yet. Returns `null` when this attempt lost
1236
+ * ownership mid-settle: a newer attempt owns the task, so this one must neither
1237
+ * write install state nor roll anything back.
1238
+ */
1239
+ async function verifyWindowsSchedulerInstallAfterSettle(
1240
+ options: ApplyElevatedOptions,
1241
+ ): Promise<WindowsSchedulerInstallVerification | null> {
1242
+ const verify = finalizeHooks?.verify ?? verifyWindowsSchedulerInstall;
1243
+ let verification = verify();
1244
+ for (const delayMs of SCHEDULER_SETTLE_DELAYS_MS) {
1245
+ if (!schedulerVerificationMaySettle(verification)) break;
1246
+ if (!attemptStillOwned(options)) return null;
1247
+ await settleDelay(delayMs);
1248
+ if (!attemptStillOwned(options)) return null;
1249
+ verification = verify();
1250
+ }
1251
+ return verification;
1252
+ }
1253
+
976
1254
  async function applyElevatedSchedulerResult(
977
1255
  result: ElevatedSchtasksCreateAndRunResult,
978
1256
  options: ApplyElevatedOptions,
@@ -1002,7 +1280,9 @@ async function applyElevatedSchedulerResult(
1002
1280
  await reconcileUnknownElevatedOutcome(result.exitCode);
1003
1281
  }
1004
1282
 
1005
- const verification = (finalizeHooks?.verify ?? verifyWindowsSchedulerInstall)();
1283
+ const verification = await verifyWindowsSchedulerInstallAfterSettle(options);
1284
+ // Ownership moved to a newer attempt while settling; that attempt owns the outcome.
1285
+ if (!verification) return;
1006
1286
  if (!verification.ok) {
1007
1287
  // Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status).
1008
1288
  // Unknown is not a confirmed dual-backend conflict; install state is still withheld.
@@ -1019,6 +1299,9 @@ async function applyElevatedSchedulerResult(
1019
1299
  "Installation state was not written.",
1020
1300
  ]);
1021
1301
  }
1302
+ // Rollback deletes a real task, so it needs the same ownership fence as the
1303
+ // state write below: a stale attempt must never delete a newer attempt's task.
1304
+ if (!attemptStillOwned(options)) return;
1022
1305
  const rollbackError = await rollbackElevatedSchedulerTask();
1023
1306
  const parts = [
1024
1307
  "Elevated Task Scheduler registration did not produce a conflict-free install.",
@@ -1258,9 +1541,14 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
1258
1541
  return value === "leastprivilege" || value === "highestavailable";
1259
1542
  }
1260
1543
 
1261
- export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
1262
- const { bun, cli } = entry;
1263
- const bunRuntime = durableBunRuntime();
1544
+ export function buildWindowsServiceScript(
1545
+ entry = cliEntry(),
1546
+ port = resolveServiceListenPort(),
1547
+ proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
1548
+ ): string {
1549
+ // Provenance rides along with the entry: a second durableBunRuntime() call here could
1550
+ // resolve differently from the binary the caller actually baked.
1551
+ const { bun, bunRuntimeSource, cli } = entry;
1264
1552
  const path = process.env.PATH ?? "";
1265
1553
  const lines = [
1266
1554
  "@echo off",
@@ -1269,24 +1557,45 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
1269
1557
  // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
1270
1558
  "chcp 65001 >nul",
1271
1559
  windowsBatchSet("OCX_SERVICE", "1"),
1560
+ windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
1561
+ windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
1272
1562
  windowsBatchSet("PATH", path, "pathList"),
1273
1563
  windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
1564
+ windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
1274
1565
  windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
1566
+ ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
1275
1567
  windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
1276
1568
  windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
1277
1569
  windowsBatchSet("OCX_BUN", bun, "path"),
1278
1570
  windowsBatchSet("OCX_CLI", cli, "path"),
1571
+ // Package root for the transactional-update restore path (#1942): cli is
1572
+ // <pkg>\src\cli\index.ts, so the package dir is three levels up.
1573
+ 'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"',
1279
1574
  'if exist "%OCX_API_TOKEN_FILE%" (',
1280
1575
  ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
1281
1576
  ")",
1282
1577
  ":loop",
1283
1578
  '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
1284
1579
  '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
1285
- `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntime.source}"`,
1580
+ `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
1286
1581
  '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
1287
1582
  '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
1288
1583
  '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
1289
1584
  '>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
1585
+ 'if not exist "%OCX_BUN%" (',
1586
+ " call :restore_backup",
1587
+ ")",
1588
+ 'if not exist "%OCX_BUN%" (',
1589
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
1590
+ " exit /b 3",
1591
+ ")",
1592
+ 'if not exist "%OCX_CLI%" (',
1593
+ " call :restore_backup",
1594
+ ")",
1595
+ 'if not exist "%OCX_CLI%" (',
1596
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair',
1597
+ " exit /b 3",
1598
+ ")",
1290
1599
  `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
1291
1600
  "if %ERRORLEVEL% NEQ 0 (",
1292
1601
  ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
@@ -1296,6 +1605,26 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
1296
1605
  " goto loop",
1297
1606
  ")",
1298
1607
  "endlocal",
1608
+ "goto :eof",
1609
+ "",
1610
+ // #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
1611
+ // a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
1612
+ // the package tree, so it can restore when the launcher itself is gone — the exact
1613
+ // window the in-launcher boot probe cannot reach.
1614
+ ":restore_backup",
1615
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore',
1616
+ 'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (',
1617
+ ' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (',
1618
+ ' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
1619
+ ' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
1620
+ ' if exist "%OCX_PKG_DIR%\\package.json" (',
1621
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
1622
+ " goto :eof",
1623
+ " )",
1624
+ " )",
1625
+ ")",
1626
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
1627
+ "goto :eof",
1299
1628
  ].filter((line): line is string => Boolean(line));
1300
1629
  return `${lines.join("\r\n")}\r\n`;
1301
1630
  }
@@ -1305,6 +1634,11 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
1305
1634
  return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1306
1635
  }
1307
1636
 
1637
+ /** Build the fixed scheduler-create command from an explicit staged XML document. */
1638
+ export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] {
1639
+ return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1640
+ }
1641
+
1308
1642
  /**
1309
1643
  * VBS launcher that starts the batch wrapper with a hidden window (style 0).
1310
1644
  * bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
@@ -1325,7 +1659,17 @@ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): st
1325
1659
  return `${lines.join("\r\n")}\r\n`;
1326
1660
  }
1327
1661
 
1328
- export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launcher = windowsLauncherVbsPath()): string {
1662
+ function windowsTaskDescription(attemptNonce?: string): string {
1663
+ return attemptNonce
1664
+ ? `OpenCodex proxy service wrapper; install-attempt=${attemptNonce}`
1665
+ : "OpenCodex proxy service wrapper";
1666
+ }
1667
+
1668
+ export function buildWindowsTaskXml(
1669
+ script = windowsServiceScriptPath(),
1670
+ launcher = windowsLauncherVbsPath(),
1671
+ attemptNonce?: string,
1672
+ ): string {
1329
1673
  const escapedWscript = taskXmlString(windowsWscript());
1330
1674
  // Escape the launcher path independently for the <Arguments> element; quoting it
1331
1675
  // keeps spaces intact, and /b (batch mode) suppresses script error popups.
@@ -1333,7 +1677,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launche
1333
1677
  return `<?xml version="1.0" encoding="UTF-16"?>
1334
1678
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1335
1679
  <RegistrationInfo>
1336
- <Description>OpenCodex proxy service wrapper</Description>
1680
+ <Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
1337
1681
  </RegistrationInfo>
1338
1682
  <Triggers>
1339
1683
  <LogonTrigger>
@@ -1453,6 +1797,21 @@ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string):
1453
1797
  return value?.trim().toLowerCase() === expected.toLowerCase();
1454
1798
  }
1455
1799
 
1800
+ /** True only when the exported live task carries this install attempt's nonce. */
1801
+ export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: string): boolean {
1802
+ if (!attemptNonce) return false;
1803
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
1804
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
1805
+ if (taskXmlHasPrefixedTag(scrubbed, "RegistrationInfo")) return false;
1806
+ if (taskXmlElementCount(scrubbed, "RegistrationInfo") !== 1) return false;
1807
+ const registrationInfo = taskXmlSection(scrubbed, "RegistrationInfo");
1808
+ return taskXmlDecodedValueEquals(
1809
+ registrationInfo,
1810
+ "Description",
1811
+ windowsTaskDescription(attemptNonce),
1812
+ );
1813
+ }
1814
+
1456
1815
  /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
1457
1816
  export function windowsTaskRegistrationHealthy(
1458
1817
  xml: string,
@@ -1523,7 +1882,10 @@ function installLaunchd(): void {
1523
1882
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1524
1883
  writeServiceApiTokenFile();
1525
1884
  const p = plistPath();
1526
- writeFileSync(p, buildPlist(), "utf8");
1885
+ // Capture this BEFORE writing: the write below makes the plist exist unconditionally,
1886
+ // so a post-write existsSync would call every fresh install an "installed" service.
1887
+ const wasInstalled = existsSync(p);
1888
+ writeServiceDefinitionFile(p, buildPlist(), "utf8");
1527
1889
  // Best-effort: an absent job is fine here, and a failed unload is caught by the
1528
1890
  // load verification below with a better message than a raw unload error.
1529
1891
  runLaunchctl(["unload", p]);
@@ -1535,7 +1897,9 @@ function installLaunchd(): void {
1535
1897
  `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1536
1898
  + "A previous job may still be bootstrapped. Try:\n"
1537
1899
  + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
1538
- + "then re-run 'ocx service install'.",
1900
+ // macOS `service repair` delegates straight to installLaunchd, so this fires for
1901
+ // an already-installed service too; repair reloads it without re-registering.
1902
+ + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
1539
1903
  );
1540
1904
  }
1541
1905
  writeServiceInstallState();
@@ -1572,8 +1936,8 @@ export function startLaunchd(deps: {
1572
1936
  throw new Error(
1573
1937
  `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1574
1938
  + (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."),
1939
+ ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
1940
+ : "The job is not loaded. Run 'ocx service repair' to reload it."),
1577
1941
  );
1578
1942
  }
1579
1943
  function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
@@ -1584,6 +1948,52 @@ function uninstallLaunchd(): void {
1584
1948
  if (existsSync(p)) unlinkSync(p);
1585
1949
  }
1586
1950
 
1951
+ /**
1952
+ * Write a service definition with owner-only permissions.
1953
+ *
1954
+ * These files carry the outbound proxy environment (#2107), and a proxy URL routinely
1955
+ * carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
1956
+ * umask, so the credential would be world-readable on a shared host. Every other
1957
+ * secret-bearing write in this file already uses 0600 — the service API token and the
1958
+ * install state — and a service definition holding a proxy credential belongs in the same
1959
+ * class.
1960
+ *
1961
+ * The explicit `chmodSync` is not redundant: `mode` only applies when the file is
1962
+ * created, so an install over a definition left at 0644 by an earlier version would keep
1963
+ * the loose mode.
1964
+ *
1965
+ * On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
1966
+ * may soft-fail depends on what the definition actually contains. A definition carrying a
1967
+ * proxy credential is a secret publication and fails closed like the API token and the
1968
+ * install state do; one carrying only paths and a port is not worth refusing an install
1969
+ * over, since before #2107 these files had no hardening at all and a failure here would
1970
+ * regress a user who has no credential to protect.
1971
+ */
1972
+ export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
1973
+ writeFileSync(path, content, { encoding, mode: 0o600 });
1974
+ try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
1975
+ if (process.platform === "win32") {
1976
+ hardenSecretPath(path, { required: definitionCarriesCredential(content) });
1977
+ }
1978
+ }
1979
+
1980
+ /**
1981
+ * Does this service definition embed a credential-bearing proxy URL?
1982
+ *
1983
+ * Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
1984
+ * variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
1985
+ * make an icacls stall fail an install that had nothing to protect.
1986
+ *
1987
+ * The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
1988
+ * because the three formats render differently — systemd writes `Environment="K=V"`, the
1989
+ * plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
1990
+ * `set "K=V"`. Keying on the assignment syntax silently missed the plist.
1991
+ */
1992
+ export function definitionCarriesCredential(content: string): boolean {
1993
+ // A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
1994
+ return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
1995
+ }
1996
+
1587
1997
  // ── Windows (Task Scheduler) ──
1588
1998
  /**
1589
1999
  * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
@@ -1592,7 +2002,7 @@ function uninstallLaunchd(): void {
1592
2002
  function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
1593
2003
  for (let attempt = 0; ; attempt++) {
1594
2004
  try {
1595
- writeFileSync(path, content, encoding);
2005
+ writeServiceDefinitionFile(path, content, encoding);
1596
2006
  return;
1597
2007
  } catch (err) {
1598
2008
  const code = (err as NodeJS.ErrnoException).code;
@@ -1617,21 +2027,236 @@ function writeWindowsSchedulerAssets(): void {
1617
2027
  writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1618
2028
  }
1619
2029
 
1620
- function installWindows(): void {
1621
- recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2030
+ const WINDOWS_SCHEDULER_STAGE_PREFIX = "opencodex-service-stage-";
2031
+ const ownedWindowsSchedulerStages = new Set<string>();
2032
+
2033
+ export interface WindowsSchedulerRegistrationStageDeps {
2034
+ createStageDir?: () => string;
2035
+ hardenDir?: (path: string) => void;
2036
+ writeXml?: (path: string, contents: string) => void;
2037
+ hardenPath?: (path: string) => void;
2038
+ removeStageDir?: (path: string) => void;
2039
+ }
2040
+
2041
+ function cleanupWindowsSchedulerStage(
2042
+ stageDir: string,
2043
+ xmlPath: string,
2044
+ removeStageDir: (path: string) => void,
2045
+ ): void {
2046
+ let cleanupError: unknown;
2047
+ try {
2048
+ unlinkSync(xmlPath);
2049
+ forgetEphemeralSecretPath(xmlPath);
2050
+ } catch (error) {
2051
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
2052
+ forgetEphemeralSecretPath(xmlPath);
2053
+ } else {
2054
+ cleanupError = error;
2055
+ }
2056
+ }
2057
+ try {
2058
+ removeStageDir(stageDir);
2059
+ forgetEphemeralSecretDir(stageDir);
2060
+ } catch (error) {
2061
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
2062
+ forgetEphemeralSecretDir(stageDir);
2063
+ } else if (cleanupError) {
2064
+ throw new AggregateError([cleanupError, error], "Task Scheduler staging cleanup failed.");
2065
+ } else {
2066
+ cleanupError = error;
2067
+ }
2068
+ }
2069
+ if (cleanupError) throw cleanupError;
2070
+ }
2071
+
2072
+ export function stageWindowsSchedulerRegistrationXml(
2073
+ attemptNonce: string,
2074
+ deps: WindowsSchedulerRegistrationStageDeps = {},
2075
+ ): string {
2076
+ const createStageDir = deps.createStageDir
2077
+ ?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
2078
+ const hardenDir = deps.hardenDir
2079
+ ?? ((path: string) => { hardenSecretDir(path, { required: true }); });
2080
+ const writeXml = deps.writeXml ?? ((path: string, contents: string) => {
2081
+ writeFileSync(path, contents, { encoding: "utf16le", flag: "wx", mode: 0o600 });
2082
+ });
2083
+ const hardenPath = deps.hardenPath
2084
+ ?? ((path: string) => { hardenSecretPath(path, { required: true }); });
2085
+ const removeStageDir = deps.removeStageDir
2086
+ ?? ((path: string) => { rmdirSync(path); });
2087
+
2088
+ let stageDir: string | null = null;
2089
+ let xmlPath: string | null = null;
2090
+ try {
2091
+ stageDir = createStageDir();
2092
+ try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
2093
+ hardenDir(stageDir);
2094
+ xmlPath = join(stageDir, "task.xml");
2095
+ // This document points at the canonical launcher but does not publish or rewrite it.
2096
+ // The hardened private directory prevents another local account from replacing the
2097
+ // document while UAC is pending; the file harden independently proves its identity.
2098
+ writeXml(
2099
+ xmlPath,
2100
+ `\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath(), attemptNonce)}`,
2101
+ );
2102
+ hardenPath(xmlPath);
2103
+ ownedWindowsSchedulerStages.add(xmlPath);
2104
+ return xmlPath;
2105
+ } catch (error) {
2106
+ if (stageDir) {
2107
+ try {
2108
+ cleanupWindowsSchedulerStage(stageDir, xmlPath ?? join(stageDir, "task.xml"), removeStageDir);
2109
+ } catch (cleanupError) {
2110
+ throw new AggregateError(
2111
+ [error, cleanupError],
2112
+ "Task Scheduler staging failed and its private temporary directory could not be removed.",
2113
+ );
2114
+ }
2115
+ }
2116
+ throw error;
2117
+ }
2118
+ }
2119
+
2120
+ function removeWindowsSchedulerRegistrationStage(xmlPath: string): void {
2121
+ if (!ownedWindowsSchedulerStages.has(xmlPath)) {
2122
+ throw new Error("Refusing to remove an unrecognized Task Scheduler staging path.");
2123
+ }
2124
+ const stageDir = dirname(xmlPath);
2125
+ cleanupWindowsSchedulerStage(
2126
+ stageDir,
2127
+ xmlPath,
2128
+ path => { rmdirSync(path); },
2129
+ );
2130
+ if (existsSync(stageDir)) {
2131
+ throw new Error("The private Task Scheduler staging directory still exists after cleanup.");
2132
+ }
2133
+ ownedWindowsSchedulerStages.delete(xmlPath);
2134
+ }
2135
+
2136
+ export interface FreshWindowsSchedulerRegistrationDeps {
2137
+ create?: (args: string[]) => void;
2138
+ elevate?: (taskName: string, xml: string) => Promise<void>;
2139
+ probe?: () => WindowsSchedulerTaskProbe;
2140
+ queryXml?: () => string;
2141
+ rollback?: () => Promise<string | null>;
2142
+ }
2143
+
2144
+ export async function registerFreshWindowsSchedulerTask(
2145
+ xmlPath: string,
2146
+ attemptNonce: string,
2147
+ deps: FreshWindowsSchedulerRegistrationDeps = {},
2148
+ ): Promise<void> {
2149
+ const args = buildWindowsSchtasksCreateArgsForXml(xmlPath);
2150
+ // Capture and validate the exact definition before an access-denied attempt can
2151
+ // cross the UAC boundary. The elevated fallback receives these immutable bytes,
2152
+ // never the caller-writable staging pathname.
2153
+ const expectedXml = decodeSchtasksOutput(readFileSync(xmlPath));
2154
+ if (
2155
+ !windowsTaskRegistrationHealthy(expectedXml)
2156
+ || !windowsTaskRegistrationOwnedByAttempt(expectedXml, attemptNonce)
2157
+ ) {
2158
+ throw new Error("The staged Task Scheduler registration failed OpenCodex ownership or shape validation.");
2159
+ }
2160
+ try {
2161
+ (deps.create ?? schtasks)(args);
2162
+ } catch (error) {
2163
+ if (
2164
+ !(error instanceof WindowsSchtasksError)
2165
+ || error.operation !== "create"
2166
+ || error.reason !== "access-denied"
2167
+ ) {
2168
+ throw error;
2169
+ }
2170
+ // Register from the captured XML string inside the elevated process. Another
2171
+ // same-user process can mutate its own temp files, but cannot change this command.
2172
+ const elevate = deps.elevate ?? (async (taskName: string, xml: string) => {
2173
+ const exitCode = await runWindowsElevatedScheduledTaskRegistration(taskName, xml);
2174
+ if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
2175
+ });
2176
+ await elevate(TASK, expectedXml);
2177
+ }
2178
+
2179
+ const rollbackTask = deps.rollback ?? (() => rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK));
2180
+ const probe = (deps.probe ?? (() => probeWindowsSchedulerTask(TASK)))();
2181
+ if (probe.status === "absent") {
2182
+ throw new Error("Task Scheduler reported success, but the new registration is absent; no service cleanup was started.");
2183
+ }
2184
+ if (probe.status === "unknown") {
2185
+ const rollback = await rollbackTask();
2186
+ throw new Error(
2187
+ `Task Scheduler registration was not verifiably present after create (${probe.detail}).`
2188
+ + (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
2189
+ );
2190
+ }
2191
+
2192
+ let registeredXml = "";
2193
+ let queryDetail: string | null = null;
2194
+ try {
2195
+ registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", TASK, "/xml"])))();
2196
+ } catch (error) {
2197
+ queryDetail = error instanceof Error ? error.message : String(error);
2198
+ }
2199
+ if (!registeredXml.trim()) {
2200
+ const rollback = await rollbackTask();
2201
+ throw new Error(
2202
+ "Task Scheduler registration was created, but its live XML could not be verified."
2203
+ + (queryDetail ? ` Query failed: ${queryDetail}` : " The query returned an empty document.")
2204
+ + (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
2205
+ );
2206
+ }
2207
+ if (
2208
+ !windowsTaskRegistrationHealthy(registeredXml)
2209
+ || !windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)
2210
+ ) {
2211
+ const rollback = await rollbackTask();
2212
+ throw new Error(
2213
+ "Task Scheduler registration was created but failed the OpenCodex action/trigger or attempt-ownership verification."
2214
+ + (rollback ? ` Cleanup also failed: ${rollback}` : " The invalid registration was rolled back."),
2215
+ );
2216
+ }
2217
+ }
2218
+
2219
+ function recordWindowsSchedulerOwnership(): boolean {
2220
+ // Ownership claiming is deliberately conservative: a legacy non-empty config root
2221
+ // without metadata stays unclaimed, but that must not turn a service reinstall into
2222
+ // an outage after prepareServiceInstall has stopped the previous manager.
2223
+ return recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2224
+ }
2225
+
2226
+ export interface RemoveNativeWindowsServiceDeps {
2227
+ status?: () => WinswStatus;
2228
+ uninstall?: () => void;
2229
+ sleep?: (ms: number) => void;
2230
+ settleChecks?: number;
2231
+ }
2232
+
2233
+ export function removeNativeWindowsServiceForScheduler(
2234
+ deps: RemoveNativeWindowsServiceDeps = {},
2235
+ ): void {
2236
+ const status = deps.status ?? statusWinswRaw;
2237
+ const uninstall = deps.uninstall ?? uninstallWinswService;
2238
+ const sleep = deps.sleep ?? Bun.sleepSync;
2239
+ const settleChecks = Math.max(1, deps.settleChecks ?? 20);
1622
2240
  // Transactional backend switch: installing the scheduler backend removes a native
1623
2241
  // service first — two live managers would both respawn the proxy (conflict).
1624
- if (statusWinswRaw() !== "nonexistent") {
2242
+ if (status() !== "nonexistent") {
1625
2243
  console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
1626
2244
  try {
1627
- uninstallWinswService();
2245
+ uninstall();
1628
2246
  } catch (err) {
1629
2247
  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
2248
  }
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.`);
2249
+ for (let check = 0; check < settleChecks; check++) {
2250
+ if (status() === "nonexistent") return;
2251
+ if (check + 1 < settleChecks) sleep(250);
1633
2252
  }
2253
+ 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
2254
  }
2255
+ }
2256
+
2257
+ function installWindows(): void {
2258
+ recordWindowsSchedulerOwnership();
2259
+ removeNativeWindowsServiceForScheduler();
1635
2260
  // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
1636
2261
  // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
1637
2262
  try { stopWindows(); } catch { /* not running */ }
@@ -1803,6 +2428,22 @@ export function stopWindows(): void {
1803
2428
  }
1804
2429
  function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
1805
2430
  function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
2431
+
2432
+ /**
2433
+ * Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
2434
+ * `schtasks /end` ends the task instance but often leaves wscript/cmd running the
2435
+ * `:loop` batch, which brings the proxy back during a stop or restart.
2436
+ *
2437
+ * The matching rule — canonical paths of THIS installation, as complete
2438
+ * command-line tokens — lives in lib/windows-service-wrappers so the update job
2439
+ * cannot drift away from it again.
2440
+ */
2441
+ function killWindowsServiceWrapperProcesses(): void {
2442
+ killWindowsSchedulerWrappers({
2443
+ scriptPath: windowsServiceScriptPath(),
2444
+ launcherPath: windowsLauncherVbsPath(),
2445
+ });
2446
+ }
1806
2447
  function uninstallWindows(): void {
1807
2448
  const probe = probeWindowsSchedulerTask(TASK);
1808
2449
  if (probe.status === "present") {
@@ -1836,7 +2477,7 @@ export function bakedServicePathsDiagnostic(): string | null {
1836
2477
  if (!state?.bunPath || !state?.cliPath) return null;
1837
2478
  const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
1838
2479
  if (missing.length === 0) return null;
1839
- return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service install' to re-bake`;
2480
+ return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
1840
2481
  }
1841
2482
 
1842
2483
  function serviceDiagnosticsSummary(): string {
@@ -1853,17 +2494,22 @@ function unitPath(): string {
1853
2494
  return join(unitDir(), `${TASK}.service`);
1854
2495
  }
1855
2496
 
1856
- export function buildUnit(): string {
1857
- const { bun, cli } = cliEntry();
2497
+ export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
2498
+ const { bun, bunRuntimeSource, cli } = cliEntry();
1858
2499
  const log = logPath();
1859
2500
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1860
2501
  const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
2502
+ const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
1861
2503
  const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
1862
2504
  const envLines = [
1863
2505
  systemdEnvironmentAssignment("OCX_SERVICE", "1"),
2506
+ systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
2507
+ systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
1864
2508
  systemdEnvironmentAssignment("PATH", path),
1865
2509
  codexHome,
2510
+ codexSqliteHome,
1866
2511
  opencodexHome,
2512
+ ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
1867
2513
  ].filter((line): line is string => Boolean(line)).join("\n");
1868
2514
  return `[Unit]
1869
2515
  Description=OpenCodex Proxy Server
@@ -1924,7 +2570,7 @@ function installSystemd(): void {
1924
2570
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1925
2571
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1926
2572
  writeServiceApiTokenFile();
1927
- writeFileSync(unitPath(), buildUnit(), "utf8");
2573
+ writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
1928
2574
  sh("systemctl --user daemon-reload");
1929
2575
  sh(`systemctl --user enable ${TASK}`);
1930
2576
  sh(`systemctl --user restart ${TASK}`);
@@ -1987,6 +2633,11 @@ type ServiceOps = {
1987
2633
  status: () => string; uninstall: () => void;
1988
2634
  };
1989
2635
 
2636
+ type ServiceInstallCleanupOps = {
2637
+ status: () => string | null;
2638
+ stop: () => void;
2639
+ };
2640
+
1990
2641
  function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
1991
2642
  if (process.platform === "darwin")
1992
2643
  return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
@@ -2012,6 +2663,67 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
2012
2663
  return null;
2013
2664
  }
2014
2665
 
2666
+ /**
2667
+ * Install-only manager operations. Unlike the ordinary status/stop helpers, these
2668
+ * distinguish confirmed absence from a failed manager query and propagate every
2669
+ * non-benign stop failure. Installing new assets is unsafe while either answer is
2670
+ * unknown because an old manager may still respawn a listener on the target port.
2671
+ */
2672
+ function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
2673
+ if (process.platform === "darwin") {
2674
+ return {
2675
+ status: () => {
2676
+ const listing = sh("launchctl list");
2677
+ return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null;
2678
+ },
2679
+ stop: () => { sh(`launchctl unload "${plistPath()}"`); },
2680
+ };
2681
+ }
2682
+ if (process.platform === "win32") {
2683
+ if (backend === "native") {
2684
+ return {
2685
+ status: () => {
2686
+ const status = statusWinswRaw();
2687
+ if (status === "unknown") throw new Error("Native service status could not be verified.");
2688
+ return status === "nonexistent" ? null : status;
2689
+ },
2690
+ stop: stopWinswService,
2691
+ };
2692
+ }
2693
+ return {
2694
+ status: () => {
2695
+ const probe = probeWindowsSchedulerTask(TASK);
2696
+ if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`);
2697
+ return probe.status === "present" ? "present" : null;
2698
+ },
2699
+ stop: () => {
2700
+ try {
2701
+ schtasks(["/end", "/tn", TASK]);
2702
+ } catch (error) {
2703
+ if (!isWindowsSchedulerEndBenign(error)) throw error;
2704
+ }
2705
+ },
2706
+ };
2707
+ }
2708
+ if (process.platform === "linux") {
2709
+ return {
2710
+ status: () => {
2711
+ // `list-unit-files <name>` exits non-zero when the unit has never been
2712
+ // installed, which made a clean first install look like an unknown manager
2713
+ // failure. `show LoadState` gives us the tri-state we actually need: a
2714
+ // healthy user manager returns `not-found` for a missing unit, while an
2715
+ // unreachable/permission-denied manager still makes `sh()` throw and the
2716
+ // caller therefore fails closed.
2717
+ const loadState = sh(`systemctl --user show ${TASK} --property=LoadState --value`).trim().toLowerCase();
2718
+ if (!loadState) throw new Error("systemd service status could not be verified.");
2719
+ return loadState === "not-found" ? null : loadState;
2720
+ },
2721
+ stop: () => { sh(`systemctl --user stop ${TASK}`); },
2722
+ };
2723
+ }
2724
+ return null;
2725
+ }
2726
+
2015
2727
  type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
2016
2728
 
2017
2729
  function verifiedKillTarget(pid: number | null | undefined): number | null {
@@ -2107,6 +2819,159 @@ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupR
2107
2819
  }
2108
2820
  }
2109
2821
 
2822
+ export interface ServiceInstallPreparationDeps {
2823
+ diagnose?: () => ServiceDiagnostic;
2824
+ managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
2825
+ stopTrackedProxy?: () => Promise<unknown>;
2826
+ platform?: NodeJS.Platform;
2827
+ }
2828
+
2829
+ /**
2830
+ * Stop every manager that could own the install port, then stop the tracked
2831
+ * standalone listener. Any unknown status or cleanup failure rejects, so callers
2832
+ * cannot write assets or report success over a surviving old listener.
2833
+ */
2834
+ export async function prepareServiceInstall(
2835
+ requestedBackend: ServiceBackend,
2836
+ deps: ServiceInstallPreparationDeps = {},
2837
+ ): Promise<void> {
2838
+ const diagnostic = (deps.diagnose ?? diagnoseService)();
2839
+ const platform = deps.platform ?? process.platform;
2840
+ const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
2841
+ const backends: ServiceBackend[] = [];
2842
+ const addBackend = (backend: ServiceBackend) => {
2843
+ if (!backends.includes(backend)) backends.push(backend);
2844
+ };
2845
+
2846
+ if (platform === "win32") {
2847
+ // The recorded backend owns the old installation and must be stopped first.
2848
+ // A conflicting diagnostic means both managers exist, so stop both even when
2849
+ // the requested backend happens to match the recorded one.
2850
+ if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") {
2851
+ addBackend(diagnostic.backend);
2852
+ if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler");
2853
+ }
2854
+ addBackend(requestedBackend);
2855
+ } else {
2856
+ addBackend(requestedBackend);
2857
+ }
2858
+
2859
+ for (const backend of backends) {
2860
+ const manager = resolveOps(backend);
2861
+ if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
2862
+ if (manager.status() !== null) manager.stop();
2863
+ }
2864
+ await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
2865
+ }
2866
+
2867
+ export async function installServiceSafely(
2868
+ requestedBackend: ServiceBackend,
2869
+ install: () => void | Promise<void>,
2870
+ deps: ServiceInstallPreparationDeps = {},
2871
+ ): Promise<void> {
2872
+ await prepareServiceInstall(requestedBackend, deps);
2873
+ await install();
2874
+ }
2875
+
2876
+ export interface FreshWindowsSchedulerInstallDeps {
2877
+ stageRegistrationXml?: (attemptNonce: string) => string;
2878
+ register?: (xmlPath: string, attemptNonce: string) => Promise<void>;
2879
+ recordOwnership?: () => boolean;
2880
+ prepare?: () => Promise<void>;
2881
+ removeNativeService?: () => void;
2882
+ publishAssets?: () => void;
2883
+ runTask?: () => void;
2884
+ writeState?: () => void;
2885
+ rollbackTask?: (attemptNonce: string) => Promise<string | null>;
2886
+ removeStagedXml?: (xmlPath: string) => void;
2887
+ }
2888
+
2889
+ /**
2890
+ * Fresh Windows scheduler install with UAC before the destructive commit.
2891
+ *
2892
+ * The registration is created but never run before `prepare`: UAC cancellation and
2893
+ * create failure therefore cannot stop the existing proxy or trigger its native-routing
2894
+ * cleanup. Rollback proves ownership from the live registration's attempt nonce before
2895
+ * deleting, because the fixed task name can be replaced by another process at any time.
2896
+ */
2897
+ export async function installFreshWindowsSchedulerSafely(
2898
+ deps: FreshWindowsSchedulerInstallDeps = {},
2899
+ ): Promise<void> {
2900
+ const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
2901
+ const register = deps.register ?? registerFreshWindowsSchedulerTask;
2902
+ const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership;
2903
+ const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
2904
+ const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
2905
+ const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
2906
+ const runTask = deps.runTask ?? startWindows;
2907
+ const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
2908
+ const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
2909
+ rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)
2910
+ ));
2911
+ const removeStagedXml = deps.removeStagedXml ?? ((path: string) => {
2912
+ removeWindowsSchedulerRegistrationStage(path);
2913
+ });
2914
+
2915
+ let stagedXml: string | null = null;
2916
+ const attemptNonce = randomUUID();
2917
+ const configRootWasAbsent = !existsSync(getConfigDir());
2918
+ let registered = false;
2919
+ let started = false;
2920
+ try {
2921
+ stagedXml = stage(attemptNonce);
2922
+ await register(stagedXml, attemptNonce);
2923
+ registered = true;
2924
+
2925
+ // The destructive boundary begins only after Task Scheduler accepted the definition.
2926
+ // The registration has consumed its temporary XML. Remove it before claiming a newly
2927
+ // created config root, because ownership initialization intentionally requires emptiness.
2928
+ removeStagedXml(stagedXml);
2929
+ stagedXml = null;
2930
+ const ownershipRecorded = recordOwnership();
2931
+ if (!ownershipRecorded && configRootWasAbsent) {
2932
+ throw new Error(
2933
+ "The fresh OpenCodex config root could not be claimed for safe uninstall; "
2934
+ + "aborting before service-manager cleanup or asset publication.",
2935
+ );
2936
+ }
2937
+ await prepare();
2938
+ removeNativeService();
2939
+ publishAssets();
2940
+ runTask();
2941
+ started = true;
2942
+ writeState();
2943
+ } catch (error) {
2944
+ const detail = error instanceof Error ? error.message : String(error);
2945
+ if (registered && !started) {
2946
+ const rollback = await rollbackTask(attemptNonce);
2947
+ throw new Error(
2948
+ `${detail}\n`
2949
+ + (rollback
2950
+ ? `The new Task Scheduler registration may remain: ${rollback}`
2951
+ : "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."),
2952
+ );
2953
+ }
2954
+ if (started) {
2955
+ throw new Error(
2956
+ `${detail}\nThe scheduler task started, but install state was not published. `
2957
+ + "The task was left in place; inspect `ocx service status` before retrying.",
2958
+ );
2959
+ }
2960
+ throw error;
2961
+ } finally {
2962
+ if (stagedXml) {
2963
+ try { removeStagedXml(stagedXml); } catch (error) {
2964
+ const code = error && typeof error === "object" && "code" in error
2965
+ ? String((error as NodeJS.ErrnoException).code)
2966
+ : "";
2967
+ console.error(
2968
+ `⚠️ Failed to remove the private Task Scheduler staging directory${code ? ` (${code})` : ""}.`,
2969
+ );
2970
+ }
2971
+ }
2972
+ }
2973
+ }
2974
+
2110
2975
  /**
2111
2976
  * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
2112
2977
  * Returns true if a service was found and stopped.
@@ -2128,6 +2993,10 @@ export function stopServiceIfInstalled(): boolean {
2128
2993
  if (statusWinswRaw() !== "nonexistent") {
2129
2994
  try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
2130
2995
  }
2996
+ // `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
2997
+ // respawns its child seconds later (issue #764), resurrecting the proxy during a
2998
+ // stop or a tray restart. Kill the launcher/wrapper processes outright.
2999
+ killWindowsServiceWrapperProcesses();
2131
3000
  if (stopped) return true;
2132
3001
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
2133
3002
  try { stopSystemd(); return true; } catch { return false; }
@@ -2142,33 +3011,53 @@ function removeServiceInstallState(): void {
2142
3011
  }
2143
3012
  }
2144
3013
 
3014
+ type UninstallServiceHooksForTests = {
3015
+ platform: typeof process.platform;
3016
+ assertEnvironment: () => void;
3017
+ probeWindowsTask: () => WindowsSchedulerTaskProbe;
3018
+ uninstallWindowsTask: () => void;
3019
+ nativeStatus: () => WinswStatus;
3020
+ uninstallNative: () => void;
3021
+ removeInstallState: () => void;
3022
+ };
3023
+
3024
+ let uninstallServiceHooksForTests: UninstallServiceHooksForTests | null = null;
3025
+
3026
+ /** Test-only hooks for full-uninstall service removal. */
3027
+ export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksForTests | null): void {
3028
+ uninstallServiceHooksForTests = hooks;
3029
+ }
3030
+
2145
3031
  /**
2146
3032
  * 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.
3033
+ * when no service exists or the platform has no service manager. An installed native Windows
3034
+ * service or scheduler task that cannot be removed throws so the caller cannot erase state and
3035
+ * report success.
2149
3036
  */
2150
3037
  export function uninstallServiceIfInstalled(): boolean {
2151
- assertServiceEnvironmentMatchesInstall();
2152
- if (process.platform === "darwin") {
3038
+ const hooks = uninstallServiceHooksForTests;
3039
+ (hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
3040
+ const platform = hooks?.platform ?? process.platform;
3041
+ if (platform === "darwin") {
2153
3042
  if (existsSync(plistPath())) {
2154
3043
  try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
2155
3044
  }
2156
- } else if (process.platform === "win32") {
3045
+ } else if (platform === "win32") {
2157
3046
  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
- }
3047
+ const scheduler = (hooks?.probeWindowsTask ?? probeWindowsSchedulerTask)();
3048
+ if (scheduler.status === "unknown") {
3049
+ throw new Error(`Could not determine Task Scheduler state: ${scheduler.detail}`);
3050
+ }
3051
+ if (scheduler.status === "present") {
3052
+ (hooks?.uninstallWindowsTask ?? uninstallWindows)();
3053
+ removed = true;
3054
+ }
3055
+ if ((hooks?.nativeStatus ?? statusWinswRaw)() !== "nonexistent") {
3056
+ (hooks?.uninstallNative ?? uninstallWinswService)();
3057
+ removed = true;
2169
3058
  }
2170
- if (removed) { removeServiceInstallState(); return true; }
2171
- } else if (process.platform === "linux" && existsSync(unitPath())) {
3059
+ if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
3060
+ } else if (platform === "linux" && existsSync(unitPath())) {
2172
3061
  try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
2173
3062
  try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
2174
3063
  }
@@ -2251,7 +3140,7 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI
2251
3140
  const detail = conflict
2252
3141
  ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
2253
3142
  : stale
2254
- ? "stale or missing service assets — run 'ocx service install' to repair"
3143
+ ? "stale or missing service assets — run 'ocx service repair'"
2255
3144
  : schedulerInstalled
2256
3145
  ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
2257
3146
  : nativeInstalled
@@ -2373,7 +3262,7 @@ export async function serviceStatusReport(
2373
3262
  : null);
2374
3263
  const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
2375
3264
  ? " launchd is running an OLDER plist than the one on disk.\n"
2376
- + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service install\n`
3265
+ + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
2377
3266
  : "";
2378
3267
 
2379
3268
  return `⚠️ ${diag.summary}\n`
@@ -2385,6 +3274,7 @@ export async function serviceStatusReport(
2385
3274
  }
2386
3275
 
2387
3276
  export function normalizeServiceSubcommand(sub?: string): string {
3277
+ if (sub === "restart") return "repair";
2388
3278
  return sub ?? "install";
2389
3279
  }
2390
3280
 
@@ -2394,6 +3284,119 @@ export interface ParsedServiceArgs {
2394
3284
  invalid: string[];
2395
3285
  }
2396
3286
 
3287
+ export type ServiceInstallationState = "installed" | "absent" | "unknown";
3288
+
3289
+ export interface ServiceInstallationProbe {
3290
+ state: ServiceInstallationState;
3291
+ detail?: string;
3292
+ }
3293
+
3294
+ export interface ServiceInstallationProbeHooks {
3295
+ platform?: NodeJS.Platform;
3296
+ exists?: (path: string) => boolean;
3297
+ probeWindowsTask?: () => WindowsSchedulerTaskProbe;
3298
+ nativeStatus?: () => WinswStatus;
3299
+ }
3300
+
3301
+ /**
3302
+ * Read only enough registration state to choose between install and repair.
3303
+ * Windows must keep query failure distinct from proven absence: treating an
3304
+ * unreadable scheduler/SCM as absent would send a bare command into the
3305
+ * elevated registration path and recreate the original #2287 failure.
3306
+ */
3307
+ export function probeServiceInstallation(
3308
+ hooks: ServiceInstallationProbeHooks = {},
3309
+ ): ServiceInstallationProbe {
3310
+ const platform = hooks.platform ?? process.platform;
3311
+ const exists = hooks.exists ?? existsSync;
3312
+ if (platform === "darwin") {
3313
+ return { state: exists(plistPath()) ? "installed" : "absent" };
3314
+ }
3315
+ if (platform === "linux") {
3316
+ return { state: exists(unitPath()) ? "installed" : "absent" };
3317
+ }
3318
+ if (platform !== "win32") return { state: "absent" };
3319
+
3320
+ let scheduler: WindowsSchedulerTaskProbe;
3321
+ try {
3322
+ scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)();
3323
+ } catch (cause) {
3324
+ scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) };
3325
+ }
3326
+ let native: WinswStatus;
3327
+ try {
3328
+ native = (hooks.nativeStatus ?? statusWinswRaw)();
3329
+ } catch {
3330
+ native = "unknown";
3331
+ }
3332
+
3333
+ if (scheduler.status === "present" || native === "started" || native === "stopped") {
3334
+ return { state: "installed" };
3335
+ }
3336
+ if (scheduler.status === "unknown" || native === "unknown") {
3337
+ const parts = [
3338
+ scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null,
3339
+ native === "unknown" ? "WinSW status could not be determined" : null,
3340
+ ].filter((part): part is string => Boolean(part));
3341
+ return { state: "unknown", detail: parts.join("; ") };
3342
+ }
3343
+ return { state: "absent" };
3344
+ }
3345
+
3346
+ /**
3347
+ * A bare invocation is an idempotent "make the installed service current"
3348
+ * operation. First-time setup still installs, but an existing registration must
3349
+ * use the repair path so Windows does not re-run the elevated `schtasks /create`.
3350
+ * Backend flags remain an explicit install request because they select which
3351
+ * registration mechanism to create.
3352
+ */
3353
+ export function selectServiceSubcommand(
3354
+ parsed: ParsedServiceArgs,
3355
+ options: { hasExplicitSubcommand: boolean; installed: boolean },
3356
+ ): string {
3357
+ if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair";
3358
+ return parsed.sub;
3359
+ }
3360
+
3361
+ export type ServiceCommandPlan =
3362
+ | { ok: true; parsed: ParsedServiceArgs; command: string }
3363
+ | { ok: false; message: string };
3364
+
3365
+ export function planServiceCommand(
3366
+ args: string[],
3367
+ options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {},
3368
+ ): ServiceCommandPlan {
3369
+ const parsed = parseServiceArgs(args);
3370
+ if (parsed.invalid.length > 0) {
3371
+ return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
3372
+ }
3373
+ if (parsed.backend && parsed.sub !== "install") {
3374
+ return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
3375
+ }
3376
+ if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") {
3377
+ return { ok: false, message: "--native (WinSW) is Windows-only." };
3378
+ }
3379
+
3380
+ const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--"));
3381
+ let installed = false;
3382
+ if (!hasExplicitSubcommand && parsed.backend === null) {
3383
+ const probe = (options.probeInstallation ?? probeServiceInstallation)();
3384
+ if (probe.state === "unknown") {
3385
+ const suffix = probe.detail ? ` (${probe.detail})` : "";
3386
+ return {
3387
+ ok: false,
3388
+ 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.`,
3389
+ };
3390
+ }
3391
+ installed = probe.state === "installed";
3392
+ }
3393
+ return {
3394
+ ok: true,
3395
+ parsed,
3396
+ command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }),
3397
+ };
3398
+ }
3399
+
2397
3400
  /**
2398
3401
  * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
2399
3402
  * subcommand; backend flags are only meaningful for `install` (validated by the caller).
@@ -2419,20 +3422,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
2419
3422
  }
2420
3423
 
2421
3424
  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.");
3425
+ const filteredArgs = args.filter((a): a is string => Boolean(a));
3426
+ const plan = planServiceCommand(filteredArgs);
3427
+ if (!plan.ok) {
3428
+ console.error(plan.message);
2434
3429
  process.exit(1);
2435
3430
  }
3431
+ const { parsed, command } = plan;
2436
3432
  if (command === "repair") {
2437
3433
  assertServiceEnvironmentMatchesInstall();
2438
3434
  assertServiceAuthEnvironment();
@@ -2455,7 +3451,31 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2455
3451
  case "install":
2456
3452
  assertServiceEnvironmentMatchesInstall();
2457
3453
  assertServiceAuthEnvironment();
2458
- await ops.install();
3454
+ // A manually started proxy can still own the configured port while the service
3455
+ // registration is absent or unloaded. Stop both the registered manager and any
3456
+ // tracked standalone listener before loading the freshly written service assets.
3457
+ // Otherwise launchd/Task Scheduler can register successfully while its child
3458
+ // restart-loops on EADDRINUSE, and the old standalone process makes the install
3459
+ // verification report a false success.
3460
+ try {
3461
+ if (process.platform === "win32" && backend === "scheduler") {
3462
+ const scheduler = probeWindowsSchedulerTask(TASK);
3463
+ if (scheduler.status === "unknown") {
3464
+ throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
3465
+ }
3466
+ if (scheduler.status === "absent") {
3467
+ await installFreshWindowsSchedulerSafely();
3468
+ } else {
3469
+ await installServiceSafely(backend, ops.install);
3470
+ }
3471
+ } else {
3472
+ await installServiceSafely(backend, ops.install);
3473
+ }
3474
+ } catch (error) {
3475
+ console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
3476
+ process.exitCode = 1;
3477
+ break;
3478
+ }
2459
3479
  // The wrapper was written moments ago in this process, so the configured port
2460
3480
  // and the baked one cannot have diverged yet — unlike `start`, which reads the
2461
3481
  // installed artifact instead.
@@ -2494,7 +3514,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2494
3514
  process.exitCode = 1;
2495
3515
  break;
2496
3516
  }
2497
- const restore = restoreNativeCodex();
3517
+ const restore = await restoreNativeCodexAsync();
2498
3518
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
2499
3519
  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
3520
  // The Grok fence is the other managed config this command owns. Leaving it behind
@@ -2532,7 +3552,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2532
3552
  process.exit(1);
2533
3553
  }
2534
3554
  {
2535
- const restore = restoreNativeCodex();
3555
+ const restore = await restoreNativeCodexAsync();
2536
3556
  if (!restore.success) {
2537
3557
  console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
2538
3558
  }
@@ -2545,9 +3565,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2545
3565
  console.log("✅ service uninstalled.");
2546
3566
  break;
2547
3567
  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.");
3568
+ console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
3569
+ console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
2550
3570
  console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
3571
+ console.error(" restart: alias of repair.");
2551
3572
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
2552
3573
  process.exit(1);
2553
3574
  }