@bitkyc08/opencodex 2.86.0 → 2.87.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 (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
@@ -12,11 +12,10 @@
12
12
  * - Windows reuses ids quickly, so the sentinel opens a handle on its parent at startup, accepts it
13
13
  * only if that process was created before the sentinel, and terminates through the handle
14
14
  * (health-sentinel-windows.ts). While the handle is open the id cannot be reused.
15
- * - POSIX allocates ids sequentially, and the sentinel's live `process.ppid` changes the moment
16
- * its parent exits, before the exited parent can be reaped and its id freed. It is the last check
17
- * before `kill`. Reuse inside that window needs the parent to exit, be reaped, and the id space to
18
- * wrap completely (99,999 ids on macOS, `pid_max` on Linux) between two adjacent syscalls; that
19
- * residual is accepted and documented in structure/ops/service-health-recovery.md.
15
+ * - Linux holds a pidfd after rechecking the live parent relationship, and signals that fd.
16
+ * Only unsupported kernels/architectures retain the live-ppid fallback used by macOS.
17
+ * - macOS checks the live parent id immediately before SIGKILL; PID reuse between those two
18
+ * syscalls remains a documented residual (structure/ops/service-health-recovery.md).
20
19
  *
21
20
  * SIGKILL rather than SIGTERM: a proxy whose event loop is stuck cannot run its own SIGTERM
22
21
  * handler, and systemd counts a SIGTERM death as a clean exit that `Restart=on-failure` does not
@@ -80,6 +79,8 @@ export type HealthSentinelDeps = {
80
79
  isTargetGone(): boolean;
81
80
  /** Bind to the target once at startup; a failure stands the sentinel down. */
82
81
  bindTarget(): { ok: true } | { ok: false; reason: string };
82
+ /** Release the process owner on every exit, refusal, cancellation or exception. */
83
+ closeTarget(): void;
83
84
  /** Terminate the bound target, re-proving it is still the target at the last moment. */
84
85
  terminateTarget(): TerminateResult;
85
86
  /** Run under the ownership mutation lease, released on return; "busy" when it could not be taken promptly. */
@@ -149,6 +150,11 @@ function matchesProbeTarget(runtime: SentinelRuntimeRecord | null, pid: number,
149
150
  }
150
151
 
151
152
  export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<HealthSentinelResult> {
153
+ try { return await observeHealthSentinel(deps); }
154
+ finally { deps.closeTarget(); }
155
+ }
156
+
157
+ async function observeHealthSentinel(deps: HealthSentinelDeps): Promise<HealthSentinelResult> {
152
158
  const timing: HealthSentinelTiming = { ...DEFAULT_HEALTH_SENTINEL_TIMING, ...deps.timing };
153
159
  const standDown = (reason: string): HealthSentinelResult => {
154
160
  deps.log(`standing down: ${reason} (pid ${deps.targetPid})`);
@@ -407,59 +413,72 @@ export async function runHealthSentinelProcess(env: NodeJS.ProcessEnv = process.
407
413
  if (opened.ok) windowsHandle = opened.handle;
408
414
  else windowsBindFailure = opened.reason;
409
415
  }
416
+ let linuxHandle: import("./health-sentinel-linux").ParentPidfd | null = null;
417
+ let linuxBindFailure: string | null = null;
418
+ if (process.platform === "linux") {
419
+ const { openParentPidfd } = await import("./health-sentinel-linux");
420
+ const opened = openParentPidfd(targetPid, () => !gone && process.ppid === targetPid);
421
+ if (opened.kind === "bound") linuxHandle = opened.handle;
422
+ else if (opened.kind === "refused") linuxBindFailure = opened.reason;
423
+ else appendBoundedLog(logPath, `pidfd unavailable: ${opened.reason}; using live parent PID check`);
424
+ }
410
425
  const lag = startLagMonitor();
411
- const result = await runHealthSentinel({
412
- targetPid,
413
- now: Date.now,
414
- sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
415
- probe: async (target, timeoutMs) => {
416
- lag.reset();
417
- const outcome = await probeHealth(target, timeoutMs);
418
- return outcome.kind === "timeout" ? { ...outcome, sentinelLagMs: lag.read() } : outcome;
419
- },
420
- readRuntime: () => readSentinelRuntime(runtimePath),
421
- targetExited,
422
- isTargetGone: () => gone || (windowsHandle?.exited() ?? false),
423
- bindTarget: () => {
424
- // POSIX: Bun reads the parent id live, and it stops naming the proxy once the proxy exits.
425
- // Windows: it is the creating parent's id, so the handle below carries the identity.
426
- if (process.ppid !== targetPid) return { ok: false, reason: "the sentinel is not the watched process's child" };
427
- if (process.platform !== "win32") return { ok: true };
428
- return windowsHandle ? { ok: true } : { ok: false, reason: windowsBindFailure ?? "the parent process cannot be opened" };
429
- },
430
- terminateTarget: () => {
431
- if (process.platform === "win32") {
432
- if (!windowsHandle) return "failed";
433
- return windowsHandle.terminate(1) ? "terminated" : "failed";
434
- }
435
- if (gone || process.ppid !== targetPid) return "not_target";
436
- try {
437
- process.kill(targetPid, "SIGKILL");
438
- return "terminated";
439
- } catch {
440
- return "failed";
441
- }
442
- },
443
- withOwnershipLease: run => {
444
- let lease: { release(): void };
445
- try {
446
- lease = acquireOwnershipMutationLease(statePaths, { waitMs: LEASE_WAIT_MS, processImage: () => null });
447
- } catch {
448
- return "busy";
449
- }
450
- try { return run(); } finally { lease.release(); }
451
- },
452
- readHistory: () => {
453
- const entries = readRecoveryEntries(historyPath);
454
- return entries === null ? null : countedRecoveryTimes(entries);
455
- },
456
- writeHistory: entry => writeRecoveryEntry(historyPath, entry),
457
- log: line => appendBoundedLog(logPath, line),
458
- timing: parseTimingOverride(env[HEALTH_SENTINEL_ENV.timing]),
459
- });
460
- lag.stop();
461
- windowsHandle?.close();
462
- void result;
426
+ try {
427
+ await runHealthSentinel({
428
+ targetPid,
429
+ now: Date.now,
430
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
431
+ probe: async (target, timeoutMs) => {
432
+ lag.reset();
433
+ const outcome = await probeHealth(target, timeoutMs);
434
+ return outcome.kind === "timeout" ? { ...outcome, sentinelLagMs: lag.read() } : outcome;
435
+ },
436
+ readRuntime: () => readSentinelRuntime(runtimePath),
437
+ targetExited,
438
+ closeTarget: () => { linuxHandle?.close(); windowsHandle?.close(); },
439
+ isTargetGone: () => gone || (windowsHandle?.exited() ?? false),
440
+ bindTarget: () => {
441
+ // POSIX: Bun reads the parent id live, and it stops naming the proxy once the proxy exits.
442
+ // Windows: it is the creating parent's id, so the handle below carries the identity.
443
+ if (process.ppid !== targetPid) return { ok: false, reason: "the sentinel is not the watched process's child" };
444
+ if (linuxBindFailure) return { ok: false, reason: linuxBindFailure };
445
+ if (process.platform !== "win32") return { ok: true };
446
+ return windowsHandle ? { ok: true } : { ok: false, reason: windowsBindFailure ?? "the parent process cannot be opened" };
447
+ },
448
+ terminateTarget: () => {
449
+ if (process.platform === "win32") {
450
+ if (!windowsHandle) return "failed";
451
+ return windowsHandle.terminate(1) ? "terminated" : "failed";
452
+ }
453
+ if (gone || process.ppid !== targetPid) return "not_target";
454
+ if (linuxHandle) return linuxHandle.terminate();
455
+ try {
456
+ process.kill(targetPid, "SIGKILL");
457
+ return "terminated";
458
+ } catch {
459
+ return "failed";
460
+ }
461
+ },
462
+ withOwnershipLease: run => {
463
+ let lease: { release(): void };
464
+ try {
465
+ lease = acquireOwnershipMutationLease(statePaths, { waitMs: LEASE_WAIT_MS, processImage: () => null });
466
+ } catch {
467
+ return "busy";
468
+ }
469
+ try { return run(); } finally { lease.release(); }
470
+ },
471
+ readHistory: () => {
472
+ const entries = readRecoveryEntries(historyPath);
473
+ return entries === null ? null : countedRecoveryTimes(entries);
474
+ },
475
+ writeHistory: entry => writeRecoveryEntry(historyPath, entry),
476
+ log: line => appendBoundedLog(logPath, linuxHandle && line.startsWith("watching pid ") ? `${line} (pidfd)` : line),
477
+ timing: parseTimingOverride(env[HEALTH_SENTINEL_ENV.timing]),
478
+ });
479
+ } finally {
480
+ lag.stop();
481
+ }
463
482
  return 0;
464
483
  }
465
484
 
@@ -1,77 +1,18 @@
1
- import { win32 } from "node:path";
2
1
  import { loadConfig } from "../config";
3
- import { serviceApiTokenFilePath } from "../lib/service-secrets";
4
- import { PROXY_ENV_KEYS } from "../lib/proxy-env";
2
+ import { resolveServiceListenPort } from "./definition";
5
3
  import { proxyIdentityAt } from "../server/proxy-liveness";
6
4
  import { launchdListenPort } from "./launchd";
7
5
  import { serviceLogPath } from "./state";
8
6
  import { systemdListenPort } from "./systemd";
9
7
  import { windowsListenPort, winswListenPort } from "./windows-ops";
10
8
 
11
- export function shellQuote(value: string): string {
12
- return `'${value.replace(/'/g, "'\\''")}'`;
13
- }
14
-
15
- /**
16
- * Listen port baked into service wrappers / WinSW XML.
17
- * Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100.
18
- * `config.port === 0` means ephemeral for interactive start; services need a stable pin,
19
- * so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`.
20
- */
21
- export function resolveServiceListenPort(override?: number): number {
22
- if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) {
23
- return Math.trunc(override);
24
- }
25
- const baked = process.env.OCX_BAKE_PORT?.trim();
26
- if (baked && /^\d+$/.test(baked)) {
27
- const n = Number(baked);
28
- if (n > 0 && n <= 65535) return n;
29
- }
30
- const configured = loadConfig().port;
31
- if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured;
32
- return 10100;
33
- }
34
-
35
- export function buildServiceShellCommand(bun: string, cli: string | null, port = resolveServiceListenPort()): string {
36
- const tokenFile = serviceApiTokenFilePath();
37
- const args = cli ? `${shellQuote(cli)} start` : "start";
38
- return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${args} --port ${port}`;
39
- }
40
-
41
- /**
42
- * The same command shape, launched through a stable `ocx` executable instead of an
43
- * explicit Bun + CLI pair. The token-file preamble is identical and deliberately shared
44
- * in form: the service still reads the token from disk at start and never carries it in
45
- * the unit.
46
- */
47
- export function buildServiceLauncherShellCommand(launcher: string, port = resolveServiceListenPort()): string {
48
- const tokenFile = serviceApiTokenFilePath();
49
- return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`;
50
- }
51
-
52
- /**
53
- * Shared tail parser for the baked `--port <n>`.
54
- *
55
- * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd
56
- * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's `</arguments>`), and `&` (an
57
- * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI
58
- * paths ahead of the argument, and a path containing the literal must not shadow it.
59
- */
60
- export function parseBakedListenPort(read: () => string): number | null {
61
- try {
62
- const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1);
63
- if (!last) return null;
64
- const n = Number(last[1]);
65
- return n > 0 && n <= 65535 ? n : null;
66
- } catch {
67
- return null;
68
- }
69
- }
9
+ export { resolveServiceListenPort };
10
+ export { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, parseBakedListenPort, resolvedProxyEnv } from "./definition";
70
11
 
71
12
  /**
72
13
  * The listen port of the INSTALLED service artifact, falling back to the configured
73
- * one. Each reader returns null off its own platform, so the chain needs no platform
74
- * branch — and on Windows both return null, preserving today's behavior.
14
+ * one. This historical artifact chain can read retained files from another platform;
15
+ * platform-specific launchd command checks instead use only the plist and configured port.
75
16
  */
76
17
  export function installedServiceListenPort(): number {
77
18
  return launchdListenPort()
@@ -239,28 +180,3 @@ export async function reportServiceServing(
239
180
  export function serviceRepairCommand(): string {
240
181
  return "ocx service repair";
241
182
  }
242
-
243
- /**
244
- * Outbound proxy settings the installing shell had, resolved for baking into a service
245
- * definition.
246
- *
247
- * A service manager does not inherit the environment of the shell that installed it, and
248
- * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
249
- * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
250
- * reach the upstream got a service that dialed direct: the socket was reset, the retry
251
- * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
252
- * same install driven through `ocx codex-shim` worked, because that path spawns with
253
- * `{ ...process.env }`.
254
- *
255
- * Lower-case variants are honored because curl-style tooling sets them and the runtime's
256
- * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
257
- * upper-case name is baked, so a definition never carries two spellings of one setting.
258
- */
259
- export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
260
- const resolved: { name: string; value: string }[] = [];
261
- for (const key of PROXY_ENV_KEYS) {
262
- const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
263
- if (value) resolved.push({ name: key, value });
264
- }
265
- return resolved;
266
- }
@@ -11,7 +11,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type D
11
11
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
12
12
  import { recordOwnedConfigPath } from "../lib/config-ownership";
13
13
  import { writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./guards";
14
- import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, installedServiceListenPort, resolvedProxyEnv } from "./health";
14
+ import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./definition";
15
15
  import { SERVICE_MANAGED_ENV, LABEL, cliEntry, filterTransientServicePath, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, type ServiceInstallState, writeServiceInstallState, readServiceInstallState } from "./state";
16
16
  import { writeServiceDefinitionFile } from "./windows-ops";
17
17
  import { readTextOrNull } from "./windows-taskxml";
@@ -359,8 +359,11 @@ export function probeLaunchdLoadState(deps: {
359
359
  for (const domain of [`gui/${uid}`, `user/${uid}`]) {
360
360
  const printed = run(["print", `${domain}/${LABEL}`]);
361
361
  if (printed.status === 0) {
362
+ // Use this backend's plist, then the configured service port. Foreign artifacts
363
+ // can exist on macOS: systemdListenPort has no platform gate, so the general
364
+ // installed-artifact chain could incorrectly select a copied Linux unit's port.
362
365
  const expected = (deps.expectedCommand
363
- ?? (() => expectedLaunchdCommand(installedServiceListenPort())))();
366
+ ?? (() => expectedLaunchdCommand(launchdListenPort() ?? resolveServiceListenPort())))();
364
367
  const printedText = `${printed.stdout}\n${printed.stderr}`;
365
368
  return {
366
369
  state: printedText.includes(expected) ? "loaded-current" : "loaded-stale",
@@ -382,8 +385,16 @@ export function probeLaunchdLoadState(deps: {
382
385
  return { state: "not-loaded" };
383
386
  }
384
387
 
385
- /** Up to ~5 × 200 ms, the launchd twin of the Windows scheduler settle delays. */
386
- const LAUNCHD_SETTLE_ATTEMPTS = 5;
388
+ /**
389
+ * Up to 125 × 200 ms = 25 s: long enough for an evicted job to finish EXITING. Measured on
390
+ * macOS 26: `bootout` returns at once, the label stays registered (`state = SIGTERMed`) until
391
+ * the process exits, and `bootstrap` fails with 5 for that whole window. The proxy drains on
392
+ * SIGTERM (`shutdownTimeoutMs`, 5 s default) behind a launcher that waits for it, so a 1 s
393
+ * settle bootstrapped into that window, the retry and the rollback raced it too, and the old
394
+ * job then exited leaving nothing loaded. launchd SIGKILLs at its default `ExitTimeOut` of
395
+ * 20 s, which bounds this wait.
396
+ */
397
+ const LAUNCHD_SETTLE_ATTEMPTS = 125;
387
398
 
388
399
  const LAUNCHD_SETTLE_DELAY_MS = 200;
389
400
 
@@ -769,6 +780,7 @@ export function restartLaunchdJob(deps: {
769
780
  export function startLaunchd(deps: {
770
781
  launchctl?: typeof runLaunchctl;
771
782
  matches?: typeof launchdJobMatchesPlist;
783
+ readPlist?: () => string;
772
784
  } = {}): void {
773
785
  const run = deps.launchctl ?? runLaunchctl;
774
786
  const p = plistPath();
@@ -778,16 +790,24 @@ export function startLaunchd(deps: {
778
790
  // already be bootstrapped from THIS plist, which is a no-op rather than an error.
779
791
  // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
780
792
  // throwing here would break `ocx service start` on every healthy service.
793
+ // A live job can outlast its deleted or unreadable plist. Only a readable
794
+ // definition can justify the no-op; use the same snapshot for port parsing.
795
+ let plist: string | null;
796
+ try { plist = (deps.readPlist ?? (() => readFileSync(p, "utf8")))(); }
797
+ catch { plist = null; }
798
+ // Match the same launchd-only port choice as probeLaunchdLoadState above.
781
799
  const live = (deps.matches ?? launchdJobMatchesPlist)(
782
- expectedLaunchdCommand(installedServiceListenPort()),
800
+ expectedLaunchdCommand(launchdListenPort({ readPlist: () => plist ?? "" }) ?? resolveServiceListenPort()),
783
801
  );
784
- if (live.loaded && live.matchesPlist) {
802
+ if (plist !== null && live.loaded && live.matchesPlist) {
785
803
  console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
786
804
  return;
787
805
  }
788
806
  throw new Error(
789
807
  `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
790
- + (live.loaded
808
+ + (plist === null
809
+ ? "The service plist is missing or unreadable. Run 'ocx service repair' to restore it."
810
+ : live.loaded
791
811
  ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
792
812
  : "The job is not loaded. Run 'ocx service repair' to reload it."),
793
813
  );
@@ -1,24 +1,81 @@
1
- import { closeSync, fstatSync, ftruncateSync, openSync, readSync, renameSync, writeSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, openSync, readSync, realpathSync, renameSync, statSync, writeSync, type BigIntStats } from "node:fs";
2
2
  import { formatWithOptions } from "node:util";
3
- import { dirname } from "node:path";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { recordOwnedConfigPath } from "../lib/config-ownership";
5
5
 
6
6
  export const SERVICE_LOG_MAX_BYTES = 20 * 1024 * 1024;
7
7
  export const SERVICE_LOG_CHECK_INTERVAL_MS = 60_000;
8
8
 
9
+ function regularLeaf(path: string): BigIntStats | undefined {
10
+ try {
11
+ const info = lstatSync(path, { bigint: true });
12
+ if (!info.isFile() || info.nlink !== 1n || info.ino === 0n) throw new Error("Unsafe service log leaf");
13
+ return info;
14
+ } catch (error) {
15
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
16
+ throw error;
17
+ }
18
+ }
19
+
20
+ function sameFile(left: BigIntStats | undefined, right: BigIntStats | undefined): boolean {
21
+ return left !== undefined && right !== undefined && left.dev === right.dev && left.ino === right.ino;
22
+ }
23
+
24
+ function matchesDescriptor(path: string, fd: number, expected: BigIntStats): boolean {
25
+ const info = fstatSync(fd, { bigint: true });
26
+ return info.isFile() && info.nlink === 1n && sameFile(expected, info)
27
+ && sameFile(info, regularLeaf(path));
28
+ }
29
+
9
30
  /** Preserve the manager's open append descriptor; retain at most two bounded archives. */
10
- export function rotateServiceLog(path: string, maxBytes = SERVICE_LOG_MAX_BYTES): boolean {
31
+ export function rotateServiceLog(requestedPath: string, maxBytes = SERVICE_LOG_MAX_BYTES): boolean {
11
32
  let source: number | undefined;
12
33
  let archive: number | undefined;
13
34
  try {
14
35
  if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return false;
15
- source = openSync(path, "r+");
16
- const size = fstatSync(source).size;
17
- if (size <= maxBytes) return false;
18
- try { renameSync(`${path}.1`, `${path}.2`); } catch (error) {
19
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
36
+ // Resolve only the parent: exclusive creation through a Windows junction
37
+ // can report EEXIST for a missing leaf. Never resolve away a leaf symlink.
38
+ const parent = dirname(requestedPath);
39
+ const directory = statSync(parent, { bigint: true });
40
+ if (!directory.isDirectory() || directory.ino === 0n) return false;
41
+ const canonicalParent = realpathSync(parent);
42
+ const path = join(canonicalParent, basename(requestedPath));
43
+ const sameDirectory = () => {
44
+ const alias = statSync(parent, { bigint: true });
45
+ const canonical = statSync(canonicalParent, { bigint: true });
46
+ return alias.isDirectory() && canonical.isDirectory()
47
+ && sameFile(directory, alias) && sameFile(directory, canonical);
48
+ };
49
+ const matches = (suffix: string, fd: number, expected: BigIntStats) => sameDirectory()
50
+ && matchesDescriptor(`${path}${suffix}`, fd, expected)
51
+ && matchesDescriptor(`${requestedPath}${suffix}`, fd, expected);
52
+ if (!sameDirectory()) return false;
53
+ const original = regularLeaf(requestedPath);
54
+ if (!original) return false;
55
+ // Windows does not expose O_NOFOLLOW: descriptor/leaf identity checks still
56
+ // apply there. Do not reject legitimate junctions in the home path.
57
+ const guardedFlags = process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK;
58
+ source = openSync(path, constants.O_RDWR | guardedFlags);
59
+ if (!matches("", source, original)) return false;
60
+ const size = Number(fstatSync(source, { bigint: true }).size);
61
+ if (!Number.isSafeInteger(size) || size <= maxBytes) return false;
62
+ const previous = regularLeaf(`${path}.1`);
63
+ const oldest = regularLeaf(`${path}.2`);
64
+ if (!matches("", source, original)) return false;
65
+ if (previous) {
66
+ if (!sameFile(previous, regularLeaf(`${path}.1`))) return false;
67
+ const currentOldest = regularLeaf(`${path}.2`);
68
+ if (oldest ? !sameFile(oldest, currentOldest) : currentOldest !== undefined) return false;
69
+ if (!sameDirectory()) return false;
70
+ renameSync(`${path}.1`, `${path}.2`);
71
+ if (!sameFile(previous, regularLeaf(`${path}.2`))) return false;
20
72
  }
21
- archive = openSync(`${path}.1`, "w", 0o600);
73
+ // Never truncate an existing archive, even if another rotation or a path
74
+ // replacement wins the gap after rename. Failure leaves the source intact.
75
+ if (!sameDirectory()) return false;
76
+ archive = openSync(`${path}.1`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | guardedFlags, 0o600);
77
+ const created = fstatSync(archive, { bigint: true });
78
+ if (!matches(".1", archive, created)) return false;
22
79
  // A legacy unbounded file may be huge. Copy only its newest cap-sized tail,
23
80
  // using fixed memory, before truncating the original inode.
24
81
  const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes));
@@ -28,12 +85,18 @@ export function rotateServiceLog(path: string, maxBytes = SERVICE_LOG_MAX_BYTES)
28
85
  if (read === 0) return false;
29
86
  let written = 0;
30
87
  while (written < read) {
88
+ if (!matches(".1", archive, created)) return false;
31
89
  const count = writeSync(archive, buffer, written, read - written);
32
90
  if (count === 0) return false;
33
91
  written += count;
34
92
  }
35
93
  position += read;
36
94
  }
95
+ fsyncSync(archive);
96
+ // Revalidate both names and open objects at the destructive boundary. These
97
+ // checks detect observed replacement; they are not an atomic namespace lock.
98
+ if (!matches(".1", archive, created)
99
+ || !matches("", source, original)) return false;
37
100
  ftruncateSync(source, 0);
38
101
  return true;
39
102
  } catch {
@@ -6,7 +6,8 @@ import { randomUUID } from "node:crypto";
6
6
  import { defaultWinswEntry, installWinswService } from "../lib/winsw";
7
7
  import { resolveWindowsTaskDiagnosticUserId, diagnoseService } from "./diagnostics";
8
8
  import type { ServiceDiagnostic } from "./diagnostics";
9
- import { assertServiceEnvironmentMatchesInstall, assertServiceAuthEnvironment } from "./guards";
9
+ import { assertServiceAuthEnvironment } from "./auth-preflight";
10
+ import { assertServiceEnvironmentMatchesInstall } from "./guards";
10
11
  import { installLaunchd, restartLaunchdJob } from "./launchd";
11
12
  import type { LaunchdInstallOutcome } from "./launchd";
12
13
  import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership, serviceLauncherPathDiagnostic } from "./state";
@@ -172,20 +172,19 @@ function defaultOpenCodexHome(): string {
172
172
  }
173
173
 
174
174
  export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] {
175
- return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome());
175
+ // Bun caches the startup home: parallel workers inherit the wrapper's HOME even
176
+ // after the preload gives each one a sandbox. Tests must not share that mirror,
177
+ // including serial cases that select different OPENCODEX_HOME fixtures.
178
+ return serviceStateFilesFor(opencodexHome, isTestHomeGuardArmed() ? opencodexHome : defaultOpenCodexHome());
176
179
  }
177
180
 
178
181
  export function serviceStatePaths(): string[] {
179
182
  const paths = serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
180
183
  if (!isTestHomeGuardArmed()) return paths;
181
184
  /*
182
- * Under an armed test process the legacy default-home entry IS the developer's real
183
- * `~/.opencodex/service-state.json`. It is there so an install made before
184
- * OPENCODEX_HOME was set can still be found, but it means a test whose OPENCODEX_HOME
185
- * points at a sandbox still writes their live install state — observed while building
186
- * the launchd repair coverage: one case replaced the real record's codexHome and
187
- * opencodexHome with temp-directory paths. Drop it rather than deny the write, so the
188
- * sandbox path keeps working and the real one is simply not in the list.
185
+ * The test resolver omits the legacy mirror; still reject an explicit or fallback
186
+ * OPENCODEX_HOME that names the developer's real home. A missing fixture pin must
187
+ * never allow a test to read or replace their live service installation.
189
188
  *
190
189
  * The predicate is the guard's own, not a local `resolve()` compare: the guard
191
190
  * canonicalizes through `realpath`, and on macOS a sandbox under `/var/folders/...`
@@ -8,7 +8,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type D
8
8
  import { recordOwnedConfigPath } from "../lib/config-ownership";
9
9
  import { systemdProperty } from "../service-manager-probe";
10
10
  import { writeServiceApiTokenFile, sh } from "./guards";
11
- import { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./health";
11
+ import { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./definition";
12
12
  import type { ServiceInstallCleanupOps } from "./orchestration";
13
13
  import { SERVICE_MANAGED_ENV, TASK, cliEntry, filterTransientServicePath, stableLauncherEntry, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, writeServiceInstallState } from "./state";
14
14
  import { writeServiceDefinitionFile } from "./windows-ops";
@@ -29,7 +29,8 @@ import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_P
29
29
  import { statusWinswRaw, winswExePath, WINSW_SERVICE_ID, type WinswStatus } from "../lib/winsw";
30
30
  import type { GuardedManagerStopped, GuardedManagerTarget } from "./guarded-manager-target";
31
31
  import { TASK, windowsLauncherVbsPath, windowsServiceScriptPath } from "./state";
32
- import { probeWindowsSchedulerTask, querySchtasks, windowsWscript, type WindowsSchedulerTaskProbe } from "./windows-scheduler";
32
+ import { probeWindowsSchedulerTask, querySchtasks, type WindowsSchedulerTaskProbe } from "./windows-scheduler";
33
+ import { windowsWscript } from "./windows-wscript";
33
34
  import { windowsTaskRegistrationHealthy, windowsTaskRegistrationRefreshableLegacy } from "./windows-taskxml";
34
35
 
35
36
  /** Runtime table of one Win32 process; a null parent means the chain is unreadable. */
@@ -5,7 +5,7 @@ import { winswXmlPath } from "../lib/winsw";
5
5
  import { hardenSecretPath } from "../lib/windows-secret-acl";
6
6
  import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
7
7
  import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight";
8
- import { parseBakedListenPort } from "./health";
8
+ import { parseBakedListenPort } from "./definition";
9
9
  import { windowsServiceScriptPath } from "./state";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { existsSync, mkdirSync, mkdtempSync, rmdirSync, unlinkSync } from "node:fs";
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { findLiveProxy } from "../server/proxy-liveness";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import { join, resolve } from "node:path";
5
4
  import { randomUUID } from "node:crypto";
6
5
  import { ELEVATION_REQUEST_TIMEOUT_MS, OCX_ELEVATED_PROTOCOL_FAILED, raceWithTimeout, resolveTrustedWindowsSchtasksExe, startElevatedSchtasksCreateAndRun, runWindowsElevated, toWindowsSchtasksError, WindowsElevationError, type ElevatedSchedulerOutcome, type ElevatedSchtasksCreateAndRunExecution, type ElevatedSchtasksCreateAndRunResult } from "../lib/windows-elevation";
7
6
  import { statusWinswRaw } from "../lib/winsw";
@@ -10,9 +9,10 @@ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
10
9
  import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, writeServiceInstallState } from "./state";
11
10
  import { buildWindowsSchtasksCreateArgs, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml";
12
11
  import type { ExpectedWindowsTaskUserId } from "./windows-taskxml";
13
- import { win32 } from "node:path";
14
12
  import { WINSW_SERVICE_ID } from "../lib/winsw";
15
13
 
14
+ export { windowsWscript } from "./windows-wscript";
15
+
16
16
  /**
17
17
  * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
18
18
  * registered task document is UTF-16; reading that as UTF-8 makes every health check
@@ -69,11 +69,6 @@ function windowsSchtasks(): string {
69
69
  return resolveTrustedWindowsSchtasksExe();
70
70
  }
71
71
 
72
- export function windowsWscript(): string {
73
- const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
74
- return existsSync(candidate) ? candidate : "wscript.exe";
75
- }
76
-
77
72
  let querySchtasksForTests: ((args: string[]) => string) | null = null;
78
73
 
79
74
  export function querySchtasks(args: string[]): string {
@@ -1,14 +1,14 @@
1
1
  import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./windows-wrapper-exit";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath } from "./state";
4
- import { windowsWscript } from "./windows-scheduler";
4
+ import { windowsWscript } from "./windows-wscript";
5
5
  import { join } from "node:path";
6
6
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime";
7
7
  import { REAL_BUN_MIN_BYTES } from "../lib/bun-binary-validator.mjs";
8
8
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
9
9
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "../lib/win-paths";
10
10
  import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
11
- import { resolveServiceListenPort, resolvedProxyEnv } from "./health";
11
+ import { resolveServiceListenPort, resolvedProxyEnv } from "./definition";
12
12
  import { cliEntry, filterTransientServicePath, serviceLogPath, currentCodexSqliteHomeAbsolute } from "./state";
13
13
 
14
14
  function windowsBatchValue(value: string): string {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The `wscript.exe` the Task Scheduler XML must name.
3
+ *
4
+ * Leaf module on purpose: `windows-taskxml.ts` needs it to render the task document,
5
+ * and taking it from `windows-scheduler.ts` made the two one cyclic component.
6
+ * `windows-scheduler` owns the schtasks process surface, not this one path lookup.
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export function windowsWscript(): string {
12
+ const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
13
+ return existsSync(candidate) ? candidate : "wscript.exe";
14
+ }
package/src/service.ts CHANGED
@@ -13,8 +13,10 @@ export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./ser
13
13
  export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility";
14
14
  export { registeredManagingCliInvocation, assessServiceTakeoverCompatibility, sameServiceTakeoverCompatibility } from "./service/ownership-compatibility";
15
15
  export type { ServiceApiTokenOrigin, ProvisionedServiceApiToken } from "./service/guards";
16
- export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, serviceRetryCommand, assertNotAdminToken, assertServiceAuthEnvironment, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards";
17
- export { resolveServiceListenPort, installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing, resolvedProxyEnv } from "./service/health";
16
+ export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, assertNotAdminToken, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards";
17
+ export { serviceRetryCommand, assertServiceAuthEnvironment } from "./service/auth-preflight";
18
+ export { resolveServiceListenPort, resolvedProxyEnv } from "./service/definition";
19
+ export { installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing } from "./service/health";
18
20
  export type { LaunchdLoadState, LaunchdLoadProbe, LaunchdInstallOutcome } from "./service/launchd";
19
21
  export { buildPlist, reusePreviousPlistPathVariable, expectedLaunchdCommand, launchdListenPort, runLaunchctl, launchctlLoadFailed, launchdJobMatchesPlist, launchdEvictionTargets, probeLaunchdLoadState, installLaunchd, restartLaunchdJob, startLaunchd } from "./service/launchd";
20
22
  export { systemdListenPort, buildUnit, systemdNeedsDaemonReload, uninstallSystemd, systemdServiceInstallCleanupOps } from "./service/systemd";
@@ -134,6 +134,8 @@ function findAnthropicAuthProvider(
134
134
  // its token path and the token guardian refuse an unmarked row of that name, so offering such a
135
135
  // custom row here would advertise credentials no helper executor will spend.
136
136
  if (providerName === "anthropic2") continue;
137
+ // The DSH account token is pinned to its own adapter and DeepSeek host; no helper may spend it.
138
+ if (providerName === "dsh-account") continue;
137
139
  if (provider.disabled === true) continue;
138
140
  if (provider.adapter !== "anthropic" || provider.authMode !== "oauth") continue;
139
141
  const set = getAccountSet(providerName);