@yansigit/opencodex 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 (826) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/LICENSE +21 -0
  3. package/README.md +303 -0
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/claude-code-models.gif +0 -0
  7. package/assets/codex-app-picker.png +0 -0
  8. package/bin/ocx.mjs +587 -0
  9. package/bin/package-main.mjs +9 -0
  10. package/gui/dist/assets/index-BNJ7r4Gd.js +102 -0
  11. package/gui/dist/assets/index-CGoDO3uO.css +1 -0
  12. package/gui/dist/favicon.png +0 -0
  13. package/gui/dist/icons.svg +24 -0
  14. package/gui/dist/index.html +25 -0
  15. package/gui/dist/logo.png +0 -0
  16. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/cline-color.svg +16 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  25. package/gui/dist/provider-icons/discord.svg +1 -0
  26. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  27. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  28. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  29. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  30. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  31. package/gui/dist/provider-icons/grok.svg +1 -0
  32. package/gui/dist/provider-icons/groq-color.svg +1 -0
  33. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  34. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  35. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  36. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  37. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  38. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  39. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  40. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  41. package/gui/dist/provider-icons/openai.svg +1 -0
  42. package/gui/dist/provider-icons/opencode.svg +2 -0
  43. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  44. package/gui/dist/provider-icons/pi.svg +21 -0
  45. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  46. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  47. package/gui/dist/provider-icons/telegram.svg +1 -0
  48. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  49. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  50. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  51. package/package.json +108 -0
  52. package/src/AGENTS.md +28 -0
  53. package/src/adapters/anthropic-image-guard.ts +251 -0
  54. package/src/adapters/anthropic-image-normalize.ts +518 -0
  55. package/src/adapters/anthropic-output-schema.ts +137 -0
  56. package/src/adapters/anthropic.ts +1327 -0
  57. package/src/adapters/azure.ts +36 -0
  58. package/src/adapters/base.ts +121 -0
  59. package/src/adapters/client-fingerprint.ts +65 -0
  60. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  61. package/src/adapters/command-code.ts +601 -0
  62. package/src/adapters/cursor/arg-codec.ts +38 -0
  63. package/src/adapters/cursor/arg-normalize.ts +104 -0
  64. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  65. package/src/adapters/cursor/cursor-errors.ts +288 -0
  66. package/src/adapters/cursor/discovery.ts +333 -0
  67. package/src/adapters/cursor/effort-map.ts +151 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +250 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/h2-pool.ts +123 -0
  72. package/src/adapters/cursor/http1-bidi.ts +361 -0
  73. package/src/adapters/cursor/images.ts +704 -0
  74. package/src/adapters/cursor/kv-store.ts +52 -0
  75. package/src/adapters/cursor/live-models.ts +269 -0
  76. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  77. package/src/adapters/cursor/live-transport.ts +1653 -0
  78. package/src/adapters/cursor/mcp-config.ts +42 -0
  79. package/src/adapters/cursor/mcp-manager.ts +333 -0
  80. package/src/adapters/cursor/message-mapper.ts +49 -0
  81. package/src/adapters/cursor/native-exec-common.ts +76 -0
  82. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  83. package/src/adapters/cursor/native-exec-fs.ts +332 -0
  84. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  85. package/src/adapters/cursor/native-exec-network.ts +43 -0
  86. package/src/adapters/cursor/native-exec-shell.ts +547 -0
  87. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  88. package/src/adapters/cursor/native-exec.ts +663 -0
  89. package/src/adapters/cursor/protobuf-events.ts +1381 -0
  90. package/src/adapters/cursor/protobuf-request.ts +1032 -0
  91. package/src/adapters/cursor/request-builder.ts +461 -0
  92. package/src/adapters/cursor/thread-continuity.ts +67 -0
  93. package/src/adapters/cursor/tool-definitions.ts +735 -0
  94. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  95. package/src/adapters/cursor/transport-retry.ts +132 -0
  96. package/src/adapters/cursor/transport.ts +79 -0
  97. package/src/adapters/cursor/types.ts +79 -0
  98. package/src/adapters/cursor.ts +322 -0
  99. package/src/adapters/google-antigravity-hosts.ts +48 -0
  100. package/src/adapters/google-antigravity-replay.ts +827 -0
  101. package/src/adapters/google-antigravity-tools.ts +105 -0
  102. package/src/adapters/google-antigravity-wire.ts +141 -0
  103. package/src/adapters/google-errors.ts +92 -0
  104. package/src/adapters/google-http.ts +460 -0
  105. package/src/adapters/google-tool-schema.ts +238 -0
  106. package/src/adapters/google-truncation.ts +24 -0
  107. package/src/adapters/google-wire-compiler.ts +232 -0
  108. package/src/adapters/google.ts +1377 -0
  109. package/src/adapters/identity.ts +77 -0
  110. package/src/adapters/image.ts +23 -0
  111. package/src/adapters/kiro-constants.ts +16 -0
  112. package/src/adapters/kiro-errors.ts +208 -0
  113. package/src/adapters/kiro-events.ts +197 -0
  114. package/src/adapters/kiro-images.ts +129 -0
  115. package/src/adapters/kiro-retry.ts +312 -0
  116. package/src/adapters/kiro-thinking.ts +112 -0
  117. package/src/adapters/kiro-tool-fallback.ts +36 -0
  118. package/src/adapters/kiro-tools.ts +224 -0
  119. package/src/adapters/kiro-truncation.ts +33 -0
  120. package/src/adapters/kiro-wire.ts +129 -0
  121. package/src/adapters/kiro.ts +1936 -0
  122. package/src/adapters/mimo-free.ts +280 -0
  123. package/src/adapters/openai-chat-url.ts +11 -0
  124. package/src/adapters/openai-chat.ts +1980 -0
  125. package/src/adapters/openai-responses-url.ts +16 -0
  126. package/src/adapters/openai-responses.ts +1911 -0
  127. package/src/adapters/registry.ts +175 -0
  128. package/src/adapters/responses-tool-schema.ts +67 -0
  129. package/src/adapters/run-turn-queue.ts +114 -0
  130. package/src/adapters/tool-call-id.ts +119 -0
  131. package/src/adapters/tool-catalog-nudge.ts +154 -0
  132. package/src/adapters/upstream-http-error.ts +48 -0
  133. package/src/adapters/xai-web-search.ts +185 -0
  134. package/src/bridge.ts +1986 -0
  135. package/src/chat/inbound.ts +319 -0
  136. package/src/chat/outbound.ts +821 -0
  137. package/src/claude/agents-inject.ts +266 -0
  138. package/src/claude/alias.ts +149 -0
  139. package/src/claude/auth-detect.ts +229 -0
  140. package/src/claude/auth-mode-migration.ts +32 -0
  141. package/src/claude/auth-mode.ts +62 -0
  142. package/src/claude/context-windows.ts +205 -0
  143. package/src/claude/desktop-3p-guard.ts +35 -0
  144. package/src/claude/desktop-3p-paths.ts +84 -0
  145. package/src/claude/desktop-3p.ts +615 -0
  146. package/src/claude/desktop-health.ts +26 -0
  147. package/src/claude/desktop-profile.ts +263 -0
  148. package/src/claude/gateway-cache.ts +107 -0
  149. package/src/claude/inbound-debug.ts +163 -0
  150. package/src/claude/inbound.ts +578 -0
  151. package/src/claude/model-info.ts +174 -0
  152. package/src/claude/outbound.ts +926 -0
  153. package/src/cli/access.ts +108 -0
  154. package/src/cli/account-api.ts +302 -0
  155. package/src/cli/account-auth.ts +250 -0
  156. package/src/cli/account-catalog-refresh.ts +14 -0
  157. package/src/cli/account-extended.ts +737 -0
  158. package/src/cli/account-main.ts +317 -0
  159. package/src/cli/account.ts +299 -0
  160. package/src/cli/agent-driven.ts +70 -0
  161. package/src/cli/agent.ts +290 -0
  162. package/src/cli/catalog-prewarm.ts +27 -0
  163. package/src/cli/claude-agent-startup-sync.ts +73 -0
  164. package/src/cli/claude-desktop.ts +213 -0
  165. package/src/cli/claude.ts +355 -0
  166. package/src/cli/codex-log-guard-doctor.ts +103 -0
  167. package/src/cli/codex-shim-autorestore.ts +47 -0
  168. package/src/cli/codex-shim-readiness.ts +76 -0
  169. package/src/cli/combo.ts +127 -0
  170. package/src/cli/config-command.ts +209 -0
  171. package/src/cli/debug.ts +228 -0
  172. package/src/cli/dispatch.ts +585 -0
  173. package/src/cli/doctor.ts +1202 -0
  174. package/src/cli/ensure-desired-integrations.ts +152 -0
  175. package/src/cli/export-command.ts +213 -0
  176. package/src/cli/help.ts +101 -0
  177. package/src/cli/index.ts +973 -0
  178. package/src/cli/init.ts +211 -0
  179. package/src/cli/integrations.ts +260 -0
  180. package/src/cli/interactive-confirm.ts +133 -0
  181. package/src/cli/lab.ts +607 -0
  182. package/src/cli/launcher-context.ts +77 -0
  183. package/src/cli/minimax.ts +497 -0
  184. package/src/cli/models-runtime.ts +245 -0
  185. package/src/cli/models.ts +422 -0
  186. package/src/cli/observe.ts +206 -0
  187. package/src/cli/opencode.ts +588 -0
  188. package/src/cli/provider-replit.ts +232 -0
  189. package/src/cli/provider-runtime.ts +179 -0
  190. package/src/cli/provider.ts +492 -0
  191. package/src/cli/ready.ts +301 -0
  192. package/src/cli/registry.ts +422 -0
  193. package/src/cli/replit-gateway-key-input.ts +138 -0
  194. package/src/cli/root.ts +86 -0
  195. package/src/cli/route-policy.ts +92 -0
  196. package/src/cli/runtime-api.ts +328 -0
  197. package/src/cli/star-prompt.ts +211 -0
  198. package/src/cli/status-oauth.ts +78 -0
  199. package/src/cli/status.ts +328 -0
  200. package/src/cli/system-command.ts +112 -0
  201. package/src/cli/system-restart-client.ts +146 -0
  202. package/src/cli/tray-proxy.ts +199 -0
  203. package/src/cli/v2.ts +268 -0
  204. package/src/cli.ts +10 -0
  205. package/src/clients/config-export.ts +1704 -0
  206. package/src/codex/account-id.ts +34 -0
  207. package/src/codex/account-label.ts +47 -0
  208. package/src/codex/account-lifecycle.ts +172 -0
  209. package/src/codex/account-namespace-match.ts +63 -0
  210. package/src/codex/account-namespaces.ts +195 -0
  211. package/src/codex/account-pause.ts +20 -0
  212. package/src/codex/account-priority.ts +83 -0
  213. package/src/codex/account-runtime-state.ts +31 -0
  214. package/src/codex/account-store.ts +544 -0
  215. package/src/codex/account-usability.ts +43 -0
  216. package/src/codex/admission.ts +256 -0
  217. package/src/codex/affinity-debug.ts +162 -0
  218. package/src/codex/agent-roles-sync.ts +225 -0
  219. package/src/codex/agent-roles.ts +238 -0
  220. package/src/codex/app-server-processes.ts +1143 -0
  221. package/src/codex/app-server-restart-service.ts +232 -0
  222. package/src/codex/auth-api.ts +2147 -0
  223. package/src/codex/auth-collision.ts +109 -0
  224. package/src/codex/auth-context.ts +665 -0
  225. package/src/codex/autostart-health.ts +156 -0
  226. package/src/codex/catalog/account-models.ts +67 -0
  227. package/src/codex/catalog/aggregation.ts +436 -0
  228. package/src/codex/catalog/bundled.ts +549 -0
  229. package/src/codex/catalog/effort.ts +446 -0
  230. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  231. package/src/codex/catalog/kinds.ts +2 -0
  232. package/src/codex/catalog/metadata.ts +664 -0
  233. package/src/codex/catalog/native-models.ts +72 -0
  234. package/src/codex/catalog/parsing.ts +650 -0
  235. package/src/codex/catalog/provider-fetch.ts +2064 -0
  236. package/src/codex/catalog/sync.ts +1883 -0
  237. package/src/codex/catalog-admission.ts +199 -0
  238. package/src/codex/catalog-refresh-status.ts +105 -0
  239. package/src/codex/catalog-write-serialization.ts +242 -0
  240. package/src/codex/catalog.ts +14 -0
  241. package/src/codex/codex-write-lock.ts +384 -0
  242. package/src/codex/convergence-types.ts +614 -0
  243. package/src/codex/convergence.ts +651 -0
  244. package/src/codex/coordinator-doctor.ts +332 -0
  245. package/src/codex/custom-model-catalog-migration.ts +176 -0
  246. package/src/codex/data/upstream-models.json +830 -0
  247. package/src/codex/desired-state.ts +230 -0
  248. package/src/codex/exec-invocation.ts +22 -0
  249. package/src/codex/features.ts +1566 -0
  250. package/src/codex/generation.ts +202 -0
  251. package/src/codex/history-job.ts +407 -0
  252. package/src/codex/history-lock.ts +242 -0
  253. package/src/codex/history-migration-guardian.ts +108 -0
  254. package/src/codex/history-provider.ts +979 -0
  255. package/src/codex/history-transition.ts +105 -0
  256. package/src/codex/history-worker.ts +220 -0
  257. package/src/codex/home.ts +206 -0
  258. package/src/codex/inject-coordination.ts +290 -0
  259. package/src/codex/inject.ts +1733 -0
  260. package/src/codex/injected-marker.ts +106 -0
  261. package/src/codex/integration-record.ts +266 -0
  262. package/src/codex/internal/catalog-writer.ts +203 -0
  263. package/src/codex/internal/history-writer.ts +80 -0
  264. package/src/codex/journal.ts +225 -0
  265. package/src/codex/log-guard/inspect.ts +506 -0
  266. package/src/codex/log-guard/lock.ts +150 -0
  267. package/src/codex/log-guard/maintenance.ts +403 -0
  268. package/src/codex/log-guard/path-safety.ts +88 -0
  269. package/src/codex/log-guard/policy.ts +44 -0
  270. package/src/codex/log-guard/processes.ts +205 -0
  271. package/src/codex/log-guard/protection.ts +489 -0
  272. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  273. package/src/codex/main-account-cache.ts +56 -0
  274. package/src/codex/main-account.ts +68 -0
  275. package/src/codex/management-convergence.ts +167 -0
  276. package/src/codex/model-cache.ts +273 -0
  277. package/src/codex/model-entitlements.ts +353 -0
  278. package/src/codex/native-main-admission.ts +47 -0
  279. package/src/codex/native-main-auth-temp.ts +187 -0
  280. package/src/codex/native-main-claim.ts +178 -0
  281. package/src/codex/native-main-lock-file.ts +162 -0
  282. package/src/codex/native-main-owner.ts +329 -0
  283. package/src/codex/native-profile-api.ts +247 -0
  284. package/src/codex/native-profile-manager.ts +1531 -0
  285. package/src/codex/native-profile-processes.ts +121 -0
  286. package/src/codex/native-profile-recovery.ts +99 -0
  287. package/src/codex/native-profile-stage-store.ts +387 -0
  288. package/src/codex/native-profile-startup.ts +492 -0
  289. package/src/codex/native-profile-store.ts +855 -0
  290. package/src/codex/native-profile-types.ts +120 -0
  291. package/src/codex/native-residue.ts +682 -0
  292. package/src/codex/paths.ts +144 -0
  293. package/src/codex/plan-from-token.ts +140 -0
  294. package/src/codex/plan.ts +40 -0
  295. package/src/codex/plugins-doctor.ts +242 -0
  296. package/src/codex/pool-rotation.ts +295 -0
  297. package/src/codex/project-config-warnings.ts +425 -0
  298. package/src/codex/prompt-journal.ts +352 -0
  299. package/src/codex/prompt-layers.ts +967 -0
  300. package/src/codex/prompt-lock.ts +143 -0
  301. package/src/codex/quota-rejection.ts +298 -0
  302. package/src/codex/quota.ts +573 -0
  303. package/src/codex/refresh.ts +62 -0
  304. package/src/codex/reset-credit-recovery.ts +1044 -0
  305. package/src/codex/routing.ts +1888 -0
  306. package/src/codex/runtime.ts +659 -0
  307. package/src/codex/shim.ts +2170 -0
  308. package/src/codex/subagent-defaults.ts +550 -0
  309. package/src/codex/subagent-model-fallback.ts +784 -0
  310. package/src/codex/sync.ts +319 -0
  311. package/src/codex/transition-state.ts +612 -0
  312. package/src/codex/upstream-host-health.ts +368 -0
  313. package/src/codex/user-identity.ts +557 -0
  314. package/src/codex/warmup.ts +298 -0
  315. package/src/codex/websocket-registry.ts +100 -0
  316. package/src/codex/write-coordination.ts +114 -0
  317. package/src/combos/failover.ts +160 -0
  318. package/src/combos/index.ts +45 -0
  319. package/src/combos/request.ts +94 -0
  320. package/src/combos/resolve.ts +232 -0
  321. package/src/combos/types.ts +398 -0
  322. package/src/config/provider-name.ts +24 -0
  323. package/src/config.ts +4041 -0
  324. package/src/fork/register.ts +3 -0
  325. package/src/generated/compatibility-version.json +3116 -0
  326. package/src/generated/model-metadata.ts +106 -0
  327. package/src/github/star-state.ts +203 -0
  328. package/src/grok/inject.ts +530 -0
  329. package/src/grok/inspect.ts +45 -0
  330. package/src/grok/status.ts +121 -0
  331. package/src/grok/sync.ts +66 -0
  332. package/src/images/artifacts.ts +516 -0
  333. package/src/images/fulfill-video.ts +163 -0
  334. package/src/images/fulfill.ts +149 -0
  335. package/src/images/index.ts +4 -0
  336. package/src/images/loop.ts +955 -0
  337. package/src/images/plan.ts +143 -0
  338. package/src/images/synthetic-tool.ts +133 -0
  339. package/src/images/types.ts +41 -0
  340. package/src/images/xai-client.ts +141 -0
  341. package/src/images/xai-video-client.ts +163 -0
  342. package/src/index.ts +22 -0
  343. package/src/integrations/config-io.ts +269 -0
  344. package/src/integrations/journal.ts +315 -0
  345. package/src/integrations/merge.ts +135 -0
  346. package/src/integrations/mutation-flight.ts +71 -0
  347. package/src/integrations/native/ownership-preflight.ts +202 -0
  348. package/src/integrations/omp-yaml-source.ts +358 -0
  349. package/src/integrations/owned-refresh.ts +74 -0
  350. package/src/integrations/ownership.ts +111 -0
  351. package/src/integrations/registry.ts +159 -0
  352. package/src/integrations/serialize.ts +314 -0
  353. package/src/integrations/state.ts +361 -0
  354. package/src/integrations/store.ts +103 -0
  355. package/src/integrations/writer-lock.ts +98 -0
  356. package/src/integrations/writer.ts +691 -0
  357. package/src/lab/artifacts/sanitize.ts +586 -0
  358. package/src/lab/artifacts/secure-fs.ts +475 -0
  359. package/src/lab/artifacts/store.ts +310 -0
  360. package/src/lab/automation/budgets.ts +78 -0
  361. package/src/lab/automation/config-persistence.ts +256 -0
  362. package/src/lab/automation/constants.ts +39 -0
  363. package/src/lab/automation/cooldown.ts +103 -0
  364. package/src/lab/automation/dispatch.ts +211 -0
  365. package/src/lab/automation/index.ts +13 -0
  366. package/src/lab/automation/orchestrator.ts +499 -0
  367. package/src/lab/automation/persistence.ts +512 -0
  368. package/src/lab/automation/planner.ts +371 -0
  369. package/src/lab/automation/policy.ts +136 -0
  370. package/src/lab/automation/queue.ts +191 -0
  371. package/src/lab/automation/recovery.ts +24 -0
  372. package/src/lab/automation/route-context.ts +21 -0
  373. package/src/lab/automation/run-key.ts +44 -0
  374. package/src/lab/automation/runs-query.ts +34 -0
  375. package/src/lab/automation/types.ts +160 -0
  376. package/src/lab/conformance/assertion.ts +325 -0
  377. package/src/lab/conformance/digest.ts +22 -0
  378. package/src/lab/conformance/executor.ts +741 -0
  379. package/src/lab/conformance/fixture-provider.ts +27 -0
  380. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  381. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  382. package/src/lab/conformance/harness-budget.ts +47 -0
  383. package/src/lab/conformance/index.ts +5 -0
  384. package/src/lab/conformance/jcs.ts +64 -0
  385. package/src/lab/conformance/json-pointer.ts +39 -0
  386. package/src/lab/conformance/manifest.ts +180 -0
  387. package/src/lab/conformance/mcp-stub.ts +179 -0
  388. package/src/lab/conformance/negative-controls.ts +164 -0
  389. package/src/lab/conformance/observation.ts +355 -0
  390. package/src/lab/conformance/runner.ts +68 -0
  391. package/src/lab/conformance/sse-normalize.ts +59 -0
  392. package/src/lab/conformance/suite-manifest.ts +78 -0
  393. package/src/lab/conformance/types.ts +214 -0
  394. package/src/lab/constants.ts +126 -0
  395. package/src/lab/digest.ts +64 -0
  396. package/src/lab/events/errors.ts +9 -0
  397. package/src/lab/events/limits.ts +117 -0
  398. package/src/lab/events/types.ts +229 -0
  399. package/src/lab/events/validate.ts +781 -0
  400. package/src/lab/fabric/constants.ts +40 -0
  401. package/src/lab/fabric/executor.ts +492 -0
  402. package/src/lab/fabric/index.ts +80 -0
  403. package/src/lab/fabric/manifest.ts +222 -0
  404. package/src/lab/fabric/observe.ts +489 -0
  405. package/src/lab/fabric/patch.ts +79 -0
  406. package/src/lab/fabric/producer-child.ts +139 -0
  407. package/src/lab/fabric/producer-isolate.ts +276 -0
  408. package/src/lab/fabric/producer-protocol.ts +61 -0
  409. package/src/lab/fabric/scratch.ts +439 -0
  410. package/src/lab/fabric/subject.ts +106 -0
  411. package/src/lab/fabric/types.ts +134 -0
  412. package/src/lab/fabric/verifier.ts +98 -0
  413. package/src/lab/index.ts +54 -0
  414. package/src/lab/ledger/artifact-refs.ts +127 -0
  415. package/src/lab/ledger/invalidation.ts +136 -0
  416. package/src/lab/ledger/purge.ts +310 -0
  417. package/src/lab/ledger/store.ts +532 -0
  418. package/src/lab/live/credential-lease.ts +53 -0
  419. package/src/lab/live/destination.ts +155 -0
  420. package/src/lab/live/executor.ts +336 -0
  421. package/src/lab/live/inert-tools.ts +56 -0
  422. package/src/lab/live/manifest.ts +85 -0
  423. package/src/lab/live/mcp-loopback.ts +57 -0
  424. package/src/lab/live/runner.ts +19 -0
  425. package/src/lab/live/sandbox.ts +61 -0
  426. package/src/lab/live/suite-manifest.ts +41 -0
  427. package/src/lab/live/transport.ts +118 -0
  428. package/src/lab/live/types.ts +197 -0
  429. package/src/lab/observe/from-conformance.ts +301 -0
  430. package/src/lab/observe/from-live.ts +117 -0
  431. package/src/lab/paths.ts +153 -0
  432. package/src/lab/projection/rebuild.ts +495 -0
  433. package/src/lab/projection/schema.ts +135 -0
  434. package/src/lab/projection/verdicts.ts +474 -0
  435. package/src/lab/projection/verification.ts +412 -0
  436. package/src/lab/public/bundle.ts +217 -0
  437. package/src/lab/public/community-authority.ts +175 -0
  438. package/src/lab/public/community-files.ts +29 -0
  439. package/src/lab/public/community.ts +479 -0
  440. package/src/lab/public/file-safety.ts +155 -0
  441. package/src/lab/public/ids.ts +26 -0
  442. package/src/lab/public/index.ts +16 -0
  443. package/src/lab/public/mutation-lock.ts +424 -0
  444. package/src/lab/public/operator.ts +353 -0
  445. package/src/lab/public/origin-purge.ts +79 -0
  446. package/src/lab/public/origin.ts +203 -0
  447. package/src/lab/public/privacy.ts +143 -0
  448. package/src/lab/public/private-file.ts +261 -0
  449. package/src/lab/public/project.ts +124 -0
  450. package/src/lab/public/purge-test-fault.ts +21 -0
  451. package/src/lab/public/purge.ts +223 -0
  452. package/src/lab/public/registry.ts +44 -0
  453. package/src/lab/public/revocation.ts +252 -0
  454. package/src/lab/public/signature.ts +243 -0
  455. package/src/lab/public/storage.ts +105 -0
  456. package/src/lab/public/strict-json.ts +206 -0
  457. package/src/lab/public/time.ts +26 -0
  458. package/src/lab/public/types.ts +172 -0
  459. package/src/lab/public/validate.ts +391 -0
  460. package/src/lab/query/catalog.ts +101 -0
  461. package/src/lab/query/connection.ts +107 -0
  462. package/src/lab/query/constants.ts +4 -0
  463. package/src/lab/query/cursor.ts +132 -0
  464. package/src/lab/query/dto-map.ts +277 -0
  465. package/src/lab/query/errors.ts +22 -0
  466. package/src/lab/query/freshness.ts +53 -0
  467. package/src/lab/query/index.ts +45 -0
  468. package/src/lab/query/latest-observation.ts +59 -0
  469. package/src/lab/query/passive-production.ts +159 -0
  470. package/src/lab/query/queries.ts +444 -0
  471. package/src/lab/query/types.ts +266 -0
  472. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  473. package/src/lab/subject/installation-salt.ts +112 -0
  474. package/src/lab/subject/protocol-subject.ts +80 -0
  475. package/src/lab/subject/route-subject.ts +74 -0
  476. package/src/lib/abort.ts +146 -0
  477. package/src/lib/admin-secrets.ts +25 -0
  478. package/src/lib/admission.ts +83 -0
  479. package/src/lib/app-owned-memory-stores.ts +195 -0
  480. package/src/lib/app-owned-memory.ts +265 -0
  481. package/src/lib/bounded-body.ts +346 -0
  482. package/src/lib/bun-binary-validator.d.mts +3 -0
  483. package/src/lib/bun-binary-validator.mjs +18 -0
  484. package/src/lib/bun-runtime.ts +184 -0
  485. package/src/lib/bun-stream-caps.ts +130 -0
  486. package/src/lib/codex-restart-contract.ts +120 -0
  487. package/src/lib/config-ownership.ts +364 -0
  488. package/src/lib/crash-guard.ts +344 -0
  489. package/src/lib/debug-log-buffer.ts +83 -0
  490. package/src/lib/debug-settings.ts +108 -0
  491. package/src/lib/debug.ts +31 -0
  492. package/src/lib/destination-policy.ts +380 -0
  493. package/src/lib/errors.ts +406 -0
  494. package/src/lib/eventstream-decoder.ts +253 -0
  495. package/src/lib/fabric-task-execution-authority.ts +7 -0
  496. package/src/lib/fabric-task-host.ts +29 -0
  497. package/src/lib/gcp-adc.ts +341 -0
  498. package/src/lib/injection-debug-log.ts +58 -0
  499. package/src/lib/lab-activation.ts +223 -0
  500. package/src/lib/lab-live-execution-authority.ts +13 -0
  501. package/src/lib/lab-live-host.ts +30 -0
  502. package/src/lib/lab-live-pinned-sender.ts +56 -0
  503. package/src/lib/lab-live-route-production.ts +130 -0
  504. package/src/lib/lab-passive-linker-registration.ts +26 -0
  505. package/src/lib/local-management-attestation.ts +51 -0
  506. package/src/lib/local-management-capability.ts +100 -0
  507. package/src/lib/local-provider-reload-contract.ts +100 -0
  508. package/src/lib/open-url.ts +25 -0
  509. package/src/lib/optional-shutdown-hooks.ts +57 -0
  510. package/src/lib/pinned-http.ts +270 -0
  511. package/src/lib/privacy.ts +20 -0
  512. package/src/lib/process-control.ts +168 -0
  513. package/src/lib/provider-outbound.ts +210 -0
  514. package/src/lib/provider-url.ts +14 -0
  515. package/src/lib/proxy-env.ts +18 -0
  516. package/src/lib/redact.ts +521 -0
  517. package/src/lib/retry-after.ts +55 -0
  518. package/src/lib/self-launch-argv.ts +15 -0
  519. package/src/lib/server-resource-ownership.ts +71 -0
  520. package/src/lib/service-secrets.ts +25 -0
  521. package/src/lib/shadow-call.ts +61 -0
  522. package/src/lib/sidecar-tracker.ts +52 -0
  523. package/src/lib/sse-decoder.ts +364 -0
  524. package/src/lib/state-store-registrations.ts +119 -0
  525. package/src/lib/state-store-sweeper.ts +184 -0
  526. package/src/lib/system-restart-contract.ts +73 -0
  527. package/src/lib/test-home-guard.ts +90 -0
  528. package/src/lib/token-estimate.ts +86 -0
  529. package/src/lib/tool-argument-integers.ts +202 -0
  530. package/src/lib/translator-budget.ts +400 -0
  531. package/src/lib/upstream-http-version.ts +57 -0
  532. package/src/lib/upstream-reachability.ts +95 -0
  533. package/src/lib/upstream-retry.ts +392 -0
  534. package/src/lib/win-exec.ts +115 -0
  535. package/src/lib/win-paths.ts +68 -0
  536. package/src/lib/windows-atomic-replace.ts +156 -0
  537. package/src/lib/windows-elevation.ts +773 -0
  538. package/src/lib/windows-secret-acl.ts +854 -0
  539. package/src/lib/windows-service-wrappers.ts +72 -0
  540. package/src/lib/windows-text.ts +106 -0
  541. package/src/lib/windows-user-principal.ts +341 -0
  542. package/src/lib/winsw.ts +403 -0
  543. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  544. package/src/oauth/account-import/index.ts +15 -0
  545. package/src/oauth/account-import/parser.ts +83 -0
  546. package/src/oauth/account-import/registry.ts +18 -0
  547. package/src/oauth/account-import/service.ts +75 -0
  548. package/src/oauth/account-import/types.ts +91 -0
  549. package/src/oauth/anthropic-routing.ts +594 -0
  550. package/src/oauth/anthropic.ts +188 -0
  551. package/src/oauth/antigravity-routing.ts +151 -0
  552. package/src/oauth/callback-server.ts +300 -0
  553. package/src/oauth/chatgpt.ts +161 -0
  554. package/src/oauth/command-code.ts +239 -0
  555. package/src/oauth/cursor.ts +252 -0
  556. package/src/oauth/github-copilot.ts +428 -0
  557. package/src/oauth/google-antigravity.ts +262 -0
  558. package/src/oauth/health.ts +407 -0
  559. package/src/oauth/index.ts +1504 -0
  560. package/src/oauth/key-providers.ts +124 -0
  561. package/src/oauth/kimi.ts +227 -0
  562. package/src/oauth/kiro-credentials.ts +726 -0
  563. package/src/oauth/kiro.ts +621 -0
  564. package/src/oauth/local-token-detect.ts +130 -0
  565. package/src/oauth/log.ts +50 -0
  566. package/src/oauth/login-cli.ts +223 -0
  567. package/src/oauth/nous.ts +798 -0
  568. package/src/oauth/pkce.ts +15 -0
  569. package/src/oauth/store.ts +728 -0
  570. package/src/oauth/token-guardian.ts +309 -0
  571. package/src/oauth/types.ts +62 -0
  572. package/src/oauth/xai.ts +241 -0
  573. package/src/providers/alibaba-region-backup.ts +75 -0
  574. package/src/providers/alibaba-region-migration.ts +156 -0
  575. package/src/providers/alibaba-region-startup.ts +36 -0
  576. package/src/providers/antigravity-models.ts +695 -0
  577. package/src/providers/antigravity-quota.ts +216 -0
  578. package/src/providers/api-keys.ts +140 -0
  579. package/src/providers/base-url-choices.ts +74 -0
  580. package/src/providers/codex-capacity.ts +292 -0
  581. package/src/providers/command-code-efforts.ts +144 -0
  582. package/src/providers/context-cap.ts +82 -0
  583. package/src/providers/cursor-pool.ts +72 -0
  584. package/src/providers/derive.ts +586 -0
  585. package/src/providers/fastwire.ts +501 -0
  586. package/src/providers/free-directory.ts +187 -0
  587. package/src/providers/github-copilot-transport.ts +56 -0
  588. package/src/providers/google-vertex-location.ts +14 -0
  589. package/src/providers/key-failover.ts +271 -0
  590. package/src/providers/kiro-models.ts +67 -0
  591. package/src/providers/label.ts +19 -0
  592. package/src/providers/model-discovery-limits.ts +16 -0
  593. package/src/providers/model-discovery.ts +449 -0
  594. package/src/providers/model-rename-migration.ts +255 -0
  595. package/src/providers/model-rename-startup.ts +28 -0
  596. package/src/providers/openai-sidecar.ts +243 -0
  597. package/src/providers/openai-tier-startup.ts +56 -0
  598. package/src/providers/openai-tiers.ts +423 -0
  599. package/src/providers/openai-virtual-models.ts +83 -0
  600. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  601. package/src/providers/openrouter-routing.ts +102 -0
  602. package/src/providers/provider-id-rewrite.ts +185 -0
  603. package/src/providers/quota.ts +2345 -0
  604. package/src/providers/registry.ts +2918 -0
  605. package/src/providers/replit/constants.ts +27 -0
  606. package/src/providers/replit/derive.ts +85 -0
  607. package/src/providers/replit/headers.ts +28 -0
  608. package/src/providers/replit/origin.ts +55 -0
  609. package/src/providers/replit/pair-install-response.ts +72 -0
  610. package/src/providers/replit/probe.ts +199 -0
  611. package/src/providers/replit/setup.ts +350 -0
  612. package/src/providers/request-pacing.ts +310 -0
  613. package/src/providers/service-tier.ts +277 -0
  614. package/src/providers/slug-codec.ts +103 -0
  615. package/src/providers/static-model-discovery.ts +86 -0
  616. package/src/providers/xai-responses-opt-in.ts +15 -0
  617. package/src/providers/xai-transport.ts +148 -0
  618. package/src/reasoning-effort.ts +183 -0
  619. package/src/responses/compaction.ts +142 -0
  620. package/src/responses/custom-tool-compat.ts +266 -0
  621. package/src/responses/hosted-tool-policy.ts +9 -0
  622. package/src/responses/namespace-tool-compat.ts +355 -0
  623. package/src/responses/parser.ts +838 -0
  624. package/src/responses/provider-continuation.ts +98 -0
  625. package/src/responses/provider-opaque-metadata.ts +73 -0
  626. package/src/responses/reasoning-envelope.ts +60 -0
  627. package/src/responses/reasoning-replay-cache.ts +426 -0
  628. package/src/responses/schema.ts +165 -0
  629. package/src/responses/spill-store.ts +459 -0
  630. package/src/responses/state.ts +1433 -0
  631. package/src/responses/thought-signature-replay.ts +347 -0
  632. package/src/responses/tool-groups.ts +19 -0
  633. package/src/responses/tool-search-compat.ts +301 -0
  634. package/src/responses/truncated-stop-reason.ts +60 -0
  635. package/src/router.ts +761 -0
  636. package/src/routing/analytics.ts +378 -0
  637. package/src/routing/capability.ts +244 -0
  638. package/src/routing/compatibility/assemble.ts +73 -0
  639. package/src/routing/compatibility/behavior.ts +278 -0
  640. package/src/routing/compatibility/catalog.ts +99 -0
  641. package/src/routing/compatibility/endpoint.ts +52 -0
  642. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  643. package/src/routing/compatibility/policy.ts +181 -0
  644. package/src/routing/compatibility/provider-slot.ts +56 -0
  645. package/src/routing/compatibility/reader.ts +110 -0
  646. package/src/routing/compatibility/subject.ts +191 -0
  647. package/src/routing/compatibility/types.ts +64 -0
  648. package/src/routing/compatibility/version.ts +104 -0
  649. package/src/routing/cost.ts +77 -0
  650. package/src/routing/evaluator.ts +495 -0
  651. package/src/routing/health.ts +412 -0
  652. package/src/routing/history/cursor.ts +43 -0
  653. package/src/routing/history/indexer.ts +605 -0
  654. package/src/routing/history/schema.ts +72 -0
  655. package/src/routing/profile-namespace.ts +15 -0
  656. package/src/routing/profile.ts +547 -0
  657. package/src/routing/quota.ts +145 -0
  658. package/src/routing/request-evidence.ts +45 -0
  659. package/src/routing/trace.ts +776 -0
  660. package/src/server/adapter-resolve.ts +53 -0
  661. package/src/server/auth-cors.ts +751 -0
  662. package/src/server/background-lifecycle.ts +182 -0
  663. package/src/server/chat-completions.ts +442 -0
  664. package/src/server/chat-native-sse.ts +331 -0
  665. package/src/server/chat-native.ts +426 -0
  666. package/src/server/claude-messages.ts +1030 -0
  667. package/src/server/direct-local-http.ts +347 -0
  668. package/src/server/effort-policy.ts +190 -0
  669. package/src/server/github-copilot-responses-repair.ts +338 -0
  670. package/src/server/gui-static.ts +152 -0
  671. package/src/server/image-retry.ts +42 -0
  672. package/src/server/images.ts +568 -0
  673. package/src/server/index.ts +1813 -0
  674. package/src/server/lifecycle.ts +498 -0
  675. package/src/server/live.ts +717 -0
  676. package/src/server/local-management-read-client.ts +90 -0
  677. package/src/server/local-provider-reload-client.ts +137 -0
  678. package/src/server/management/agent-settings-routes.ts +1433 -0
  679. package/src/server/management/api-access.ts +141 -0
  680. package/src/server/management/api-key-usage.ts +193 -0
  681. package/src/server/management/body.ts +41 -0
  682. package/src/server/management/combo-routes.ts +263 -0
  683. package/src/server/management/config-routes.ts +835 -0
  684. package/src/server/management/context.ts +113 -0
  685. package/src/server/management/integration-routes.ts +498 -0
  686. package/src/server/management/lab-automation-routes.ts +206 -0
  687. package/src/server/management/lab-routes.ts +563 -0
  688. package/src/server/management/logs-usage-routes.ts +586 -0
  689. package/src/server/management/model-routes.ts +560 -0
  690. package/src/server/management/model-rows.ts +163 -0
  691. package/src/server/management/native-integration-routes.ts +769 -0
  692. package/src/server/management/oauth-account-routes.ts +637 -0
  693. package/src/server/management/provider-capability-config.ts +48 -0
  694. package/src/server/management/provider-routes.ts +1033 -0
  695. package/src/server/management/replit-provider-routes.ts +86 -0
  696. package/src/server/management/request-history-routes.ts +191 -0
  697. package/src/server/management/routing-analytics-routes.ts +74 -0
  698. package/src/server/management/routing-profile-routes.ts +385 -0
  699. package/src/server/management/shared.ts +286 -0
  700. package/src/server/management/sidebar-routes.ts +106 -0
  701. package/src/server/management/storage-log-guard-routes.ts +186 -0
  702. package/src/server/management/sync-response.ts +69 -0
  703. package/src/server/management/system-restart.ts +435 -0
  704. package/src/server/management/system-routes.ts +194 -0
  705. package/src/server/management/usage-summary-cache.ts +94 -0
  706. package/src/server/management/vision-sidecar-options.ts +167 -0
  707. package/src/server/management/web-search-sidecar-options.ts +120 -0
  708. package/src/server/management-api.ts +314 -0
  709. package/src/server/management-auth.ts +482 -0
  710. package/src/server/memory-watchdog.ts +156 -0
  711. package/src/server/passive-route-linker.ts +66 -0
  712. package/src/server/port-reclaim.ts +307 -0
  713. package/src/server/ports.ts +156 -0
  714. package/src/server/proxy-liveness.ts +328 -0
  715. package/src/server/readiness.ts +99 -0
  716. package/src/server/relay-eager.ts +353 -0
  717. package/src/server/relay.ts +1209 -0
  718. package/src/server/request-decompress.ts +239 -0
  719. package/src/server/request-log-conversation.ts +168 -0
  720. package/src/server/request-log.ts +1259 -0
  721. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  722. package/src/server/responses/agent-task-recovery.ts +465 -0
  723. package/src/server/responses/collaboration.ts +551 -0
  724. package/src/server/responses/compact.ts +771 -0
  725. package/src/server/responses/core.ts +5389 -0
  726. package/src/server/responses/empty-completion-guard.ts +276 -0
  727. package/src/server/responses/encrypted-payload.ts +331 -0
  728. package/src/server/responses/fetch-helpers.ts +232 -0
  729. package/src/server/responses/input-admission.ts +185 -0
  730. package/src/server/responses/pacing-overload.ts +13 -0
  731. package/src/server/responses/passthrough-error.ts +78 -0
  732. package/src/server/responses/policy-fallback.ts +178 -0
  733. package/src/server/responses/responses-field-backfill.ts +251 -0
  734. package/src/server/responses/terminal-guard.ts +251 -0
  735. package/src/server/responses/upstream-error.ts +53 -0
  736. package/src/server/responses/ws-upstream.ts +308 -0
  737. package/src/server/responses-custom-tool-repair.ts +282 -0
  738. package/src/server/responses-image-gen-repair.ts +132 -0
  739. package/src/server/responses-item-id-repair.ts +272 -0
  740. package/src/server/responses-json-events.ts +90 -0
  741. package/src/server/responses-model-rewrite.ts +29 -0
  742. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  743. package/src/server/responses-snapshot-repair.ts +621 -0
  744. package/src/server/responses-terminal-repair.ts +342 -0
  745. package/src/server/responses-tool-search-repair.ts +267 -0
  746. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  747. package/src/server/responses.ts +25 -0
  748. package/src/server/search.ts +201 -0
  749. package/src/server/sse-frame-buffer.ts +292 -0
  750. package/src/server/sse-payload-rewrite.ts +263 -0
  751. package/src/server/startup-action-control.ts +315 -0
  752. package/src/server/startup-health-cache.ts +131 -0
  753. package/src/server/system-env.ts +484 -0
  754. package/src/server/windows-tcp-drop.ts +184 -0
  755. package/src/server/windows-tray-control.ts +41 -0
  756. package/src/server/ws-bridge.ts +472 -0
  757. package/src/service-manager-probe.ts +892 -0
  758. package/src/service.ts +3575 -0
  759. package/src/sidecar/auth.ts +92 -0
  760. package/src/sidecar/candidates.ts +83 -0
  761. package/src/stall-timeout.ts +20 -0
  762. package/src/storage/cleanup-job.ts +57 -0
  763. package/src/storage/cleanup.ts +3085 -0
  764. package/src/storage/policy-job.ts +457 -0
  765. package/src/storage/policy-scheduler.ts +40 -0
  766. package/src/storage/policy-worker.ts +59 -0
  767. package/src/storage/policy.ts +527 -0
  768. package/src/storage/restore-job.ts +299 -0
  769. package/src/storage/restore-worker.ts +58 -0
  770. package/src/storage/scanner.ts +238 -0
  771. package/src/storage/storage-mutation-coordinator.ts +139 -0
  772. package/src/storage/worker-lifecycle.ts +215 -0
  773. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  774. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  775. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  776. package/src/tray/assets/opencodex-tray.png +0 -0
  777. package/src/tray/windows-tray.ps1 +364 -0
  778. package/src/tray/windows.ts +757 -0
  779. package/src/types/accounts.ts +37 -0
  780. package/src/types/config.ts +876 -0
  781. package/src/types/provider.ts +545 -0
  782. package/src/types/request.ts +384 -0
  783. package/src/types/tools.ts +131 -0
  784. package/src/types/wire.ts +80 -0
  785. package/src/types.ts +106 -0
  786. package/src/update/badge.ts +72 -0
  787. package/src/update/index.ts +415 -0
  788. package/src/update/job.ts +1887 -0
  789. package/src/update/notify.ts +263 -0
  790. package/src/update/npm-cache-preflight.d.mts +47 -0
  791. package/src/update/npm-cache-preflight.mjs +201 -0
  792. package/src/update/npm-invocation.d.mts +23 -0
  793. package/src/update/npm-invocation.mjs +94 -0
  794. package/src/update/transactional-install.d.mts +22 -0
  795. package/src/update/transactional-install.mjs +259 -0
  796. package/src/update/tray-update-plan.d.mts +18 -0
  797. package/src/update/tray-update-plan.mjs +38 -0
  798. package/src/usage/cost.ts +625 -0
  799. package/src/usage/debug.ts +97 -0
  800. package/src/usage/expected-prices.ts +416 -0
  801. package/src/usage/log.ts +1223 -0
  802. package/src/usage/summary.ts +753 -0
  803. package/src/usage/totals.ts +14 -0
  804. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  805. package/src/usage/user-cost-overlays.ts +314 -0
  806. package/src/vision/anthropic-describe.ts +189 -0
  807. package/src/vision/backends.ts +97 -0
  808. package/src/vision/describe.ts +131 -0
  809. package/src/vision/eligibility.ts +250 -0
  810. package/src/vision/index.ts +681 -0
  811. package/src/vision/reasoning.ts +55 -0
  812. package/src/vision/routed-describe.ts +175 -0
  813. package/src/vision/timeout-bounds.ts +9 -0
  814. package/src/web-search/anthropic-executor.ts +195 -0
  815. package/src/web-search/backends.ts +108 -0
  816. package/src/web-search/exa-executor.ts +88 -0
  817. package/src/web-search/executor.ts +113 -0
  818. package/src/web-search/format-result.ts +89 -0
  819. package/src/web-search/gemini-executor.ts +141 -0
  820. package/src/web-search/index.ts +331 -0
  821. package/src/web-search/loop.ts +896 -0
  822. package/src/web-search/parse.ts +315 -0
  823. package/src/web-search/progress-stream.ts +342 -0
  824. package/src/web-search/sources.ts +60 -0
  825. package/src/web-search/synthetic-tool.ts +47 -0
  826. package/src/web-search/xai-executor.ts +219 -0
package/src/service.ts ADDED
@@ -0,0 +1,3575 @@
1
+ /**
2
+ * `ocx service` — run the proxy as a background service that auto-starts on login and
3
+ * auto-restarts on crash. macOS → launchd; Windows → Task Scheduler; Linux → systemd user unit.
4
+ * The service sets OCX_SERVICE=1 so the proxy's shutdown handler does NOT restore native
5
+ * Codex on a service-managed restart (the restarted instance re-injects); explicit stop/uninstall
6
+ * restore it via the command.
7
+ */
8
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
9
+ import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
10
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { dirname, join, posix, resolve, win32 } from "node:path";
13
+ import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
14
+ import { loadConfig } from "./config";
15
+ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
16
+ import { stripGrokConfig } from "./grok/inject";
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";
20
+ import { isProcessAlive, stopProxy } from "./lib/process-control";
21
+ import { serviceApiTokenFilePath } from "./lib/service-secrets";
22
+ import { PROXY_ENV_KEYS } from "./lib/proxy-env";
23
+ import { randomUUID } from "node:crypto";
24
+ import {
25
+ ELEVATION_REQUEST_TIMEOUT_MS,
26
+ OCX_ELEVATED_PROTOCOL_FAILED,
27
+ raceWithTimeout,
28
+ resolveTrustedWindowsPowerShellExe,
29
+ resolveTrustedWindowsSchtasksExe,
30
+ startElevatedSchtasksCreateAndRun,
31
+ runWindowsElevated,
32
+ runWindowsElevatedScheduledTaskRegistration,
33
+ toWindowsSchtasksError,
34
+ WindowsElevationError,
35
+ WindowsSchtasksError,
36
+ type ElevatedSchedulerOutcome,
37
+ type ElevatedSchtasksCreateAndRunExecution,
38
+ type ElevatedSchtasksCreateAndRunResult,
39
+ } from "./lib/windows-elevation";
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";
47
+ import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
48
+ import { recordOwnedConfigPath } from "./lib/config-ownership";
49
+ import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
50
+ import { maybeShowStarPrompt } from "./cli/star-prompt";
51
+
52
+ const LABEL = "com.opencodex.proxy";
53
+ const TASK = "opencodex-proxy";
54
+
55
+ export type ServiceBackend = "scheduler" | "native";
56
+
57
+ function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
58
+ // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
59
+ // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
60
+ // standalone Bun is later removed. The CLI entry lives at src/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") };
66
+ }
67
+
68
+ function plistPath(): string {
69
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
70
+ }
71
+
72
+ function logPath(): string {
73
+ return join(getConfigDir(), "service.log");
74
+ }
75
+
76
+ export function serviceLogPath(): string {
77
+ return logPath();
78
+ }
79
+
80
+ function windowsServiceScriptPath(): string {
81
+ return join(getConfigDir(), "opencodex-service.cmd");
82
+ }
83
+
84
+ function windowsLauncherVbsPath(): string {
85
+ return join(getConfigDir(), "opencodex-service-launcher.vbs");
86
+ }
87
+
88
+ function windowsTaskXmlPath(): string {
89
+ return join(getConfigDir(), "opencodex-service-task.xml");
90
+ }
91
+
92
+ function serviceStatePath(): string {
93
+ return join(getConfigDir(), "service-state.json");
94
+ }
95
+
96
+ function defaultOpenCodexHome(): string {
97
+ return resolve(join(homedir(), ".opencodex"));
98
+ }
99
+
100
+ function serviceStatePaths(): string[] {
101
+ const paths = [serviceStatePath()];
102
+ const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
103
+ if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
104
+ return paths;
105
+ }
106
+
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);
131
+ }
132
+
133
+ function currentOpenCodexHome(): string {
134
+ // getConfigDir() already resolves OPENCODEX_HOME with ~ expansion; keep the
135
+ // install-state comparison on the same normalization or `~/...` values falsely
136
+ // fail the environment-match check depending on cwd.
137
+ return getConfigDir();
138
+ }
139
+
140
+ function normalizePathForCompare(path: string): string {
141
+ const resolved = resolve(path);
142
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
143
+ }
144
+
145
+ export interface ServiceInstallState {
146
+ version: 1 | 2;
147
+ codexHome: string;
148
+ opencodexHome: string;
149
+ /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
150
+ bunPath?: string;
151
+ cliPath?: string;
152
+ /** v2: which Windows backend was chosen at install; absent (v1/legacy) means scheduler. */
153
+ backend?: ServiceBackend;
154
+ winswVersion?: string;
155
+ winswSha256?: string;
156
+ }
157
+
158
+ export function parseServiceInstallState(value: unknown): ServiceInstallState | null {
159
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
160
+ const state = value as Record<string, unknown>;
161
+ if (state.version !== 1 && state.version !== 2) return null;
162
+ if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null;
163
+ if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null;
164
+ for (const key of ["bunPath", "cliPath", "winswVersion", "winswSha256"] as const) {
165
+ if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
166
+ }
167
+ if (state.version === 1) {
168
+ if (state.backend !== undefined) return null;
169
+ } else if (state.backend !== "scheduler" && state.backend !== "native") {
170
+ return null;
171
+ }
172
+ return state as unknown as ServiceInstallState;
173
+ }
174
+
175
+ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
176
+ const { bun, cli } = cliEntry();
177
+ const state: ServiceInstallState = {
178
+ version: 2,
179
+ codexHome: currentCodexHome(),
180
+ opencodexHome: currentOpenCodexHome(),
181
+ bunPath: bun,
182
+ cliPath: cli,
183
+ backend,
184
+ ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
185
+ };
186
+ for (const path of serviceStatePaths()) {
187
+ const dir = dirname(path);
188
+ recordOwnedConfigPath(getConfigDir(), path);
189
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
190
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
191
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
192
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
193
+ }
194
+ }
195
+
196
+ function readServiceInstallState(): ServiceInstallState | null {
197
+ for (const path of serviceStatePaths()) {
198
+ try {
199
+ const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8")));
200
+ if (parsed) return parsed;
201
+ } catch {
202
+ /* try the next known state path */
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
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. */
261
+ export function readServiceBackend(): ServiceBackend {
262
+ return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
263
+ }
264
+
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
+ */
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[] {
282
+ return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
283
+ }
284
+
285
+ /**
286
+ * The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not
287
+ * touch it. Distinct from "stop failed": the manager was never even contacted, which means the
288
+ * installed service is still live and shared state (native Codex config, the Grok fence) must be
289
+ * left alone — tearing it down would strip config out from under a running service.
290
+ */
291
+ export class ServiceOwnershipError extends Error {
292
+ readonly code = "service-ownership-mismatch" as const;
293
+ }
294
+
295
+ export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError {
296
+ return err instanceof ServiceOwnershipError;
297
+ }
298
+
299
+ /**
300
+ * True when no installed service exists, or the installed one belongs to THIS
301
+ * CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state
302
+ * (native Codex config, the Grok fence) that a foreign service would still be relying on.
303
+ */
304
+ export function serviceEnvironmentOwnedHere(): boolean {
305
+ try {
306
+ assertServiceEnvironmentMatchesInstall();
307
+ return true;
308
+ } catch (err) {
309
+ if (isServiceOwnershipError(err)) return false;
310
+ return true; // unrelated failure: fall back to the previous behavior rather than wedging
311
+ }
312
+ }
313
+
314
+ export function assertServiceEnvironmentMatchesInstall(): void {
315
+ const state = readServiceInstallState();
316
+ if (!state) return;
317
+ const actualCodexHome = currentCodexHome();
318
+ const expected = normalizePathForCompare(state.codexHome);
319
+ const actual = normalizePathForCompare(actualCodexHome);
320
+ if (expected !== actual) {
321
+ throw new ServiceOwnershipError(
322
+ `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
323
+ "Run the service command from the same Codex home so native Codex restore updates the correct config.",
324
+ );
325
+ }
326
+ const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
327
+ const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
328
+ if (expectedOpenCodexHome !== actualOpenCodexHome) {
329
+ throw new ServiceOwnershipError(
330
+ `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
331
+ "Run the service command from the same OpenCodex home so service state and secrets match.",
332
+ );
333
+ }
334
+ }
335
+
336
+
337
+ function plistString(value: string): string {
338
+ return value
339
+ .replace(/&/g, "&amp;")
340
+ .replace(/</g, "&lt;")
341
+ .replace(/>/g, "&gt;")
342
+ .replace(/"/g, "&quot;")
343
+ .replace(/'/g, "&apos;");
344
+ }
345
+
346
+ function isLoopbackHostname(hostname: string | undefined): boolean {
347
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
348
+ return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
349
+ }
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
+
365
+ export function assertServiceAuthEnvironment(): void {
366
+ const config = loadConfig();
367
+ if (isLoopbackHostname(config.hostname)) return;
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);
373
+ throw new Error(
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}\`.`,
376
+ );
377
+ }
378
+
379
+ function writeServiceApiTokenFile(): string | null {
380
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
381
+ if (!token) return null;
382
+ const path = serviceApiTokenFilePath();
383
+ const dir = getConfigDir();
384
+ recordOwnedConfigPath(dir, path);
385
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
386
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
387
+ writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
388
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
389
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
390
+ return path;
391
+ }
392
+
393
+ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
394
+ const { bun, bunRuntimeSource, cli } = cliEntry();
395
+ const log = logPath();
396
+ const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
397
+ const codexHome = process.env.CODEX_HOME?.trim();
398
+ const codexSqliteHome = currentCodexSqliteHomeAbsolute();
399
+ const opencodexHome = process.env.OPENCODEX_HOME?.trim();
400
+ const envLines = [
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>`,
404
+ ` <key>PATH</key><string>${plistString(path)}</string>`,
405
+ codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
406
+ codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
407
+ opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
408
+ ...proxyEnv.map(({ name, value }) =>
409
+ ` <key>${name}</key><string>${plistString(value)}</string>`),
410
+ ].filter((line): line is string => Boolean(line)).join("\n");
411
+ const command = buildServiceShellCommand(bun, cli);
412
+ return `<?xml version="1.0" encoding="UTF-8"?>
413
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
414
+ <plist version="1.0">
415
+ <dict>
416
+ <key>Label</key><string>${LABEL}</string>
417
+ <key>ProgramArguments</key>
418
+ <array>
419
+ <string>/bin/sh</string>
420
+ <string>-lc</string>
421
+ <string>${plistString(command)}</string>
422
+ </array>
423
+ <key>RunAtLoad</key><true/>
424
+ <key>KeepAlive</key><true/>
425
+ <key>EnvironmentVariables</key>
426
+ <dict>
427
+ ${envLines}
428
+ </dict>
429
+ <key>StandardOutPath</key><string>${plistString(log)}</string>
430
+ <key>StandardErrorPath</key><string>${plistString(log)}</string>
431
+ </dict>
432
+ </plist>
433
+ `;
434
+ }
435
+
436
+ function shellQuote(value: string): string {
437
+ return `'${value.replace(/'/g, "'\\''")}'`;
438
+ }
439
+
440
+ /**
441
+ * Listen port baked into service wrappers / WinSW XML.
442
+ * Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100.
443
+ * `config.port === 0` means ephemeral for interactive start; services need a stable pin,
444
+ * so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`.
445
+ */
446
+ export function resolveServiceListenPort(override?: number): number {
447
+ if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) {
448
+ return Math.trunc(override);
449
+ }
450
+ const baked = process.env.OCX_BAKE_PORT?.trim();
451
+ if (baked && /^\d+$/.test(baked)) {
452
+ const n = Number(baked);
453
+ if (n > 0 && n <= 65535) return n;
454
+ }
455
+ const configured = loadConfig().port;
456
+ if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured;
457
+ return 10100;
458
+ }
459
+
460
+ function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string {
461
+ const tokenFile = serviceApiTokenFilePath();
462
+ return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`;
463
+ }
464
+
465
+ /**
466
+ * The `--port <n>` actually baked into the installed launchd plist, or null when it
467
+ * cannot be read. macOS only — named for launchd rather than "service" so no caller
468
+ * assumes it covers systemd or the Windows wrapper.
469
+ *
470
+ * `start` needs this because it does NOT rewrite the plist: an install made under
471
+ * OCX_BAKE_PORT, or any later config.port edit, would otherwise leave launchd serving
472
+ * one port while the confirmation probes another, failing a healthy service.
473
+ *
474
+ * Anchored on the closing tag and matched LAST: the command also carries the Bun and
475
+ * CLI paths, and a path containing the literal `start --port 9999` must not shadow
476
+ * the real argument. buildPlist emits the command as the final ProgramArguments
477
+ * string, and buildServiceShellCommand puts the port at the very end of it.
478
+ */
479
+ export function launchdListenPort(deps: { readPlist?: () => string } = {}): number | null {
480
+ try {
481
+ const text = (deps.readPlist ?? (() => readFileSync(plistPath(), "utf8")))();
482
+ const last = [...text.matchAll(/start --port (\d{1,5})\s*<\/string>/g)].at(-1);
483
+ if (!last) return null;
484
+ const n = Number(last[1]);
485
+ return n > 0 && n <= 65535 ? n : null;
486
+ } catch {
487
+ return null;
488
+ }
489
+ }
490
+
491
+ /** The `--port <n>` baked into the installed systemd user unit. Linux only. */
492
+ export function systemdListenPort(deps: { readUnit?: () => string } = {}): number | null {
493
+ try {
494
+ const text = (deps.readUnit ?? (() => readFileSync(unitPath(), "utf8")))();
495
+ const last = [...text.matchAll(/start --port (\d{1,5})(?:\s|"|$)/gm)].at(-1);
496
+ if (!last) return null;
497
+ const n = Number(last[1]);
498
+ return n > 0 && n <= 65535 ? n : null;
499
+ } catch {
500
+ return null;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Shared tail parser for the baked `--port <n>`.
506
+ *
507
+ * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd
508
+ * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's `</arguments>`), and `&` (an
509
+ * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI
510
+ * paths ahead of the argument, and a path containing the literal must not shadow it.
511
+ */
512
+ function parseBakedListenPort(read: () => string): number | null {
513
+ try {
514
+ const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1);
515
+ if (!last) return null;
516
+ const n = Number(last[1]);
517
+ return n > 0 && n <= 65535 ? n : null;
518
+ } catch {
519
+ return null;
520
+ }
521
+ }
522
+
523
+ /** The `--port <n>` baked into the Task Scheduler wrapper. Windows scheduler backend. */
524
+ export function windowsListenPort(deps: { readScript?: () => string } = {}): number | null {
525
+ return parseBakedListenPort(deps.readScript ?? (() => readFileSync(windowsServiceScriptPath(), "utf8")));
526
+ }
527
+
528
+ /**
529
+ * The `--port <n>` baked into the WinSW XML's `<arguments>`. Windows native backend.
530
+ *
531
+ * Separate from {@link windowsListenPort} rather than one function branching on
532
+ * `readServiceBackend()`: the recorded backend can disagree with what is actually on
533
+ * disk (the `stale` / `backendStateMismatch` cases `deriveWindowsServiceDiagnostic`
534
+ * exists to catch), and a reader that trusted it would then read the wrong file.
535
+ * Each returns null when its own artifact is absent, so the chain needs no branch.
536
+ */
537
+ export function winswListenPort(deps: { readXml?: () => string } = {}): number | null {
538
+ return parseBakedListenPort(deps.readXml ?? (() => readFileSync(winswXmlPath(), "utf8")));
539
+ }
540
+
541
+ /**
542
+ * The listen port of the INSTALLED service artifact, falling back to the configured
543
+ * one. Each reader returns null off its own platform, so the chain needs no platform
544
+ * branch — and on Windows both return null, preserving today's behavior.
545
+ */
546
+ export function installedServiceListenPort(): number {
547
+ return launchdListenPort()
548
+ ?? systemdListenPort()
549
+ ?? windowsListenPort()
550
+ ?? winswListenPort()
551
+ ?? resolveServiceListenPort();
552
+ }
553
+
554
+ export const SERVICE_INSTALL_HEALTH_MS = 20_000;
555
+
556
+ /**
557
+ * Whether a proxy actually answers on the port this install/start just produced.
558
+ *
559
+ * Registration is not service: `launchctl list` reports a job that never bound, and
560
+ * `systemctl is-active` reports a process that bound nothing. Probing is the only
561
+ * thing that answers the question the user is actually asking.
562
+ *
563
+ * Probes the BAKED target rather than resolving one. `findLiveProxy` resolves through
564
+ * pidfile -> runtime-port -> config.port, and a service reinstall has just invalidated
565
+ * the first two while `resolveServiceListenPort` (OCX_BAKE_PORT precedence, config.port
566
+ * === 0 normalization) can disagree with the third.
567
+ *
568
+ * Soft: returns the outcome, never throws; the caller chooses between a checkmark and
569
+ * an actionable warning.
570
+ */
571
+ export async function confirmServiceServing(
572
+ deps: {
573
+ port?: number;
574
+ hostname?: string;
575
+ probe?: (port: number, hostname: string) => Promise<boolean>;
576
+ sleep?: (ms: number) => Promise<void>;
577
+ now?: () => number;
578
+ timeoutMs?: number;
579
+ } = {},
580
+ ): Promise<{ ok: true; port: number } | { ok: false; port: number }> {
581
+ const port = deps.port ?? installedServiceListenPort();
582
+ const hostname = deps.hostname ?? loadConfig().hostname ?? "127.0.0.1";
583
+ const now = deps.now ?? Date.now;
584
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
585
+ const probe = deps.probe ?? (async (p, h) => !!(await proxyIdentityAt(p, { hostname: h })));
586
+ const deadline = now() + (deps.timeoutMs ?? SERVICE_INSTALL_HEALTH_MS);
587
+ for (;;) {
588
+ if (await probe(port, hostname)) return { ok: true, port };
589
+ if (now() >= deadline) return { ok: false, port };
590
+ await sleep(500);
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Print the outcome of `install` / `start` / `repair` in terms of what the user cares
596
+ * about — is it serving? — instead of whether the manager accepted the registration.
597
+ *
598
+ * Sets `process.exitCode = 1` when nothing answers. That is deliberate: the GUI update
599
+ * worker reads the child's exit status, so a registered-but-silent service now makes it
600
+ * fall back to a direct proxy start rather than reporting a successful update over a
601
+ * dead port.
602
+ */
603
+ async function reportServiceServing(
604
+ verb: "installed" | "started" | "repaired",
605
+ deps: Parameters<typeof confirmServiceServing>[0] = {},
606
+ ): Promise<void> {
607
+ const serving = await confirmServiceServing(deps);
608
+ if (serving.ok) {
609
+ console.log(`✅ opencodex service ${verb} and serving on port ${serving.port}.`);
610
+ return;
611
+ }
612
+ console.error(
613
+ `⚠️ Service ${verb}, but no proxy answered on port ${serving.port} within `
614
+ + `${Math.trunc(SERVICE_INSTALL_HEALTH_MS / 1000)}s.\n`
615
+ + ` The manager registered the job; that is not the same as serving.\n`
616
+ + ` Log: ${serviceLogPath()}\n`
617
+ + ` Meanwhile: ocx start (serves in the foreground)`,
618
+ );
619
+ process.exitCode = 1;
620
+ }
621
+
622
+ /**
623
+ * The command that repairs the CURRENTLY INSTALLED backend without re-registering it.
624
+ *
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`.
628
+ */
629
+ function serviceRepairCommand(): string {
630
+ return "ocx service repair";
631
+ }
632
+
633
+ function systemdQuote(value: string): string {
634
+ return `"${value
635
+ .replace(/\\/g, "\\\\")
636
+ .replace(/"/g, "\\\"")
637
+ .replace(/%/g, "%%")
638
+ .replace(/\n/g, "\\n")}"`;
639
+ }
640
+
641
+ function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null {
642
+ if (!value) return null;
643
+ return `Environment=${systemdQuote(`${name}=${value}`)}`;
644
+ }
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
+
671
+ function systemdOutputTarget(value: string): string {
672
+ // StandardOutput/StandardError use output specifiers such as append:/path.
673
+ // Quoting the full specifier makes systemd reject it as an invalid output target.
674
+ return value.replace(/%/g, "%%").replace(/\n/g, "\\n");
675
+ }
676
+
677
+ function sh(cmd: string): string {
678
+ return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
679
+ }
680
+
681
+ /**
682
+ * Run `launchctl` and report BOTH streams regardless of exit status.
683
+ *
684
+ * `launchctl load` writes "Load failed: <n>: <reason>" to stderr and exits 0 for
685
+ * every already-bootstrapped job. `sh()` above is execSync, which throws only on a
686
+ * non-zero exit, so install and start both reported success for a load that did
687
+ * nothing — leaving launchd running the PREVIOUS plist while a freshly written one
688
+ * sat unused on disk. That is the 2026-08-02 report: `ocx service` prints a
689
+ * checkmark, `launchctl list` shows the job, and the port answers nothing.
690
+ *
691
+ * spawnSync, NOT execFileSync: execFileSync discards stderr when the child exits 0,
692
+ * which is precisely this case — a runner built on it returns an empty stderr and
693
+ * the guard below can never fire. Measured on macOS 27.0.
694
+ */
695
+ export function runLaunchctl(
696
+ args: string[],
697
+ deps: { run?: typeof spawnSync } = {},
698
+ ): { ok: boolean; stdout: string; stderr: string; status: number | null } {
699
+ const run = deps.run ?? spawnSync;
700
+ const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
701
+ // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
702
+ // null for a signalled child; neither may be reported as success.
703
+ if (result.error) {
704
+ return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
705
+ }
706
+ return {
707
+ ok: result.status === 0,
708
+ stdout: String(result.stdout ?? "").trim(),
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,
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Whether launchctl output indicates the operation did not take. Needed because
725
+ * `ok` alone is insufficient for the legacy `load`/`unload` subcommands, which
726
+ * report failure on stderr while exiting 0. `bootstrap` exits 5, so for that path
727
+ * this is belt-and-braces rather than the only signal.
728
+ */
729
+ export function launchctlLoadFailed(stderr: string): boolean {
730
+ return /\b(?:Load|Bootstrap) failed\b/i.test(stderr);
731
+ }
732
+
733
+ /** launchd domain target for the current user's GUI session. */
734
+ function launchdGuiDomain(): string {
735
+ return `gui/${process.getuid?.() ?? 0}`;
736
+ }
737
+
738
+ /**
739
+ * Whether launchd is running the job from the CURRENT plist. `launchctl list` only
740
+ * proves domain membership — a job bootstrapped from an older plist stays listed
741
+ * forever. `launchctl print` exposes the live `arguments`, which is the only way to
742
+ * catch a load that silently no-op'd.
743
+ */
744
+ export function launchdJobMatchesPlist(
745
+ expectedCommand: string,
746
+ deps: { run?: typeof runLaunchctl } = {},
747
+ ): { loaded: boolean; matchesPlist: boolean } {
748
+ const run = deps.run ?? runLaunchctl;
749
+ const printed = run(["print", `${launchdGuiDomain()}/${LABEL}`]);
750
+ if (!printed.ok) return { loaded: false, matchesPlist: false };
751
+ // `print` writes the arguments block to stdout for a live job. Search both streams
752
+ // anyway so a future launchctl that moves diagnostics between them cannot turn this
753
+ // into a false negative — a false "stale" verdict would send users to `bootout` for
754
+ // nothing.
755
+ const printedText = `${printed.stdout}\n${printed.stderr}`;
756
+ return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
757
+ }
758
+
759
+ /**
760
+ * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
761
+ * registered task document is UTF-16; reading that as UTF-8 makes every health check
762
+ * fail ("registration present but unhealthy") and rolls back a successful elevated create.
763
+ */
764
+ export function decodeSchtasksOutput(buffer: Buffer): string {
765
+ if (buffer.length === 0) return "";
766
+ const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
767
+ const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
768
+ const looksUtf16Le = buffer.length >= 4
769
+ && buffer[1] === 0x00
770
+ && buffer[3] === 0x00
771
+ && buffer[0] !== 0x00;
772
+ if (bomUtf16Le || looksUtf16Le) {
773
+ return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
774
+ }
775
+ if (bomUtf16Be) {
776
+ // Swap pairs then decode as utf16le.
777
+ const swapped = Buffer.alloc(buffer.length - 2);
778
+ for (let i = 2; i + 1 < buffer.length; i += 2) {
779
+ swapped[i - 2] = buffer[i + 1]!;
780
+ swapped[i - 1] = buffer[i]!;
781
+ }
782
+ return swapped.toString("utf16le").trim();
783
+ }
784
+ return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
785
+ }
786
+
787
+ function runFile(file: string, args: string[]): string {
788
+ const buffer = execFileSync(file, args, {
789
+ encoding: "buffer",
790
+ stdio: ["ignore", "pipe", "pipe"],
791
+ windowsHide: true,
792
+ }) as Buffer;
793
+ return decodeSchtasksOutput(buffer);
794
+ }
795
+
796
+ function windowsSchtasks(): string {
797
+ return resolveTrustedWindowsSchtasksExe();
798
+ }
799
+
800
+ function windowsWscript(): string {
801
+ const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
802
+ return existsSync(candidate) ? candidate : "wscript.exe";
803
+ }
804
+
805
+ let querySchtasksForTests: ((args: string[]) => string) | null = null;
806
+
807
+ function querySchtasks(args: string[]): string {
808
+ if (querySchtasksForTests) return querySchtasksForTests(args);
809
+ return runFile(windowsSchtasks(), args);
810
+ }
811
+
812
+ /** Test-only seam for Task Scheduler query used by presence probes. */
813
+ export function setQuerySchtasksForTests(next: ((args: string[]) => string) | null): void {
814
+ querySchtasksForTests = next;
815
+ }
816
+
817
+ function schtasks(args: string[]): string {
818
+ try {
819
+ return querySchtasks(args);
820
+ } catch (error) {
821
+ throw toWindowsSchtasksError(error, args);
822
+ }
823
+ }
824
+
825
+ /** Tri-state Task Scheduler presence: never treat a failed query as proven absence. */
826
+ export type WindowsSchedulerTaskProbe =
827
+ | { status: "present" }
828
+ | { status: "absent" }
829
+ | { status: "unknown"; detail: string };
830
+
831
+ export type WindowsSchedulerProxyProbe =
832
+ | { status: "running"; port: number }
833
+ | { status: "not-running" }
834
+ | { status: "unknown" };
835
+
836
+ /**
837
+ * Render Task Scheduler status without exposing localized `schtasks` table output.
838
+ * The task probe answers installation state; the identity-checked health probe answers
839
+ * runtime state. Keep probe details out of this user-facing line because they can contain
840
+ * incorrectly decoded, locale-specific command output.
841
+ */
842
+ export function formatWindowsSchedulerServiceStatus(
843
+ task: WindowsSchedulerTaskProbe,
844
+ proxy: WindowsSchedulerProxyProbe,
845
+ ): string {
846
+ if (task.status === "present") {
847
+ if (proxy.status === "running") {
848
+ return `✅ service installed (Task Scheduler); OpenCodex proxy running on port ${proxy.port}.`;
849
+ }
850
+ if (proxy.status === "not-running") {
851
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy not running.";
852
+ }
853
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy status unknown.";
854
+ }
855
+ if (task.status === "absent") {
856
+ if (proxy.status === "running") {
857
+ return `❌ service not installed (Task Scheduler); OpenCodex proxy is running independently on port ${proxy.port}.`;
858
+ }
859
+ if (proxy.status === "unknown") {
860
+ return "❌ service not installed (Task Scheduler); OpenCodex proxy status unknown.";
861
+ }
862
+ return "❌ service not installed (Task Scheduler).";
863
+ }
864
+ if (proxy.status === "running") {
865
+ return `⚠️ Task Scheduler registration unknown; OpenCodex proxy running on port ${proxy.port}.`;
866
+ }
867
+ if (proxy.status === "not-running") {
868
+ return "⚠️ service status unknown (Task Scheduler query failed); OpenCodex proxy not running.";
869
+ }
870
+ return "⚠️ service status unknown (Task Scheduler and proxy checks failed).";
871
+ }
872
+
873
+ export async function inspectWindowsSchedulerServiceStatus(io: {
874
+ probeTask?: () => WindowsSchedulerTaskProbe;
875
+ findProxy?: () => Promise<{ port: number } | null>;
876
+ } = {}): Promise<string> {
877
+ let task: WindowsSchedulerTaskProbe;
878
+ try {
879
+ task = (io.probeTask ?? probeWindowsSchedulerTask)();
880
+ } catch (error) {
881
+ task = { status: "unknown", detail: schtasksErrorDetail(error) };
882
+ }
883
+
884
+ let proxy: WindowsSchedulerProxyProbe;
885
+ try {
886
+ const live = await (io.findProxy ?? findLiveProxy)();
887
+ proxy = live ? { status: "running", port: live.port } : { status: "not-running" };
888
+ } catch {
889
+ proxy = { status: "unknown" };
890
+ }
891
+
892
+ return formatWindowsSchedulerServiceStatus(task, proxy);
893
+ }
894
+
895
+ function schtasksErrorDetail(error: unknown): string {
896
+ return error instanceof Error ? error.message : String(error);
897
+ }
898
+
899
+ /** True when a schtasks CSV listing line refers to the given task name. */
900
+ export function windowsSchedulerCsvIncludesTask(csv: string, taskName: string): boolean {
901
+ const needle = taskName.toLowerCase();
902
+ for (const line of csv.split(/\r?\n/)) {
903
+ const lower = line.toLowerCase();
904
+ if (!lower.includes(needle)) continue;
905
+ // Prefer exact CSV field matches ("\TaskName" / "TaskName") before a substring hit.
906
+ if (
907
+ lower.includes(`"\\${needle}"`)
908
+ || lower.includes(`"${needle}"`)
909
+ || new RegExp(`(^|[,\\\\])${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([,"]|$)`).test(lower)
910
+ ) {
911
+ return true;
912
+ }
913
+ }
914
+ return false;
915
+ }
916
+
917
+ /**
918
+ * Probe whether the OpenCodex Task Scheduler task exists.
919
+ * Query failures fall back to a CSV listing before concluding absence; if both
920
+ * fail, returns `unknown` so callers can fail closed instead of releasing locks.
921
+ */
922
+ export function probeWindowsSchedulerTask(taskName = TASK): WindowsSchedulerTaskProbe {
923
+ if (process.platform !== "win32") return { status: "absent" };
924
+
925
+ let queryFailure: string | null = null;
926
+ try {
927
+ const out = querySchtasks(["/query", "/tn", taskName]);
928
+ if (out.includes(taskName)) return { status: "present" };
929
+ } catch (error) {
930
+ queryFailure = schtasksErrorDetail(error);
931
+ }
932
+
933
+ try {
934
+ const csv = querySchtasks(["/query", "/fo", "CSV"]);
935
+ if (windowsSchedulerCsvIncludesTask(csv, taskName)) return { status: "present" };
936
+ return { status: "absent" };
937
+ } catch (error) {
938
+ const listDetail = schtasksErrorDetail(error);
939
+ const detail = queryFailure
940
+ ? `Specific query failed (${queryFailure}); CSV listing also failed (${listDetail}).`
941
+ : `Task query did not confirm presence and CSV listing failed (${listDetail}).`;
942
+ return { status: "unknown", detail };
943
+ }
944
+ }
945
+
946
+ /** True when the Task Scheduler registration for the default proxy task is proven present. */
947
+ export function windowsSchedulerTaskInstalled(taskName = TASK): boolean {
948
+ return probeWindowsSchedulerTask(taskName).status === "present";
949
+ }
950
+
951
+ export interface WindowsSchedulerInstallVerification {
952
+ taskInstalled: boolean;
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;
958
+ assetsHealthy: boolean;
959
+ nativeServiceAbsent: boolean;
960
+ /** True when SCM probe failed; not a proven WinSW presence. */
961
+ nativeStatusUnknown: boolean;
962
+ conflict: boolean;
963
+ ok: boolean;
964
+ detail: string;
965
+ }
966
+
967
+ /** Pure postcondition evaluation for an elevated scheduler install. */
968
+ export function evaluateWindowsSchedulerInstallVerification(inputs: {
969
+ taskInstalled: boolean;
970
+ xml: string;
971
+ assetsExist: boolean;
972
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
973
+ wscript?: string;
974
+ launcher?: string;
975
+ }): WindowsSchedulerInstallVerification {
976
+ const registrationHealthy = inputs.xml.length > 0
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;
982
+ const assetsHealthy = inputs.assetsExist;
983
+ const nativeServiceAbsent = inputs.nativeStatus === "nonexistent";
984
+ const nativeStatusUnknown = inputs.nativeStatus === "unknown";
985
+ // Only treat proven WinSW presence as a backend conflict — never "unknown".
986
+ const conflict = inputs.taskInstalled
987
+ && (inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
988
+ const ok = inputs.taskInstalled && registrationHealthy && assetsHealthy && nativeServiceAbsent && !conflict;
989
+ const detail = !inputs.taskInstalled
990
+ ? "Task Scheduler task is not installed."
991
+ : conflict
992
+ ? `CONFLICT: Task Scheduler and native WinSW (${WINSW_SERVICE_ID}) are both present.`
993
+ : !assetsHealthy
994
+ ? "Required scheduler service assets are missing."
995
+ : !registrationHealthy
996
+ ? (inputs.xml.trim()
997
+ ? "Task Scheduler registration is present but unhealthy."
998
+ : "Task Scheduler task is present but its XML could not be read.")
999
+ : nativeStatusUnknown
1000
+ ? "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent."
1001
+ : "ok";
1002
+ return {
1003
+ taskInstalled: inputs.taskInstalled,
1004
+ registrationHealthy,
1005
+ registrationInvalid,
1006
+ assetsHealthy,
1007
+ nativeServiceAbsent,
1008
+ nativeStatusUnknown,
1009
+ conflict,
1010
+ ok,
1011
+ detail,
1012
+ };
1013
+ }
1014
+
1015
+ /** Conflict-free postcondition check for an elevated scheduler install. */
1016
+ export function verifyWindowsSchedulerInstall(taskName = TASK): WindowsSchedulerInstallVerification {
1017
+ const taskInstalled = windowsSchedulerTaskInstalled(taskName);
1018
+ let xml = "";
1019
+ if (taskInstalled) {
1020
+ try { xml = querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { xml = ""; }
1021
+ }
1022
+ // After elevated create, non-elevated `/query /xml` can fail or return empty while the
1023
+ // task is still listed. Fall back to the on-disk document we registered.
1024
+ if (taskInstalled && !xml.trim()) {
1025
+ const diskPath = windowsTaskXmlPath();
1026
+ if (existsSync(diskPath)) {
1027
+ try { xml = decodeSchtasksOutput(readFileSync(diskPath)); } catch { /* keep empty */ }
1028
+ }
1029
+ }
1030
+ return evaluateWindowsSchedulerInstallVerification({
1031
+ taskInstalled,
1032
+ xml,
1033
+ assetsExist: [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()].every(existsSync),
1034
+ nativeStatus: statusWinswRaw(),
1035
+ });
1036
+ }
1037
+
1038
+ async function elevateSchtasks(args: string[]): Promise<void> {
1039
+ const exitCode = await runWindowsElevated(windowsSchtasks(), args);
1040
+ if (exitCode !== 0) {
1041
+ throw new Error(`Background service install failed with exit code ${exitCode}.`);
1042
+ }
1043
+ }
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.
1087
+ async function rollbackElevatedSchedulerTask(taskName = TASK): Promise<string | null> {
1088
+ try {
1089
+ await elevateSchtasks(["/delete", "/tn", taskName, "/f"]);
1090
+ } catch (error) {
1091
+ return error instanceof Error ? error.message : String(error);
1092
+ }
1093
+ const probe = resolveWindowsSchedulerTaskProbe(taskName);
1094
+ if (probe.status === "absent") return null;
1095
+ if (probe.status === "unknown") {
1096
+ return `Task Scheduler task ${taskName} presence could not be verified after rollback: ${probe.detail}`;
1097
+ }
1098
+ return `Task Scheduler task ${taskName} is still present after rollback.`;
1099
+ }
1100
+
1101
+ type ElevateCreateAndRunStart = (
1102
+ schtasksPath: string,
1103
+ createArgs: string[],
1104
+ runArgs: string[],
1105
+ deleteArgs: string[],
1106
+ ) => ElevatedSchtasksCreateAndRunExecution;
1107
+
1108
+ type FinalizeHooks = {
1109
+ startElevateCreateAndRun?: ElevateCreateAndRunStart;
1110
+ /** Legacy sync hook used by older tests — wraps a resolved result as an execution. */
1111
+ elevateCreateAndRun?: (
1112
+ schtasksPath: string,
1113
+ createArgs: string[],
1114
+ runArgs: string[],
1115
+ deleteArgs: string[],
1116
+ ) => Promise<ElevatedSchtasksCreateAndRunResult>;
1117
+ verify?: () => WindowsSchedulerInstallVerification;
1118
+ writeInstallState?: () => void;
1119
+ /** Preferred tri-state probe for security-sensitive reconciliation. */
1120
+ probeTask?: () => WindowsSchedulerTaskProbe;
1121
+ /** Legacy boolean hook; mapped to present/absent when probeTask is unset. */
1122
+ taskInstalled?: () => boolean;
1123
+ /** Defense-in-depth: late reconciliation must still own this attempt. */
1124
+ stillOwnsAttempt?: (attemptId: string) => boolean;
1125
+ requestTimeoutMs?: number;
1126
+ /** Test-only seam for the post-create settle backoff; real installs use a timer. */
1127
+ settleDelay?: (ms: number) => Promise<void>;
1128
+ };
1129
+
1130
+ let finalizeHooks: FinalizeHooks | null = null;
1131
+
1132
+ function resolveWindowsSchedulerTaskProbe(taskName = TASK): WindowsSchedulerTaskProbe {
1133
+ if (finalizeHooks?.probeTask) return finalizeHooks.probeTask();
1134
+ if (finalizeHooks?.taskInstalled) {
1135
+ return finalizeHooks.taskInstalled() ? { status: "present" } : { status: "absent" };
1136
+ }
1137
+ return probeWindowsSchedulerTask(taskName);
1138
+ }
1139
+
1140
+ /** Test-only hooks for elevated create+run finalization. */
1141
+ export function setFinalizeWindowsSchedulerHooksForTests(hooks: FinalizeHooks | null): void {
1142
+ finalizeHooks = hooks;
1143
+ }
1144
+
1145
+ function throwPartialInstall(parts: string[]): never {
1146
+ throw new Error(parts.filter(Boolean).join(" "));
1147
+ }
1148
+
1149
+ /**
1150
+ * Reconcile an unrecognized elevated exit when we cannot trust the phase code.
1151
+ * Never invent a create-vs-run classification; inspect actual task state first.
1152
+ * An unverifiable probe must fail closed (partial / blocked), never release.
1153
+ */
1154
+ async function reconcileUnknownElevatedOutcome(exitCode: number): Promise<void> {
1155
+ const probe = resolveWindowsSchedulerTaskProbe();
1156
+ const parts = [
1157
+ "The elevated Task Scheduler operation returned an unknown result.",
1158
+ `Exit code: ${exitCode}.`,
1159
+ "OpenCodex could not prove whether task creation completed, so installation state was not written.",
1160
+ ];
1161
+ if (probe.status === "unknown") {
1162
+ parts.push(`Task Scheduler presence could not be verified: ${probe.detail}`);
1163
+ parts.push("A partial Task Scheduler backend may remain.");
1164
+ throwPartialInstall(parts);
1165
+ }
1166
+ if (probe.status === "absent") {
1167
+ parts.push("No OpenCodex Task Scheduler task was found after the elevated operation.");
1168
+ throwPartialInstall(parts);
1169
+ }
1170
+ parts.push("A Task Scheduler task is present; attempting cleanup.");
1171
+ const rollbackError = await rollbackElevatedSchedulerTask();
1172
+ if (rollbackError) {
1173
+ parts.push(`Cleanup also failed: ${rollbackError}`);
1174
+ parts.push(`Remove the task manually with 'schtasks /delete /tn ${TASK} /f' if it remains.`);
1175
+ } else {
1176
+ parts.push("The elevated Task Scheduler task was removed.");
1177
+ }
1178
+ throwPartialInstall(parts);
1179
+ }
1180
+
1181
+ type ApplyElevatedOptions = {
1182
+ attemptId: string;
1183
+ writeOnSuccess: boolean;
1184
+ stillOwnsAttempt?: (attemptId: string) => boolean;
1185
+ };
1186
+
1187
+ function attemptStillOwned(options: ApplyElevatedOptions): boolean {
1188
+ const check = options.stillOwnsAttempt ?? finalizeHooks?.stillOwnsAttempt;
1189
+ return !check || check(options.attemptId);
1190
+ }
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
+
1254
+ async function applyElevatedSchedulerResult(
1255
+ result: ElevatedSchtasksCreateAndRunResult,
1256
+ options: ApplyElevatedOptions,
1257
+ ): Promise<void> {
1258
+ if (!attemptStillOwned(options)) {
1259
+ return;
1260
+ }
1261
+ const outcome: ElevatedSchedulerOutcome = result.outcome;
1262
+
1263
+ if (outcome === "create-failed") {
1264
+ throw new Error("Elevated schtasks /create failed. The Task Scheduler task was not registered.");
1265
+ }
1266
+ if (outcome === "run-failed-rolled-back") {
1267
+ throw new Error(
1268
+ "Elevated schtasks /run failed after the task was registered. The elevated process rolled the task back. Installation state was not written.",
1269
+ );
1270
+ }
1271
+ if (outcome === "run-failed-rollback-failed") {
1272
+ throwPartialInstall([
1273
+ "Elevated schtasks /run failed after the task was registered, and elevated rollback also failed.",
1274
+ "A partial Task Scheduler backend may remain.",
1275
+ `Remove the task manually with 'schtasks /delete /tn ${TASK} /f' if present.`,
1276
+ "Installation state was not written.",
1277
+ ]);
1278
+ }
1279
+ if (outcome !== "success") {
1280
+ await reconcileUnknownElevatedOutcome(result.exitCode);
1281
+ }
1282
+
1283
+ const verification = await verifyWindowsSchedulerInstallAfterSettle(options);
1284
+ // Ownership moved to a newer attempt while settling; that attempt owns the outcome.
1285
+ if (!verification) return;
1286
+ if (!verification.ok) {
1287
+ // Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status).
1288
+ // Unknown is not a confirmed dual-backend conflict; install state is still withheld.
1289
+ const preserveElevatedTask = verification.taskInstalled
1290
+ && verification.registrationHealthy
1291
+ && verification.assetsHealthy
1292
+ && !verification.conflict
1293
+ && verification.nativeStatusUnknown;
1294
+ if (preserveElevatedTask) {
1295
+ throwPartialInstall([
1296
+ "Elevated Task Scheduler registration did not produce a conflict-free install.",
1297
+ verification.detail,
1298
+ "The elevated Task Scheduler task was left in place because native WinSW status could not be verified.",
1299
+ "Installation state was not written.",
1300
+ ]);
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;
1305
+ const rollbackError = await rollbackElevatedSchedulerTask();
1306
+ const parts = [
1307
+ "Elevated Task Scheduler registration did not produce a conflict-free install.",
1308
+ verification.detail,
1309
+ ];
1310
+ if (rollbackError) {
1311
+ parts.push(`Rollback also failed: ${rollbackError}`);
1312
+ parts.push(`Remove the task manually with 'schtasks /delete /tn ${TASK} /f' and the native service with 'sc delete ${WINSW_SERVICE_ID}' if present.`);
1313
+ } else {
1314
+ parts.push("The elevated Task Scheduler task was rolled back.");
1315
+ }
1316
+ parts.push("Installation state was not written.");
1317
+ throwPartialInstall(parts);
1318
+ }
1319
+ if (options.writeOnSuccess) {
1320
+ if (!attemptStillOwned(options)) {
1321
+ return;
1322
+ }
1323
+ (finalizeHooks?.writeInstallState ?? (() => writeServiceInstallState("scheduler")))();
1324
+ }
1325
+ }
1326
+
1327
+ /** Outcome of late reconciliation after a request-level elevation timeout. */
1328
+ export type ElevatedReconciliationOutcome =
1329
+ | "released"
1330
+ | "blocked-partial";
1331
+
1332
+ export type FinalizeWindowsSchedulerResult =
1333
+ | { kind: "done" }
1334
+ | {
1335
+ kind: "indeterminate";
1336
+ attemptId: string;
1337
+ /** Settles after the elevated transaction finishes and late reconciliation runs. */
1338
+ reconciliation: Promise<ElevatedReconciliationOutcome>;
1339
+ };
1340
+
1341
+ export type FinalizeWindowsSchedulerOptions = {
1342
+ attemptId?: string;
1343
+ stillOwnsAttempt?: (attemptId: string) => boolean;
1344
+ requestTimeoutMs?: number;
1345
+ };
1346
+
1347
+ function startElevateExecution(
1348
+ schtasksPath: string,
1349
+ createArgs: string[],
1350
+ runArgs: string[],
1351
+ deleteArgs: string[],
1352
+ ): ElevatedSchtasksCreateAndRunExecution {
1353
+ if (finalizeHooks?.startElevateCreateAndRun) {
1354
+ return finalizeHooks.startElevateCreateAndRun(schtasksPath, createArgs, runArgs, deleteArgs);
1355
+ }
1356
+ if (finalizeHooks?.elevateCreateAndRun) {
1357
+ const completion = finalizeHooks.elevateCreateAndRun(schtasksPath, createArgs, runArgs, deleteArgs);
1358
+ return { completion, launcherPid: null };
1359
+ }
1360
+ return startElevatedSchtasksCreateAndRun(schtasksPath, createArgs, runArgs, deleteArgs);
1361
+ }
1362
+
1363
+ function isPartialInstallError(error: unknown): boolean {
1364
+ if (!(error instanceof Error)) return false;
1365
+ return /partial Task Scheduler/i.test(error.message)
1366
+ || /Cleanup also failed/i.test(error.message)
1367
+ || /left in place because native WinSW status could not be verified/i.test(error.message)
1368
+ || /Task Scheduler presence could not be verified/i.test(error.message);
1369
+ }
1370
+
1371
+ /**
1372
+ * Re-register the scheduler task with elevation after a non-elevated install wrote assets.
1373
+ *
1374
+ * Request timeout does not kill the elevated launcher. On timeout this returns
1375
+ * `indeterminate` and keeps reconciling the eventual protocol result.
1376
+ */
1377
+ export async function finalizeWindowsSchedulerServiceRegistration(
1378
+ script = windowsServiceScriptPath(),
1379
+ options?: FinalizeWindowsSchedulerOptions,
1380
+ ): Promise<FinalizeWindowsSchedulerResult> {
1381
+ if (process.platform !== "win32") {
1382
+ throw new Error("Windows scheduler registration is only supported on Windows.");
1383
+ }
1384
+ const attemptId = options?.attemptId ?? randomUUID();
1385
+ const stillOwnsAttempt = options?.stillOwnsAttempt ?? finalizeHooks?.stillOwnsAttempt;
1386
+ const createArgs = buildWindowsSchtasksCreateArgs(script);
1387
+ const runArgs = ["/run", "/tn", TASK];
1388
+ const deleteArgs = ["/delete", "/tn", TASK, "/f"];
1389
+ const started = startElevateExecution(windowsSchtasks(), createArgs, runArgs, deleteArgs);
1390
+ const timeoutMs = options?.requestTimeoutMs
1391
+ ?? finalizeHooks?.requestTimeoutMs
1392
+ ?? ELEVATION_REQUEST_TIMEOUT_MS;
1393
+ const applyOpts: ApplyElevatedOptions = { attemptId, writeOnSuccess: true, stillOwnsAttempt };
1394
+
1395
+ let raced: { status: "completed"; value: ElevatedSchtasksCreateAndRunResult } | { status: "timed-out" };
1396
+ try {
1397
+ raced = await raceWithTimeout(started.completion, timeoutMs);
1398
+ } catch (error) {
1399
+ // Cancellation / launch failure / signal before or instead of a protocol result.
1400
+ // Signal after Start-Process may leave an elevated child; reconcile conservatively.
1401
+ if (error instanceof WindowsElevationError && error.reason === "terminated") {
1402
+ try {
1403
+ await reconcileUnknownElevatedOutcome(OCX_ELEVATED_PROTOCOL_FAILED);
1404
+ } catch (reconcileError) {
1405
+ // Prefer the reconciliation detail (partial install / cleanup guidance) over the
1406
+ // generic signal message so callers can block retries when a task remains.
1407
+ throw reconcileError;
1408
+ }
1409
+ }
1410
+ throw error;
1411
+ }
1412
+
1413
+ if (raced.status === "completed") {
1414
+ await applyElevatedSchedulerResult(raced.value, applyOpts);
1415
+ return { kind: "done" };
1416
+ }
1417
+
1418
+ const reconciliation = (async (): Promise<ElevatedReconciliationOutcome> => {
1419
+ try {
1420
+ const result = await started.completion;
1421
+ await applyElevatedSchedulerResult(result, applyOpts);
1422
+ return "released";
1423
+ } catch (error) {
1424
+ if (error instanceof WindowsElevationError && error.reason === "cancelled") {
1425
+ return "released";
1426
+ }
1427
+ if (error instanceof WindowsElevationError && error.reason === "launch-failed") {
1428
+ return "released";
1429
+ }
1430
+ if (error instanceof WindowsElevationError && error.reason === "terminated") {
1431
+ try {
1432
+ await reconcileUnknownElevatedOutcome(OCX_ELEVATED_PROTOCOL_FAILED);
1433
+ return "released";
1434
+ } catch (reconcileError) {
1435
+ return isPartialInstallError(reconcileError) ? "blocked-partial" : "released";
1436
+ }
1437
+ }
1438
+ // applyElevatedSchedulerResult failures are expected (create/run/conflict); swallow for background.
1439
+ if (isPartialInstallError(error)) {
1440
+ return "blocked-partial";
1441
+ }
1442
+ return "released";
1443
+ }
1444
+ })();
1445
+
1446
+ return { kind: "indeterminate", attemptId, reconciliation };
1447
+ }
1448
+
1449
+ /**
1450
+ * Pure post-restart / pre-install advisory check. Does not mutate state.
1451
+ * A process-local indeterminate lock cannot survive restart — callers must inspect reality.
1452
+ */
1453
+ export function evaluateSchedulerInstallRestartReconciliation(inputs: {
1454
+ taskInstalled: boolean;
1455
+ registrationHealthy: boolean;
1456
+ assetsHealthy: boolean;
1457
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
1458
+ installStateBackend: "scheduler" | "native" | null;
1459
+ }): {
1460
+ status: "healthy" | "orphan-task" | "stale-install-state" | "conflict" | "unhealthy" | "unverified";
1461
+ detail: string;
1462
+ } {
1463
+ const conflict = inputs.taskInstalled
1464
+ && (inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
1465
+ if (conflict) {
1466
+ return {
1467
+ status: "conflict",
1468
+ detail: `CONFLICT: Task Scheduler and native WinSW (${WINSW_SERVICE_ID}) are both present.`,
1469
+ };
1470
+ }
1471
+ if (inputs.taskInstalled && inputs.nativeStatus === "unknown") {
1472
+ return {
1473
+ status: "unverified",
1474
+ detail: "The Task Scheduler task exists, but native WinSW status could not be verified.",
1475
+ };
1476
+ }
1477
+ if (inputs.taskInstalled && (!inputs.registrationHealthy || !inputs.assetsHealthy)) {
1478
+ return {
1479
+ status: "unhealthy",
1480
+ detail: !inputs.assetsHealthy
1481
+ ? "Required scheduler service assets are missing."
1482
+ : "Task Scheduler registration is present but unhealthy.",
1483
+ };
1484
+ }
1485
+ if (inputs.taskInstalled && inputs.installStateBackend !== "scheduler") {
1486
+ return {
1487
+ status: "orphan-task",
1488
+ detail: "A Task Scheduler task is present without matching scheduler install state.",
1489
+ };
1490
+ }
1491
+ if (!inputs.taskInstalled && inputs.installStateBackend === "scheduler") {
1492
+ return {
1493
+ status: "stale-install-state",
1494
+ detail: "Scheduler install state is present but the Task Scheduler task is absent.",
1495
+ };
1496
+ }
1497
+ return { status: "healthy", detail: "ok" };
1498
+ }
1499
+
1500
+ function windowsBatchValue(value: string): string {
1501
+ return value
1502
+ .replace(/%/g, "%%")
1503
+ .replace(/\^/g, "^^")
1504
+ .replace(/"/g, "")
1505
+ .replace(/[\r\n]/g, "");
1506
+ }
1507
+
1508
+ type WindowsBatchValueKind = "raw" | "path" | "pathList";
1509
+
1510
+ function windowsBatchSet(name: string, value: string | undefined, kind: WindowsBatchValueKind = "raw"): string | null {
1511
+ if (!value) return null;
1512
+ const rendered =
1513
+ kind === "path" ? windowsEnvIndirectBatchValue(value, windowsBatchValue)
1514
+ : kind === "pathList" ? windowsEnvIndirectBatchPathList(value, windowsBatchValue)
1515
+ : windowsBatchValue(value);
1516
+ return `set "${name}=${rendered}"`;
1517
+ }
1518
+
1519
+ function taskXmlString(value: string): string {
1520
+ return value
1521
+ .replace(/&/g, "&amp;")
1522
+ .replace(/</g, "&lt;")
1523
+ .replace(/>/g, "&gt;")
1524
+ .replace(/"/g, "&quot;")
1525
+ .replace(/'/g, "&apos;");
1526
+ }
1527
+
1528
+ /**
1529
+ * RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
1530
+ * `schtasks /create` often rewrites the registered task to HighestAvailable even when
1531
+ * the source XML asked for LeastPrivilege — still InteractiveToken / same user.
1532
+ * Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
1533
+ * installs, and windowsTaskRegistrationHealthy tests encode that contract.
1534
+ */
1535
+ function taskXmlRunLevelAcceptable(principal: string): boolean {
1536
+ if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
1537
+ const count = taskXmlElementCount(principal, "RunLevel");
1538
+ if (count === 0) return true;
1539
+ if (count > 1) return false;
1540
+ const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
1541
+ return value === "leastprivilege" || value === "highestavailable";
1542
+ }
1543
+
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;
1552
+ const path = process.env.PATH ?? "";
1553
+ const lines = [
1554
+ "@echo off",
1555
+ "setlocal",
1556
+ // The wrapper console is hidden by the wscript launcher (window style 0), so switching
1557
+ // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
1558
+ "chcp 65001 >nul",
1559
+ windowsBatchSet("OCX_SERVICE", "1"),
1560
+ windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
1561
+ windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
1562
+ windowsBatchSet("PATH", path, "pathList"),
1563
+ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
1564
+ windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
1565
+ windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
1566
+ ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
1567
+ windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
1568
+ windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
1569
+ windowsBatchSet("OCX_BUN", bun, "path"),
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"',
1574
+ 'if exist "%OCX_API_TOKEN_FILE%" (',
1575
+ ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
1576
+ ")",
1577
+ ":loop",
1578
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
1579
+ '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
1580
+ `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
1581
+ '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
1582
+ '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
1583
+ '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
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
+ ")",
1599
+ `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
1600
+ "if %ERRORLEVEL% NEQ 0 (",
1601
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
1602
+ // `timeout` needs console stdin and dies with "Input redirection is not supported"
1603
+ // under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
1604
+ " ping -n 6 127.0.0.1 >nul",
1605
+ " goto loop",
1606
+ ")",
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",
1628
+ ].filter((line): line is string => Boolean(line));
1629
+ return `${lines.join("\r\n")}\r\n`;
1630
+ }
1631
+
1632
+ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
1633
+ const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
1634
+ return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1635
+ }
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
+
1642
+ /**
1643
+ * VBS launcher that starts the batch wrapper with a hidden window (style 0).
1644
+ * bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
1645
+ * scheduled task stays "running": MultipleInstancesPolicy=IgnoreNew keeps preventing
1646
+ * duplicates and `schtasks /end` still has a live task instance to stop. Without the
1647
+ * launcher, the console batch action shows a closable cmd window in the interactive
1648
+ * session (issue #165). VBS string literals escape `"` as `""`.
1649
+ */
1650
+ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): string {
1651
+ const escaped = script.replace(/"/g, '""');
1652
+ const lines = [
1653
+ "' OpenCodex service launcher — runs the batch wrapper with a hidden window.",
1654
+ "' Generated by `ocx service install`; do not edit.",
1655
+ 'Set shell = CreateObject("WScript.Shell")',
1656
+ // WshShell.Run(command, windowStyle 0 = hidden, bWaitOnReturn True = stay resident).
1657
+ `shell.Run """${escaped}""", 0, True`,
1658
+ ];
1659
+ return `${lines.join("\r\n")}\r\n`;
1660
+ }
1661
+
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 {
1673
+ const escapedWscript = taskXmlString(windowsWscript());
1674
+ // Escape the launcher path independently for the <Arguments> element; quoting it
1675
+ // keeps spaces intact, and /b (batch mode) suppresses script error popups.
1676
+ const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
1677
+ return `<?xml version="1.0" encoding="UTF-16"?>
1678
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1679
+ <RegistrationInfo>
1680
+ <Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
1681
+ </RegistrationInfo>
1682
+ <Triggers>
1683
+ <LogonTrigger>
1684
+ <Enabled>true</Enabled>
1685
+ </LogonTrigger>
1686
+ </Triggers>
1687
+ <Principals>
1688
+ <Principal id="Author">
1689
+ <LogonType>InteractiveToken</LogonType>
1690
+ <RunLevel>LeastPrivilege</RunLevel>
1691
+ </Principal>
1692
+ </Principals>
1693
+ <Settings>
1694
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1695
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1696
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1697
+ <AllowHardTerminate>true</AllowHardTerminate>
1698
+ <StartWhenAvailable>true</StartWhenAvailable>
1699
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
1700
+ <AllowStartOnDemand>true</AllowStartOnDemand>
1701
+ <Enabled>true</Enabled>
1702
+ <Hidden>false</Hidden>
1703
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1704
+ <Priority>7</Priority>
1705
+ <RestartOnFailure>
1706
+ <Interval>PT1M</Interval>
1707
+ <Count>3</Count>
1708
+ </RestartOnFailure>
1709
+ </Settings>
1710
+ <Actions Context="Author">
1711
+ <Exec>
1712
+ <Command>${escapedWscript}</Command>
1713
+ <Arguments>${escapedLauncherArgs}</Arguments>
1714
+ </Exec>
1715
+ </Actions>
1716
+ </Task>
1717
+ `;
1718
+ }
1719
+
1720
+ function taskXmlSection(xml: string, tag: string): string {
1721
+ return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? "";
1722
+ }
1723
+
1724
+ /** Drop comments and CDATA so a commented-out decoy cannot satisfy any check. */
1725
+ function taskXmlWithoutCommentsAndCdata(xml: string): string {
1726
+ return xml.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
1727
+ }
1728
+
1729
+ /**
1730
+ * Count occurrences of an unprefixed tag, including the self-closing form. The
1731
+ * element boundary matters: `<EnabledExtra>` must not count as `Enabled`.
1732
+ */
1733
+ function taskXmlElementCount(xml: string, tag: string): number {
1734
+ return xml.match(new RegExp(`<${tag}(?:\\s[^>]*?)?\\s*\\/?>`, "gi"))?.length ?? 0;
1735
+ }
1736
+
1737
+ /**
1738
+ * True when a namespace-prefixed form of the tag appears. A prefixed element bound
1739
+ * to the task namespace carries a real value, but this module parses by regex and
1740
+ * cannot resolve prefixes — so it fails closed instead of reading the element as
1741
+ * absent (which would silently apply the schema default).
1742
+ */
1743
+ function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
1744
+ return new RegExp(`<[A-Za-z_][\\w.-]*:${tag}(?:[\\s/>])`, "i").test(xml);
1745
+ }
1746
+
1747
+ /**
1748
+ * Compare an element that Task Scheduler may omit when exporting a registered task.
1749
+ * Absence means the documented schema default (#432); a present element must still
1750
+ * match exactly, so a malformed or explicitly unsafe value never reads as healthy.
1751
+ */
1752
+ /**
1753
+ * Decode XML's five predefined entities, exactly once.
1754
+ *
1755
+ * Task Scheduler re-encodes element text when it exports a task, so a needle we
1756
+ * escaped ourselves can never match its output (#608). Compare decoded values
1757
+ * instead of encoded ones.
1758
+ *
1759
+ * The single pass is the point: decoding twice would turn `&amp;quot;` into `"`,
1760
+ * letting a doubly-encoded value impersonate the expected launcher path.
1761
+ */
1762
+ function taskXmlDecodeEntities(value: string): string {
1763
+ return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => (
1764
+ name === "amp" ? "&"
1765
+ : name === "lt" ? "<"
1766
+ : name === "gt" ? ">"
1767
+ : name === "quot" ? "\""
1768
+ : "'"
1769
+ ));
1770
+ }
1771
+
1772
+ /**
1773
+ * Exactly one unprefixed `<tag>` whose DECODED text equals `expected`.
1774
+ *
1775
+ * Unlike taskXmlOptionalValueEquals(), an absent element is NOT a pass: these
1776
+ * elements name what actually gets executed, so a missing <Command>/<Arguments>
1777
+ * must fail the health check rather than inherit a schema default.
1778
+ */
1779
+ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string): boolean {
1780
+ // Same reasoning as the optional helper: `<t:Arguments>` must not read as absent.
1781
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
1782
+ if (taskXmlElementCount(xml, tag) !== 1) return false;
1783
+ // `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
1784
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
1785
+ if (value === undefined) return false;
1786
+ return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
1787
+ }
1788
+
1789
+ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
1790
+ // Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
1791
+ // omission would turn an explicitly disabled task into a healthy one.
1792
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
1793
+ const count = taskXmlElementCount(xml, tag);
1794
+ if (count === 0) return true;
1795
+ if (count > 1) return false;
1796
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/${tag}>`, "i").exec(xml)?.[1];
1797
+ return value?.trim().toLowerCase() === expected.toLowerCase();
1798
+ }
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
+
1815
+ /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
1816
+ export function windowsTaskRegistrationHealthy(
1817
+ xml: string,
1818
+ wscript = windowsWscript(),
1819
+ launcher = windowsLauncherVbsPath(),
1820
+ ): boolean {
1821
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
1822
+ // taskXmlSection() takes the FIRST match and the schema allows arbitrary XML under
1823
+ // Task/Data, so a Data block placed before the real sections could shadow them.
1824
+ // We never emit Data, so its presence alone disqualifies the registration. Both
1825
+ // forms are rejected because taskXmlElementCount() ignores prefixed tags.
1826
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
1827
+ const triggers = taskXmlSection(scrubbed, "Triggers");
1828
+ const trigger = taskXmlSection(triggers, "LogonTrigger");
1829
+ const principal = taskXmlSection(scrubbed, "Principal");
1830
+ const settings = taskXmlSection(scrubbed, "Settings");
1831
+ const action = taskXmlSection(scrubbed, "Exec");
1832
+ // A self-closing <LogonTrigger /> leaves an empty section, so look for the element
1833
+ // itself — scoped to <Triggers> so a decoy elsewhere cannot satisfy it.
1834
+ return taskXmlElementCount(triggers, "LogonTrigger") > 0
1835
+ && taskXmlOptionalValueEquals(trigger, "Enabled", "true")
1836
+ && /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
1837
+ && taskXmlRunLevelAcceptable(principal)
1838
+ && taskXmlOptionalValueEquals(settings, "Enabled", "true")
1839
+ && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
1840
+ && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
1841
+ // Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
1842
+ // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
1843
+ // needle never matched and a healthy task read as permanently stale (#608).
1844
+ // Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
1845
+ && taskXmlDecodedValueEquals(action, "Command", wscript)
1846
+ && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
1847
+ }
1848
+
1849
+ export interface WindowsSchedulerXmlState {
1850
+ installed: boolean;
1851
+ enabled: boolean;
1852
+ registrationHealthy: boolean;
1853
+ }
1854
+
1855
+ /**
1856
+ * Single source of truth for reading a registered task's XML. Both the status
1857
+ * diagnostic and its tests go through here, so a partial fix cannot leave one
1858
+ * caller on an older, stricter reading of the same document (#432).
1859
+ */
1860
+ export function readWindowsSchedulerXmlState(
1861
+ xml: string,
1862
+ wscript?: string,
1863
+ launcher?: string,
1864
+ ): WindowsSchedulerXmlState {
1865
+ const installed = xml.length > 0;
1866
+ if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
1867
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
1868
+ const hasData = taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data");
1869
+ const settings = hasData ? "" : taskXmlSection(scrubbed, "Settings");
1870
+ return {
1871
+ installed: true,
1872
+ enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"),
1873
+ registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher),
1874
+ };
1875
+ }
1876
+
1877
+ // ── macOS (launchd) ──
1878
+ function installLaunchd(): void {
1879
+ const dir = join(homedir(), "Library", "LaunchAgents");
1880
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1881
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1882
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1883
+ writeServiceApiTokenFile();
1884
+ const p = plistPath();
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");
1889
+ // Best-effort: an absent job is fine here, and a failed unload is caught by the
1890
+ // load verification below with a better message than a raw unload error.
1891
+ runLaunchctl(["unload", p]);
1892
+ const loaded = runLaunchctl(["load", "-w", p]);
1893
+ if (!loaded.ok || launchctlLoadFailed(loaded.stderr)) {
1894
+ // Do NOT write install state for a load that did not take: state describing an
1895
+ // unused plist is what made this failure invisible.
1896
+ throw new Error(
1897
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1898
+ + "A previous job may still be bootstrapped. Try:\n"
1899
+ + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
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"}'.`,
1903
+ );
1904
+ }
1905
+ writeServiceInstallState();
1906
+ }
1907
+ /**
1908
+ * Deps are named for the layer they replace, not for the process API: `launchctl`
1909
+ * returns a {@link runLaunchctl} result and `matches` a {@link launchdJobMatchesPlist}
1910
+ * result. Only `runLaunchctl` itself takes a spawnSync mock.
1911
+ *
1912
+ * Exported for the branch tests. Every parameter is optional, so this stays
1913
+ * assignable to `ServiceOps.start` (`() => void`) and `platformOps` wires the same
1914
+ * function the tests exercise.
1915
+ */
1916
+ export function startLaunchd(deps: {
1917
+ launchctl?: typeof runLaunchctl;
1918
+ matches?: typeof launchdJobMatchesPlist;
1919
+ } = {}): void {
1920
+ const run = deps.launchctl ?? runLaunchctl;
1921
+ const p = plistPath();
1922
+ const loaded = run(["load", "-w", p]);
1923
+ if (loaded.ok && !launchctlLoadFailed(loaded.stderr)) return;
1924
+ // `Load failed` on start is AMBIGUOUS in a way it is not on install: the job may
1925
+ // already be bootstrapped from THIS plist, which is a no-op rather than an error.
1926
+ // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
1927
+ // throwing here would break `ocx service start` on every healthy service.
1928
+ const entry = cliEntry();
1929
+ const live = (deps.matches ?? launchdJobMatchesPlist)(
1930
+ buildServiceShellCommand(entry.bun, entry.cli),
1931
+ );
1932
+ if (live.loaded && live.matchesPlist) {
1933
+ console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
1934
+ return;
1935
+ }
1936
+ throw new Error(
1937
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1938
+ + (live.loaded
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."),
1941
+ );
1942
+ }
1943
+ function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
1944
+ function statusLaunchd(): string { try { return sh(`launchctl list | grep ${LABEL} || true`); } catch { return ""; } }
1945
+ function uninstallLaunchd(): void {
1946
+ const p = plistPath();
1947
+ try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
1948
+ if (existsSync(p)) unlinkSync(p);
1949
+ }
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
+
1997
+ // ── Windows (Task Scheduler) ──
1998
+ /**
1999
+ * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
2000
+ * throws while the just-ended task's cmd.exe (or an AV scanner) still holds the file.
2001
+ */
2002
+ function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
2003
+ for (let attempt = 0; ; attempt++) {
2004
+ try {
2005
+ writeServiceDefinitionFile(path, content, encoding);
2006
+ return;
2007
+ } catch (err) {
2008
+ const code = (err as NodeJS.ErrnoException).code;
2009
+ if (attempt >= 2 || (code !== "EBUSY" && code !== "EPERM" && code !== "EACCES")) throw err;
2010
+ Bun.sleepSync(150);
2011
+ }
2012
+ }
2013
+ }
2014
+
2015
+ /**
2016
+ * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task.
2017
+ * Used by fresh install (before schtasks /create) and by repair (no elevation).
2018
+ */
2019
+ function writeWindowsSchedulerAssets(): void {
2020
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2021
+ writeServiceApiTokenFile();
2022
+ const script = windowsServiceScriptPath();
2023
+ writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
2024
+ // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
2025
+ // paths on some WSH/codepage combinations — same contract as the task XML below.
2026
+ writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
2027
+ writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
2028
+ }
2029
+
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);
2240
+ // Transactional backend switch: installing the scheduler backend removes a native
2241
+ // service first — two live managers would both respawn the proxy (conflict).
2242
+ if (status() !== "nonexistent") {
2243
+ console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
2244
+ try {
2245
+ uninstall();
2246
+ } catch (err) {
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.`);
2248
+ }
2249
+ for (let check = 0; check < settleChecks; check++) {
2250
+ if (status() === "nonexistent") return;
2251
+ if (check + 1 < settleChecks) sleep(250);
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.`);
2254
+ }
2255
+ }
2256
+
2257
+ function installWindows(): void {
2258
+ recordWindowsSchedulerOwnership();
2259
+ removeNativeWindowsServiceForScheduler();
2260
+ // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
2261
+ // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
2262
+ try { stopWindows(); } catch { /* not running */ }
2263
+ writeWindowsSchedulerAssets();
2264
+ schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
2265
+ schtasks(["/run", "/tn", TASK]);
2266
+ writeServiceInstallState("scheduler");
2267
+ }
2268
+
2269
+ export interface RepairServiceDeps {
2270
+ diagnose?: () => ServiceDiagnostic;
2271
+ assertEnv?: () => void;
2272
+ assertAuth?: () => void;
2273
+ writeSchedulerAssets?: () => void;
2274
+ stopScheduler?: () => void;
2275
+ startScheduler?: () => void;
2276
+ writeSchedulerState?: () => void;
2277
+ writeNativeState?: () => void;
2278
+ repairNative?: () => void | Promise<void>;
2279
+ repairLaunchd?: () => void;
2280
+ repairSystemd?: () => void;
2281
+ /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
2282
+ platform?: NodeJS.Platform;
2283
+ }
2284
+
2285
+ /**
2286
+ * Repair an already-installed background service without Task Scheduler re-registration.
2287
+ *
2288
+ * Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC.
2289
+ * Windows native: WinSW asset rewrite + restart (skips `install /p` when present).
2290
+ * macOS/Linux: re-run the user-level install/reload path.
2291
+ */
2292
+ export async function repairService(deps: RepairServiceDeps = {}): Promise<void> {
2293
+ const diagnose = deps.diagnose ?? diagnoseService;
2294
+ const platform = deps.platform ?? process.platform;
2295
+ const diag = diagnose();
2296
+ if (!diag.supported) {
2297
+ throw new Error(`Background service is unsupported (${diag.summary}).`);
2298
+ }
2299
+ if (diag.conflict) {
2300
+ throw new Error(
2301
+ "Cannot repair while Task Scheduler and native WinSW are both present. "
2302
+ + "Run 'ocx service uninstall' then reinstall one backend with 'ocx service install'.",
2303
+ );
2304
+ }
2305
+ if (!diag.installed) {
2306
+ throw new Error("Background service is not installed. Run 'ocx service install' first.");
2307
+ }
2308
+
2309
+ (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
2310
+ (deps.assertAuth ?? assertServiceAuthEnvironment)();
2311
+
2312
+ if (platform === "win32") {
2313
+ if (diag.backend === "native") {
2314
+ await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(import.meta.dir))))();
2315
+ (deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
2316
+ return;
2317
+ }
2318
+ try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
2319
+ (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
2320
+ (deps.startScheduler ?? startWindows)();
2321
+ (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
2322
+ return;
2323
+ }
2324
+ if (platform === "darwin") {
2325
+ (deps.repairLaunchd ?? installLaunchd)();
2326
+ return;
2327
+ }
2328
+ if (platform === "linux") {
2329
+ (deps.repairSystemd ?? installSystemd)();
2330
+ return;
2331
+ }
2332
+ throw new Error(`Background service repair is unsupported on ${platform}.`);
2333
+ }
2334
+
2335
+ /**
2336
+ * Opt-in native backend (`ocx service install --native`). Transactional: removes the
2337
+ * scheduler backend first; on failure the machine is left with NO service (explicitly
2338
+ * reported) — never a silent fallback to the scheduler.
2339
+ */
2340
+ /** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */
2341
+ export function assertWindowsNativeServiceAccountSupported(): void {
2342
+ if (process.platform !== "win32") return;
2343
+ const source = readWindowsPrincipalSource();
2344
+ if (source?.toLowerCase() === "microsoftaccount") {
2345
+ throw new Error(
2346
+ "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. "
2347
+ + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.",
2348
+ );
2349
+ }
2350
+ }
2351
+
2352
+ function readWindowsPrincipalSource(): string | null {
2353
+ if (process.platform !== "win32") return null;
2354
+ const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
2355
+ if (!existsSync(ps)) return null;
2356
+ try {
2357
+ const out = execFileSync(ps, [
2358
+ "-NoLogo",
2359
+ "-NoProfile",
2360
+ "-NonInteractive",
2361
+ "-Command",
2362
+ "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource",
2363
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
2364
+ return out || null;
2365
+ } catch {
2366
+ return null;
2367
+ }
2368
+ }
2369
+
2370
+ async function installWindowsNative(): Promise<void> {
2371
+ assertWindowsNativeServiceAccountSupported();
2372
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2373
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2374
+ writeServiceApiTokenFile();
2375
+ let hadScheduler = false;
2376
+ try {
2377
+ hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK);
2378
+ } catch { /* task absent */ }
2379
+ if (hadScheduler) {
2380
+ console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service...");
2381
+ try { stopWindows(); } catch { /* not running */ }
2382
+ try {
2383
+ uninstallWindows();
2384
+ } catch (err) {
2385
+ throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`);
2386
+ }
2387
+ // Verify removal — schtasks /delete can silently fail if UAC or policy blocks it.
2388
+ try {
2389
+ if (schtasks(["/query", "/tn", TASK]).includes(TASK)) {
2390
+ throw new Error("Task Scheduler backend still present after removal — aborting switch.");
2391
+ }
2392
+ } catch (e) {
2393
+ if (e instanceof Error && e.message.includes("still present")) throw e;
2394
+ /* query failure = task absent, which is what we want */
2395
+ }
2396
+ }
2397
+ try {
2398
+ await installWinswService(defaultWinswEntry(import.meta.dir));
2399
+ } catch (err) {
2400
+ if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`.");
2401
+ throw err;
2402
+ }
2403
+ writeServiceInstallState("native");
2404
+ }
2405
+ function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
2406
+
2407
+ export function isWindowsSchedulerEndBenign(error: unknown): boolean {
2408
+ const detail = schtasksErrorDetail(error).toLowerCase();
2409
+ return detail.includes("no running instance")
2410
+ || detail.includes("not currently running")
2411
+ || detail.includes("0x41330");
2412
+ }
2413
+
2414
+ /**
2415
+ * End the scheduler task. "Already stopped" is success; other `/end` failures are
2416
+ * swallowed so callers can still run tracked-proxy + live-proxy cleanup.
2417
+ *
2418
+ * Do not key a restart-window wait on `/end` failure: the #764 case is an `/end`
2419
+ * that *succeeds* while the wrapper survives and respawns. That verification lives
2420
+ * on the stop-verification path (poll across the restart window), not here.
2421
+ */
2422
+ export function stopWindows(): void {
2423
+ try {
2424
+ schtasks(["/end", "/tn", TASK]);
2425
+ } catch (error) {
2426
+ if (isWindowsSchedulerEndBenign(error)) return;
2427
+ }
2428
+ }
2429
+ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
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
+ }
2447
+ function uninstallWindows(): void {
2448
+ const probe = probeWindowsSchedulerTask(TASK);
2449
+ if (probe.status === "present") {
2450
+ try {
2451
+ schtasks(["/delete", "/tn", TASK, "/f"]);
2452
+ } catch (error) {
2453
+ throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`);
2454
+ }
2455
+ const afterDelete = probeWindowsSchedulerTask(TASK);
2456
+ if (afterDelete.status === "present") {
2457
+ throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`);
2458
+ }
2459
+ if (afterDelete.status === "unknown") {
2460
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`);
2461
+ }
2462
+ } else if (probe.status === "unknown") {
2463
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`);
2464
+ }
2465
+ if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
2466
+ if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath());
2467
+ if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
2468
+ }
2469
+
2470
+ /**
2471
+ * Warn when the paths baked into installed service assets no longer exist (npm prefix
2472
+ * moved, nvm switch, reinstall) — the service manager would restart-loop on a dead path
2473
+ * while `schtasks`/`launchctl` still report "installed".
2474
+ */
2475
+ export function bakedServicePathsDiagnostic(): string | null {
2476
+ const state = readServiceInstallState();
2477
+ if (!state?.bunPath || !state?.cliPath) return null;
2478
+ const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
2479
+ if (missing.length === 0) return null;
2480
+ return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
2481
+ }
2482
+
2483
+ function serviceDiagnosticsSummary(): string {
2484
+ const stale = bakedServicePathsDiagnostic();
2485
+ return stale ? `${stale}; logs: ${serviceLogPath()}` : `logs: ${serviceLogPath()}`;
2486
+ }
2487
+
2488
+ // ── Linux (systemd user unit) ──
2489
+ function unitDir(): string {
2490
+ return join(homedir(), ".config", "systemd", "user");
2491
+ }
2492
+
2493
+ function unitPath(): string {
2494
+ return join(unitDir(), `${TASK}.service`);
2495
+ }
2496
+
2497
+ export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
2498
+ const { bun, bunRuntimeSource, cli } = cliEntry();
2499
+ const log = logPath();
2500
+ const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
2501
+ const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
2502
+ const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
2503
+ const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
2504
+ const envLines = [
2505
+ systemdEnvironmentAssignment("OCX_SERVICE", "1"),
2506
+ systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
2507
+ systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
2508
+ systemdEnvironmentAssignment("PATH", path),
2509
+ codexHome,
2510
+ codexSqliteHome,
2511
+ opencodexHome,
2512
+ ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
2513
+ ].filter((line): line is string => Boolean(line)).join("\n");
2514
+ return `[Unit]
2515
+ Description=OpenCodex Proxy Server
2516
+ After=network-online.target
2517
+ Wants=network-online.target
2518
+
2519
+ [Service]
2520
+ Type=simple
2521
+ ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))}
2522
+ Restart=on-failure
2523
+ RestartSec=5
2524
+ ${envLines}
2525
+ StandardOutput=${systemdOutputTarget(`append:${log}`)}
2526
+ StandardError=${systemdOutputTarget(`append:${log}`)}
2527
+
2528
+ [Install]
2529
+ WantedBy=default.target
2530
+ `;
2531
+ }
2532
+
2533
+ /** The per-user runtime dir systemd creates (holds the user-bus socket), or null. */
2534
+ function userRuntimeDir(): string | null {
2535
+ const fromEnv = process.env.XDG_RUNTIME_DIR;
2536
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
2537
+ if (typeof process.getuid === "function") {
2538
+ const candidate = `/run/user/${process.getuid()}`;
2539
+ if (existsSync(candidate)) return candidate;
2540
+ }
2541
+ return null;
2542
+ }
2543
+
2544
+ /**
2545
+ * SSH sessions frequently start without `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS`, so
2546
+ * `systemctl --user` can't find the user bus even when systemd is running. Point `XDG_RUNTIME_DIR`
2547
+ * at the per-user runtime dir when it exists so the `--user` probe and install commands reach the
2548
+ * bus. No-op when already set or when no runtime dir exists (e.g. genuinely non-systemd hosts).
2549
+ */
2550
+ function ensureUserBusEnv(): void {
2551
+ if (process.env.XDG_RUNTIME_DIR) return;
2552
+ const dir = userRuntimeDir();
2553
+ if (dir) process.env.XDG_RUNTIME_DIR = dir;
2554
+ }
2555
+
2556
+ function isSystemd(): boolean {
2557
+ try { execSync("systemctl --version", { stdio: "pipe" }); } catch { return false; }
2558
+ ensureUserBusEnv();
2559
+ // Prefer the user-bus probe; but an SSH session without a user D-Bus fails it even when systemd
2560
+ // is present (F9). Fall back to the per-user runtime dir existing — a strong signal the user
2561
+ // systemd instance is available — so a first-time `ocx service install` isn't wrongly refused.
2562
+ try { execSync("systemctl --user show-environment", { stdio: "pipe" }); return true; } catch { /* no user bus in this session */ }
2563
+ return userRuntimeDir() !== null;
2564
+ }
2565
+
2566
+ function installSystemd(): void {
2567
+ ensureUserBusEnv(); // reach the user bus over a bare SSH session (F9)
2568
+ const dir = unitDir();
2569
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2570
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2571
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2572
+ writeServiceApiTokenFile();
2573
+ writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
2574
+ sh("systemctl --user daemon-reload");
2575
+ sh(`systemctl --user enable ${TASK}`);
2576
+ sh(`systemctl --user restart ${TASK}`);
2577
+ writeServiceInstallState();
2578
+ }
2579
+ /**
2580
+ * Whether systemd's in-memory unit differs from the file on disk.
2581
+ *
2582
+ * The systemd analogue of launchd's stale-plist case: writing
2583
+ * `~/.config/systemd/user/<unit>` does not change the definition systemd has loaded
2584
+ * until `daemon-reload`, so a plain `systemctl start` would run the PREVIOUS
2585
+ * ExecStart. `NeedDaemonReload` is a per-unit property emitted as a bare
2586
+ * `NeedDaemonReload=yes|no` line; pass the unit name or `show` reports the manager's
2587
+ * own property instead, which answers a different question.
2588
+ *
2589
+ * Fail-open: if the query cannot run (no user bus, unit absent) we must not block a
2590
+ * start that would otherwise work.
2591
+ */
2592
+ export function systemdNeedsDaemonReload(deps: { show?: () => string } = {}): boolean {
2593
+ try {
2594
+ const out = (deps.show ?? (() => sh(`systemctl --user show -p NeedDaemonReload ${TASK}`)))();
2595
+ return /NeedDaemonReload\s*=\s*yes/i.test(out);
2596
+ } catch {
2597
+ return false;
2598
+ }
2599
+ }
2600
+
2601
+ function startSystemd(): void {
2602
+ ensureUserBusEnv();
2603
+ if (!existsSync(unitPath())) {
2604
+ console.error(`opencodex service is not installed: ${unitPath()}`);
2605
+ console.error("Run `ocx service install` first to create and enable the systemd user unit.");
2606
+ process.exit(1);
2607
+ }
2608
+ // The unit on disk may be newer than what systemd loaded; starting now would run
2609
+ // the previous definition.
2610
+ //
2611
+ // `start` alone is not enough after a reload: it is a no-op on an already-active
2612
+ // unit, so the stale process would keep running the old ExecStart. NeedDaemonReload
2613
+ // compares disk against loaded, never loaded against running, so the only way to
2614
+ // make the running process match the file is to restart it.
2615
+ if (systemdNeedsDaemonReload()) {
2616
+ console.log("ℹ️ unit file changed on disk; reloading systemd and restarting the service.");
2617
+ sh("systemctl --user daemon-reload");
2618
+ sh(`systemctl --user restart ${TASK}`);
2619
+ return;
2620
+ }
2621
+ sh(`systemctl --user start ${TASK}`);
2622
+ }
2623
+ function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
2624
+ function statusSystemd(): string { try { return sh(`systemctl --user status ${TASK}`); } catch { return ""; } }
2625
+ function uninstallSystemd(): void {
2626
+ try { sh(`systemctl --user disable --now ${TASK}`); } catch { /* absent */ }
2627
+ if (existsSync(unitPath())) unlinkSync(unitPath());
2628
+ try { sh("systemctl --user daemon-reload"); } catch { /* best-effort */ }
2629
+ }
2630
+
2631
+ type ServiceOps = {
2632
+ install: () => void | Promise<void>; start: () => void; stop: () => void;
2633
+ status: () => string; uninstall: () => void;
2634
+ };
2635
+
2636
+ type ServiceInstallCleanupOps = {
2637
+ status: () => string | null;
2638
+ stop: () => void;
2639
+ };
2640
+
2641
+ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
2642
+ if (process.platform === "darwin")
2643
+ return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
2644
+ if (process.platform === "win32") {
2645
+ if (backend === "native")
2646
+ return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
2647
+ return { install: installWindows, start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows };
2648
+ }
2649
+ if (process.platform === "linux") {
2650
+ if (existsSync("/.dockerenv")) {
2651
+ console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
2652
+ process.exit(1);
2653
+ }
2654
+ if (!isSystemd() && !existsSync(unitPath())) {
2655
+ console.error("systemd not found. Run 'ocx start' under your process supervisor.");
2656
+ if (isWslRuntime()) {
2657
+ console.error("WSL detected: enable systemd by adding [boot] systemd=true to /etc/wsl.conf, then run 'wsl --shutdown' from Windows and reopen the distro (WSL 0.67.6+).");
2658
+ }
2659
+ process.exit(1);
2660
+ }
2661
+ return { install: installSystemd, start: startSystemd, stop: stopSystemd, status: statusSystemd, uninstall: uninstallSystemd };
2662
+ }
2663
+ return null;
2664
+ }
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
+
2727
+ type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
2728
+
2729
+ function verifiedKillTarget(pid: number | null | undefined): number | null {
2730
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
2731
+ const verified = verifyPidIdentity(pid);
2732
+ return verified === pid ? verified : null;
2733
+ }
2734
+
2735
+ /**
2736
+ * Whether a proxy is still answering after the service manager claimed to stop it.
2737
+ *
2738
+ * `ops.stop()` reports the outcome of the STOP COMMAND, not of the process. A Windows scheduler
2739
+ * task whose wrapper survives `schtasks /end` respawns its child a few seconds later, so a stop
2740
+ * that returned success can still leave a live proxy — and `ocx service stop` then restored
2741
+ * native Codex on top of a running one (#764). The tracked-pid cleanup does not catch it either:
2742
+ * the respawned child writes a different pid, or none this process knows about.
2743
+ *
2744
+ * Probed rather than assumed, and bounded. The respawn risk is specific to a supervisor that can
2745
+ * restart its child — the Windows scheduler wrapper — so only that case pays the restart window.
2746
+ * Everywhere else a single probe answers the question, because nothing is going to bring the
2747
+ * proxy back after `launchctl unload` or `systemctl stop`. Making every platform wait 7s on a
2748
+ * stop that already succeeded would trade one bug for a worse everyday one.
2749
+ */
2750
+ export async function proxyStillLiveAfterStop(deps: {
2751
+ findProxy?: () => Promise<{ port: number } | null>;
2752
+ sleep?: (ms: number) => Promise<void>;
2753
+ now?: () => number;
2754
+ /** Whether the stopped supervisor can respawn its child; only then is polling worth the wait. */
2755
+ canRespawn?: boolean;
2756
+ } = {}): Promise<{ port: number } | null> {
2757
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
2758
+ const now = deps.now ?? Date.now;
2759
+ const canRespawn = deps.canRespawn ?? process.platform === "win32";
2760
+ const deadline = now() + (canRespawn ? 7000 : 0);
2761
+ // Single-shot (non-respawn) still needs one full SERVICE_STOP_LIVENESS budget; respawn
2762
+ // polling shares the outer deadline so multi-candidate discovery cannot overrun it.
2763
+ const findProxy = deps.findProxy ?? (() => {
2764
+ const probeDeadline = canRespawn
2765
+ ? deadline
2766
+ : now() + (SERVICE_STOP_LIVENESS.timeoutMs! * SERVICE_STOP_LIVENESS.attempts! + 250);
2767
+ return findLiveProxy({ ...SERVICE_STOP_LIVENESS, deadlineAt: probeDeadline, nowFn: now });
2768
+ });
2769
+ for (;;) {
2770
+ try {
2771
+ const live = await findProxy();
2772
+ if (live) return live;
2773
+ } catch {
2774
+ // A probe failure is not proof the proxy is gone; keep polling until the deadline.
2775
+ }
2776
+ if (now() >= deadline) return null;
2777
+ await sleep(1000);
2778
+ }
2779
+ }
2780
+
2781
+ async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
2782
+ let stopped = false;
2783
+ const pid = readPid();
2784
+ const trackedKillPid = verifiedKillTarget(pid);
2785
+ if (trackedKillPid !== null && isProcessAlive(trackedKillPid)) {
2786
+ await stopProxy(trackedKillPid);
2787
+ removePid(trackedKillPid);
2788
+ removeRuntimePort(trackedKillPid);
2789
+ stopped = true;
2790
+ } else if (pid) {
2791
+ removePid(pid);
2792
+ removeRuntimePort(pid);
2793
+ }
2794
+ // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
2795
+ // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
2796
+ // Cap multi-candidate discovery so stop cleanup cannot hang for three full retry budgets.
2797
+ const live = await findLiveProxy({
2798
+ ...SERVICE_STOP_LIVENESS,
2799
+ deadlineAt: Date.now() + 7000,
2800
+ });
2801
+ const liveKillPid = verifiedKillTarget(live?.pid);
2802
+ if (liveKillPid !== null) {
2803
+ await stopProxy(liveKillPid);
2804
+ removePid(liveKillPid);
2805
+ removeRuntimePort(liveKillPid);
2806
+ stopped = true;
2807
+ }
2808
+ if (stopped) return "stopped";
2809
+ if (pid) return "stale";
2810
+ return "none";
2811
+ }
2812
+
2813
+ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
2814
+ try {
2815
+ return await stopTrackedProxyIfRunning();
2816
+ } catch (err) {
2817
+ console.error(`⚠️ Failed to stop proxy: ${err instanceof Error ? err.message : String(err)}`);
2818
+ return "none";
2819
+ }
2820
+ }
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
+
2975
+ /**
2976
+ * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
2977
+ * Returns true if a service was found and stopped.
2978
+ */
2979
+ export function stopServiceIfInstalled(): boolean {
2980
+ assertServiceEnvironmentMatchesInstall();
2981
+ if (process.platform === "darwin") {
2982
+ if (existsSync(plistPath())) {
2983
+ try { stopLaunchd(); return true; } catch { return false; }
2984
+ }
2985
+ } else if (process.platform === "win32") {
2986
+ // Query BOTH backends regardless of state: a failed switch or stale state can leave
2987
+ // two managers installed, and either one would respawn the proxy after `ocx stop`.
2988
+ let stopped = false;
2989
+ try {
2990
+ const q = schtasks(["/query", "/tn", TASK]);
2991
+ if (q.includes(TASK)) { stopWindows(); stopped = true; }
2992
+ } catch { /* task not found */ }
2993
+ if (statusWinswRaw() !== "nonexistent") {
2994
+ try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
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();
3000
+ if (stopped) return true;
3001
+ } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
3002
+ try { stopSystemd(); return true; } catch { return false; }
3003
+ }
3004
+ return false;
3005
+ }
3006
+
3007
+ /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
3008
+ function removeServiceInstallState(): void {
3009
+ for (const path of serviceStatePaths()) {
3010
+ try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
3011
+ }
3012
+ }
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
+
3031
+ /**
3032
+ * Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
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.
3036
+ */
3037
+ export function uninstallServiceIfInstalled(): boolean {
3038
+ const hooks = uninstallServiceHooksForTests;
3039
+ (hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
3040
+ const platform = hooks?.platform ?? process.platform;
3041
+ if (platform === "darwin") {
3042
+ if (existsSync(plistPath())) {
3043
+ try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
3044
+ }
3045
+ } else if (platform === "win32") {
3046
+ let removed = false;
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;
3058
+ }
3059
+ if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
3060
+ } else if (platform === "linux" && existsSync(unitPath())) {
3061
+ try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
3062
+ try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
3063
+ }
3064
+ }
3065
+ return false;
3066
+ }
3067
+
3068
+ /** True if a background service (launchd/systemd/Task Scheduler) is installed. */
3069
+ export function isServiceInstalled(): boolean {
3070
+ return diagnoseService().installed;
3071
+ }
3072
+
3073
+ /**
3074
+ * True when an installed background service can actually supervise the proxy.
3075
+ * Presence alone is not enough: stale/missing assets, conflicts, and disabled
3076
+ * registrations report `installed` but will not bring the proxy back after exit.
3077
+ */
3078
+ export function isServiceViable(): boolean {
3079
+ return diagnoseService().viable;
3080
+ }
3081
+
3082
+ export interface ServiceDiagnostic {
3083
+ supported: boolean;
3084
+ installed: boolean;
3085
+ enabled: boolean;
3086
+ running: boolean;
3087
+ viable: boolean;
3088
+ startable: boolean;
3089
+ stale: boolean;
3090
+ conflict: boolean;
3091
+ backend: ServiceBackend | "launchd" | "systemd" | null;
3092
+ summary: string;
3093
+ }
3094
+
3095
+ /** Windows tray may restart a healthy-but-stopped native service; stale/conflicting installs remain blocked. */
3096
+ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
3097
+ return service.startable && !service.stale && !service.conflict;
3098
+ }
3099
+
3100
+ export interface WindowsServiceDiagnosticInputs {
3101
+ /**
3102
+ * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
3103
+ * XML rather than pre-computed booleans so every caller reads the document through
3104
+ * readWindowsSchedulerXmlState() — a second, stricter reading elsewhere would
3105
+ * silently reintroduce the stale-status false positive (#432).
3106
+ */
3107
+ schedulerXml: string;
3108
+ /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
3109
+ schedulerAssetsPresent: boolean;
3110
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
3111
+ recordedBackend: ServiceBackend | null;
3112
+ staleBakedPaths: boolean;
3113
+ nativeRepairAssetsOnly: boolean;
3114
+ diagnostics: string;
3115
+ }
3116
+
3117
+ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
3118
+ const schedulerState = readWindowsSchedulerXmlState(inputs.schedulerXml);
3119
+ const schedulerInstalled = schedulerState.installed;
3120
+ const schedulerEnabled = schedulerState.enabled;
3121
+ const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
3122
+ const nativeInstalled = inputs.nativeStatus !== "nonexistent";
3123
+ const conflict = schedulerInstalled && nativeInstalled;
3124
+ const backendStateMismatch = schedulerInstalled
3125
+ ? inputs.recordedBackend !== "scheduler"
3126
+ : nativeInstalled && inputs.recordedBackend !== "native";
3127
+ const stale = inputs.staleBakedPaths
3128
+ || (schedulerInstalled && !schedulerAssetsHealthy)
3129
+ || backendStateMismatch
3130
+ || (inputs.nativeStatus === "nonexistent" && inputs.nativeRepairAssetsOnly);
3131
+ const backend = schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null;
3132
+ const enabled = schedulerInstalled ? schedulerEnabled : inputs.nativeStatus === "started";
3133
+ const running = nativeInstalled ? inputs.nativeStatus === "started" : schedulerInstalled && schedulerEnabled;
3134
+ const viable = !conflict && !stale
3135
+ && (schedulerInstalled ? schedulerEnabled && schedulerAssetsHealthy : inputs.nativeStatus === "started");
3136
+ const startable = !conflict && !stale
3137
+ && (schedulerInstalled
3138
+ ? schedulerEnabled && schedulerAssetsHealthy
3139
+ : inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
3140
+ const detail = conflict
3141
+ ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
3142
+ : stale
3143
+ ? "stale or missing service assets — run 'ocx service repair'"
3144
+ : schedulerInstalled
3145
+ ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
3146
+ : nativeInstalled
3147
+ ? `native (WinSW ${WINSW_VERSION}): ${inputs.nativeStatus}`
3148
+ : "not installed";
3149
+ const summary = backend ? `installed, ${detail} (${inputs.diagnostics})` : `not installed (${inputs.diagnostics})`;
3150
+ return {
3151
+ supported: true,
3152
+ installed: schedulerInstalled || nativeInstalled,
3153
+ enabled,
3154
+ running,
3155
+ viable,
3156
+ startable,
3157
+ stale,
3158
+ conflict,
3159
+ backend,
3160
+ summary,
3161
+ };
3162
+ }
3163
+
3164
+ /**
3165
+ * Fail-closed restart diagnostic. Presence alone is never enough: conflicting
3166
+ * managers, stale baked paths, disabled registrations, and unknown/stopped
3167
+ * native managers cannot claim that Codex will reconnect after a reboot.
3168
+ */
3169
+ export function diagnoseService(): ServiceDiagnostic {
3170
+ const diagnostics = serviceDiagnosticsSummary();
3171
+ if (process.platform === "darwin") {
3172
+ const installed = existsSync(plistPath());
3173
+ const running = installed && Boolean(statusLaunchd());
3174
+ const stale = installed && bakedServicePathsDiagnostic() !== null;
3175
+ const viable = installed && running && !stale;
3176
+ const summary = !installed ? `not installed (${diagnostics})`
3177
+ : stale ? `installed, but stale (launchd; ${diagnostics})`
3178
+ : running ? `installed and loaded (launchd; ${diagnostics})`
3179
+ : `installed, not loaded (launchd; ${diagnostics})`;
3180
+ return { supported: true, installed, enabled: running, running, viable, startable: installed && !stale, stale, conflict: false, backend: "launchd", summary };
3181
+ }
3182
+ if (process.platform === "win32") {
3183
+ const schedulerXml = statusWindowsXml();
3184
+ const schedulerAssetsPresent = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()]
3185
+ .every(existsSync);
3186
+ const nativeStatus = statusWinswRaw();
3187
+ const installState = readServiceInstallState();
3188
+ const recordedBackend: ServiceBackend | null = !installState
3189
+ ? null
3190
+ : installState.backend === "native" ? "native" : "scheduler";
3191
+ return deriveWindowsServiceDiagnostic({
3192
+ schedulerXml,
3193
+ schedulerAssetsPresent,
3194
+ nativeStatus,
3195
+ recordedBackend,
3196
+ staleBakedPaths: bakedServicePathsDiagnostic() !== null,
3197
+ nativeRepairAssetsOnly: Boolean(winswStatusSummary()),
3198
+ diagnostics,
3199
+ });
3200
+ }
3201
+ if (process.platform === "linux") {
3202
+ if (existsSync("/.dockerenv")) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported in Docker" };
3203
+ if (!isSystemd()) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported: systemd not found" };
3204
+ const installed = existsSync(unitPath());
3205
+ const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })();
3206
+ const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })();
3207
+ const stale = installed && bakedServicePathsDiagnostic() !== null;
3208
+ const viable = installed && enabled && running && !stale;
3209
+ const summary = !installed ? `not installed (${diagnostics})`
3210
+ : stale ? `installed, but stale (systemd user; ${diagnostics})`
3211
+ : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
3212
+ : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
3213
+ return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
3214
+ }
3215
+ return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
3216
+ }
3217
+
3218
+ export function serviceStatusSummary(): string {
3219
+ return diagnoseService().summary;
3220
+ }
3221
+
3222
+ /**
3223
+ * Status a human can act on: registration state, whether a proxy actually answers,
3224
+ * and — when it does not — whether launchd is running the plist we have on disk.
3225
+ *
3226
+ * `launchctl list` membership cannot distinguish "serving", "bootstrapped from an
3227
+ * older plist", and "loaded but never bound"; the reported failure was the middle
3228
+ * one presented as the first.
3229
+ *
3230
+ * Resolves the port through `confirmServiceServing`, i.e. the same
3231
+ * `installedServiceListenPort()` path install/start/repair use, so those surfaces can
3232
+ * never disagree about one service. The budget is short (2 probes) because this is a
3233
+ * status read, not a post-install wait.
3234
+ */
3235
+ export async function serviceStatusReport(
3236
+ deps: {
3237
+ diagnose?: () => ServiceDiagnostic;
3238
+ serving?: () => Promise<{ ok: boolean; port: number }>;
3239
+ matchesPlist?: () => { loaded: boolean; matchesPlist: boolean };
3240
+ } = {},
3241
+ ): Promise<string> {
3242
+ const diag = (deps.diagnose ?? diagnoseService)();
3243
+ if (!diag.installed) return `❌ ${diag.summary}`;
3244
+
3245
+ const serving = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))();
3246
+ if (serving.ok) return `✅ ${diag.summary}\n Serving on port ${serving.port}.`;
3247
+
3248
+ // The dep is consulted FIRST; the platform check only guards the default. Wrapping
3249
+ // the whole expression in a darwin check would discard an injected seam on
3250
+ // Linux/Windows and make the stale-plist case untestable there.
3251
+ const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
3252
+ ? (() => {
3253
+ const entry = cliEntry();
3254
+ // Pass the INSTALLED port explicitly: the default third argument is
3255
+ // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
3256
+ // a config edit the expected string would never match and every run would
3257
+ // print a false "OLDER plist".
3258
+ return launchdJobMatchesPlist(
3259
+ buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()),
3260
+ );
3261
+ })()
3262
+ : null);
3263
+ const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
3264
+ ? " launchd is running an OLDER plist than the one on disk.\n"
3265
+ + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
3266
+ : "";
3267
+
3268
+ return `⚠️ ${diag.summary}\n`
3269
+ + ` Registered, but no proxy is answering on port ${serving.port}.\n`
3270
+ + staleLine
3271
+ + ` Log: ${serviceLogPath()}\n`
3272
+ + ` Repair: ${serviceRepairCommand()}\n`
3273
+ + " Meanwhile: ocx start (serves in the foreground)";
3274
+ }
3275
+
3276
+ export function normalizeServiceSubcommand(sub?: string): string {
3277
+ if (sub === "restart") return "repair";
3278
+ return sub ?? "install";
3279
+ }
3280
+
3281
+ export interface ParsedServiceArgs {
3282
+ sub: string;
3283
+ backend: ServiceBackend | null;
3284
+ invalid: string[];
3285
+ }
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
+
3400
+ /**
3401
+ * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
3402
+ * subcommand; backend flags are only meaningful for `install` (validated by the caller).
3403
+ */
3404
+ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
3405
+ let sub: string | undefined;
3406
+ let backend: ServiceBackend | null = null;
3407
+ const invalid: string[] = [];
3408
+ for (const arg of args) {
3409
+ if (arg === "--native") {
3410
+ if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
3411
+ backend = "native";
3412
+ }
3413
+ else if (arg === "--scheduler") {
3414
+ if (backend === "native") { invalid.push("--scheduler (conflicts with --native)"); continue; }
3415
+ backend = "scheduler";
3416
+ }
3417
+ else if (arg.startsWith("--")) invalid.push(arg);
3418
+ else if (sub === undefined) sub = arg;
3419
+ else invalid.push(arg);
3420
+ }
3421
+ return { sub: normalizeServiceSubcommand(sub), backend, invalid };
3422
+ }
3423
+
3424
+ export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
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);
3429
+ process.exit(1);
3430
+ }
3431
+ const { parsed, command } = plan;
3432
+ if (command === "repair") {
3433
+ assertServiceEnvironmentMatchesInstall();
3434
+ assertServiceAuthEnvironment();
3435
+ await repairService();
3436
+ // All three platforms: a repair that reports success while nothing serves is the
3437
+ // defect class this unit exists to close. Windows bakes its port into the
3438
+ // scheduler wrapper or the WinSW XML, both of which installedServiceListenPort()
3439
+ // now reads.
3440
+ await reportServiceServing("repaired");
3441
+ return;
3442
+ }
3443
+ // Non-install subcommands follow the backend recorded at install time (state v2).
3444
+ const backend: ServiceBackend = parsed.backend ?? (process.platform === "win32" ? readServiceBackend() : "scheduler");
3445
+ const ops = platformOps(backend);
3446
+ if (!ops) {
3447
+ console.error("ocx service supports macOS (launchd), Windows (Task Scheduler), and Linux (systemd).");
3448
+ process.exit(1);
3449
+ }
3450
+ switch (command) {
3451
+ case "install":
3452
+ assertServiceEnvironmentMatchesInstall();
3453
+ assertServiceAuthEnvironment();
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
+ }
3479
+ // The wrapper was written moments ago in this process, so the configured port
3480
+ // and the baked one cannot have diverged yet — unlike `start`, which reads the
3481
+ // installed artifact instead.
3482
+ await reportServiceServing("installed", { port: resolveServiceListenPort() });
3483
+ if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
3484
+ // Service users never reach the `ocx start` prompt: the proxy they run is the
3485
+ // supervised child, which always carries OCX_SERVICE=1. This command, though, is
3486
+ // hand-typed in a real terminal, so it is the one interactive moment they get.
3487
+ // Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
3488
+ await maybeShowStarPrompt();
3489
+ break;
3490
+ case "start":
3491
+ ops.start();
3492
+ await reportServiceServing("started");
3493
+ break;
3494
+ case "stop": {
3495
+ assertServiceEnvironmentMatchesInstall();
3496
+ // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
3497
+ // (and its Windows/Linux twins) even with nothing installed.
3498
+ if (ops.status() !== null || isServiceInstalled()) {
3499
+ ops.stop();
3500
+ }
3501
+ await stopTrackedProxyForServiceCommand();
3502
+ {
3503
+ // Verify rather than trust the stop command: a surviving wrapper respawns its child
3504
+ // seconds later, and restoring native Codex on top of a live proxy is the failure #764
3505
+ // reports as "stop reports success without stopping the proxy".
3506
+ const survivor = await proxyStillLiveAfterStop();
3507
+ if (survivor) {
3508
+ console.error(
3509
+ `❌ service stop did not take effect: a proxy is still listening on port ${survivor.port}.`
3510
+ + "\nNative Codex was NOT restored, because doing so while the proxy is running leaves"
3511
+ + " both pointing at each other. Check for a second service backend (`ocx service status`)"
3512
+ + " or a manually started proxy, then re-run `ocx service stop`.",
3513
+ );
3514
+ process.exitCode = 1;
3515
+ break;
3516
+ }
3517
+ const restore = await restoreNativeCodexAsync();
3518
+ if (restore.success) console.log("✅ service stopped + native Codex restored.");
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.`);
3520
+ // The Grok fence is the other managed config this command owns. Leaving it behind
3521
+ // pointed grok at a dead endpoint while native Codex was already restored.
3522
+ const grok = stripGrokConfig();
3523
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
3524
+ else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
3525
+ }
3526
+ break;
3527
+ }
3528
+ case "status": {
3529
+ if (process.platform === "win32" && backend === "scheduler") {
3530
+ console.log(await inspectWindowsSchedulerServiceStatus());
3531
+ } else {
3532
+ // Replaces raw `ops.status()` output, which on darwin is a `launchctl list`
3533
+ // line: registration reported as if it were service. serviceStatusReport
3534
+ // subsumes the not-installed case and adds the serving / stale-plist split.
3535
+ console.log(await serviceStatusReport());
3536
+ }
3537
+ console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
3538
+ break;
3539
+ }
3540
+ case "uninstall":
3541
+ case "remove":
3542
+ assertServiceEnvironmentMatchesInstall();
3543
+ try { ops.stop(); } catch (err) {
3544
+ console.warn(`⚠️ Service stop failed: ${err instanceof Error ? err.message : String(err)}`);
3545
+ }
3546
+ await stopTrackedProxyForServiceCommand();
3547
+ try {
3548
+ ops.uninstall();
3549
+ } catch (err) {
3550
+ console.error(`❌ Service uninstall failed: ${err instanceof Error ? err.message : String(err)}`);
3551
+ console.error("The service may still be installed. Check with 'ocx service status' or remove manually.");
3552
+ process.exit(1);
3553
+ }
3554
+ {
3555
+ const restore = await restoreNativeCodexAsync();
3556
+ if (!restore.success) {
3557
+ console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
3558
+ }
3559
+ const grok = stripGrokConfig();
3560
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
3561
+ else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
3562
+ }
3563
+ removeServiceInstallState();
3564
+ try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
3565
+ console.log("✅ service uninstalled.");
3566
+ break;
3567
+ default:
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.");
3570
+ console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
3571
+ console.error(" restart: alias of repair.");
3572
+ console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
3573
+ process.exit(1);
3574
+ }
3575
+ }