failproofai 1.0.0-beta.2 → 1.0.0-beta.21

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 (381) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -1
  3. package/.next/standalone/.next/build-manifest.json +6 -6
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +6 -0
  7. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  8. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js +3 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js +3 -1
  22. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  25. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  27. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  28. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  31. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  33. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
  35. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -1
  39. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -2
  41. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  43. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +3 -2
  45. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -2
  47. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  53. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/index.html +1 -1
  56. package/.next/standalone/.next/server/app/index.rsc +15 -15
  57. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  58. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  59. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  60. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  61. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  63. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/page.js +3 -1
  65. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  68. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  69. package/.next/standalone/.next/server/app/policies/page.js +5 -3
  70. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  73. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  74. package/.next/standalone/.next/server/app/project/[name]/page.js +4 -2
  75. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  76. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  77. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  78. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  79. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  80. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  81. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  83. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  84. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  85. package/.next/standalone/.next/server/app/projects/page.js +4 -2
  86. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/settings/page/app-paths-manifest.json +3 -0
  89. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +18 -0
  90. package/.next/standalone/.next/server/app/settings/page/next-font-manifest.json +6 -0
  91. package/.next/standalone/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
  92. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +53 -0
  93. package/.next/standalone/.next/server/app/settings/page.js +18 -0
  94. package/.next/standalone/.next/server/app/settings/page.js.map +5 -0
  95. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -0
  96. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +3 -0
  97. package/.next/standalone/.next/server/app-paths-manifest.json +2 -1
  98. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  99. package/.next/standalone/.next/server/chunks/{[externals]__1nl3dvw._.js → [externals]__0r-zamh._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__00ni9za._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__0effm9m._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g0vcwt._.js +3 -0
  109. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0ix8gm7._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ji11a-._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__10oa-v9._.js → [root-of-the-server]__0ojwjxl._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__0pbp9fe._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +4 -4
  116. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +3 -0
  122. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
  123. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  124. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  125. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-wfhrp._.js +3 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06bp_fg._.js +5 -0
  129. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__074qmkp._.js +3 -0
  130. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0_1tiya._.js} +2 -2
  131. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  132. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t281d4._.js +3 -0
  133. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +5 -0
  134. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1n2vcv4._.js} +2 -2
  135. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +3 -0
  137. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tlvv9a._.js +3 -0
  138. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
  139. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +3 -0
  140. package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
  141. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
  142. package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +3 -0
  143. package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +3 -0
  144. package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +3 -0
  145. package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +3 -0
  147. package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +3 -0
  148. package/.next/standalone/.next/server/chunks/ssr/_1ctse6u._.js +3 -0
  149. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  150. package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +3 -0
  151. package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +3 -0
  152. package/.next/standalone/.next/server/chunks/ssr/_1sdgprl._.js +5 -0
  153. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  154. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  155. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  156. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dwo94s.js +4 -0
  157. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  158. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +3 -0
  159. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  160. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  161. package/.next/standalone/.next/server/middleware.js +2 -2
  162. package/.next/standalone/.next/server/pages/404.html +1 -1
  163. package/.next/standalone/.next/server/pages/500.html +1 -1
  164. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  165. package/.next/standalone/.next/server/server-reference-manifest.json +74 -20
  166. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  167. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  168. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  169. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 113g8wtal6_c4.js} +22 -22
  170. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  171. package/.next/standalone/.next/static/chunks/18wqn0_3mzylf.js +1 -0
  172. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
  173. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 1xq1m8h14tnlj.js} +1 -1
  174. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 1y3vwogqb88dx.js} +1 -1
  175. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 1zmh-b0ulrqu7.js} +1 -1
  176. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  177. package/.next/standalone/.next/static/chunks/299lhw60e8gr6.js +1 -0
  178. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 2adbpdaxp5ygq.js} +1 -1
  179. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 2kbqcw-6swmav.js} +1 -1
  180. package/.next/standalone/.next/static/chunks/2n9n5m0963pdg.js +6 -0
  181. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
  182. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 30nrrpwymht5m.js} +1 -1
  183. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  184. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  185. package/.next/standalone/SECURITY.md +8 -2
  186. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  187. package/.next/standalone/app/actions/get-hooks-config.ts +2 -2
  188. package/.next/standalone/app/actions/get-scheduled-audit.ts +70 -0
  189. package/.next/standalone/app/actions/update-scheduled-audit.ts +44 -0
  190. package/.next/standalone/app/api/audit/run/route.ts +28 -0
  191. package/.next/standalone/app/api/audit/status/route.ts +13 -1
  192. package/.next/standalone/app/api/auth/status/route.ts +1 -1
  193. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -3
  194. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +1 -1
  195. package/.next/standalone/app/audit/_components/auth-dialog.css +159 -0
  196. package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
  197. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +1 -1
  198. package/.next/standalone/app/audit/_components/empty-state.tsx +0 -1
  199. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +1 -1
  200. package/.next/standalone/app/audit/_components/quirks-section.tsx +0 -1
  201. package/.next/standalone/app/audit/_components/report-footer.tsx +0 -2
  202. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  203. package/.next/standalone/app/audit/_components/strengths-section.tsx +0 -1
  204. package/.next/standalone/app/audit/audit-styles.css +6 -149
  205. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  206. package/.next/standalone/app/components/raw-log-viewer.tsx +1 -1
  207. package/.next/standalone/app/components/toast.tsx +1 -1
  208. package/.next/standalone/app/policies/hooks-client.tsx +84 -8
  209. package/.next/standalone/app/settings/page.tsx +31 -0
  210. package/.next/standalone/app/settings/settings-client.tsx +487 -0
  211. package/.next/standalone/components/navbar.tsx +2 -0
  212. package/.next/standalone/lib/auth/api-server-client.ts +2 -3
  213. package/.next/standalone/lib/auth/auth-store.ts +4 -5
  214. package/.next/standalone/lib/codex-sessions.ts +2 -1
  215. package/.next/standalone/lib/dashboard-host.ts +101 -0
  216. package/.next/standalone/lib/install-check.ts +22 -7
  217. package/.next/standalone/lib/pi-sessions.ts +99 -14
  218. package/.next/standalone/lib/telemetry-enabled.ts +54 -0
  219. package/.next/standalone/lib/telemetry-id.ts +52 -1
  220. package/.next/standalone/lib/telemetry.ts +8 -4
  221. package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
  222. package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
  223. package/.next/standalone/node_modules/react/package.json +3 -3
  224. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
  225. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
  226. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
  227. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
  228. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
  229. package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
  230. package/.next/standalone/node_modules/react-dom/package.json +4 -4
  231. package/.next/standalone/package.json +23 -12
  232. package/.next/standalone/proxy.ts +111 -1
  233. package/.next/standalone/server.js +1 -1
  234. package/bin/failproofai-worker.mjs +1 -1
  235. package/bin/failproofai.mjs +1041 -81
  236. package/bin/failproofaid-shim.mjs +4 -4
  237. package/dist/cli.mjs +18513 -10474
  238. package/dist/worker.mjs +1223 -299
  239. package/lib/auth/api-server-client.ts +2 -3
  240. package/lib/auth/auth-store.ts +4 -5
  241. package/lib/codex-sessions.ts +2 -1
  242. package/lib/dashboard-host.ts +101 -0
  243. package/lib/install-check.ts +22 -7
  244. package/lib/pi-sessions.ts +99 -14
  245. package/lib/telemetry-enabled.ts +54 -0
  246. package/lib/telemetry-id.ts +52 -1
  247. package/lib/telemetry.ts +8 -4
  248. package/package.json +23 -12
  249. package/scripts/build-daemon-packages.mjs +247 -0
  250. package/scripts/daemon-platforms.mjs +42 -0
  251. package/scripts/dev-hook.mjs +46 -5
  252. package/scripts/install-telemetry.mjs +8 -1
  253. package/scripts/launch.ts +25 -3
  254. package/scripts/parse-script-args.ts +27 -1
  255. package/scripts/prune-standalone.mjs +26 -0
  256. package/scripts/publish-aliases.mjs +62 -41
  257. package/scripts/repro-npm-install.sh +155 -0
  258. package/scripts/translate-docs/cache.ts +1 -1
  259. package/scripts/translate-docs/cli.ts +2 -2
  260. package/scripts/translate-docs/mintlify-nav.ts +1 -1
  261. package/scripts/translate-docs/readme-translator.ts +163 -4
  262. package/scripts/validate-mdx.ts +153 -13
  263. package/src/audit/audit-lock.ts +285 -0
  264. package/src/audit/audit-schedule.ts +111 -0
  265. package/src/audit/cache.ts +18 -5
  266. package/src/audit/cli-adapters/goose.ts +2 -1
  267. package/src/audit/cli-adapters/hermes.ts +28 -10
  268. package/src/audit/cli.ts +191 -35
  269. package/src/audit/dashboard-cache.ts +2 -3
  270. package/src/audit/report.ts +0 -1
  271. package/src/hooks/backfill-cli.ts +247 -0
  272. package/src/hooks/builtin-policies.ts +391 -3
  273. package/src/hooks/cloud-connection.ts +354 -0
  274. package/src/hooks/cloud-enrollment-cli.ts +439 -0
  275. package/src/hooks/cloud-enrollment.ts +284 -0
  276. package/src/hooks/cloud-introspect.ts +147 -0
  277. package/src/hooks/cloud-managed-policies.ts +206 -0
  278. package/src/hooks/collector-config.ts +218 -0
  279. package/src/hooks/configure-wizard.ts +1211 -233
  280. package/src/hooks/custom-hooks-loader.ts +243 -37
  281. package/src/hooks/daemon-client.ts +136 -33
  282. package/src/hooks/daemon-download.ts +264 -31
  283. package/src/hooks/daemon-service.ts +1124 -76
  284. package/src/hooks/first-run-gate.ts +50 -0
  285. package/src/hooks/flush-cli.ts +180 -0
  286. package/src/hooks/fp-config.ts +835 -0
  287. package/src/hooks/fp-home.ts +714 -0
  288. package/src/hooks/fp-reset.ts +1363 -0
  289. package/src/hooks/handler.ts +185 -6
  290. package/src/hooks/harness-cli.ts +386 -0
  291. package/src/hooks/hook-activity-store.ts +171 -24
  292. package/src/hooks/hook-logger.ts +2 -2
  293. package/src/hooks/hook-telemetry.ts +5 -1
  294. package/src/hooks/hooks-config.ts +4 -3
  295. package/src/hooks/integrations.ts +0 -1
  296. package/src/hooks/loader-utils.ts +172 -9
  297. package/src/hooks/manager.ts +41 -5
  298. package/src/hooks/migrations.ts +450 -0
  299. package/src/hooks/onboarding-attempt.ts +195 -0
  300. package/src/hooks/onboarding-lock.ts +118 -0
  301. package/src/hooks/policy-types.ts +28 -0
  302. package/src/hooks/session-pause-cli.ts +159 -0
  303. package/src/hooks/session-pause.ts +267 -0
  304. package/src/hooks/setup-state.ts +256 -0
  305. package/src/hooks/tui.ts +209 -9
  306. package/src/hooks/uninstall-cli.ts +418 -0
  307. package/src/hooks/worker-server.ts +134 -15
  308. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  309. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  310. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  311. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0longh-._.js +0 -3
  312. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +0 -3
  313. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +0 -3
  314. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +0 -3
  315. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +0 -3
  316. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +0 -3
  317. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  318. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +0 -4
  319. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
  320. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
  321. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
  322. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0nb0syg._.js +0 -3
  323. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
  324. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
  325. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
  326. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
  327. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
  328. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1psjybo._.js +0 -3
  329. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +0 -4
  330. package/.next/standalone/.next/server/chunks/ssr/_1m_p7pi._.js +0 -3
  331. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_0oy_-36.js +0 -3
  332. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +0 -5
  333. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +0 -5
  334. package/.next/standalone/.next/static/chunks/0h7auy7hzjyhw.js +0 -1
  335. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  336. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  337. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  338. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  339. package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
  340. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  341. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  342. package/.next/standalone/Cargo.lock +0 -428
  343. package/.next/standalone/Cargo.toml +0 -9
  344. package/.next/standalone/crates/PROTOCOL.md +0 -122
  345. package/.next/standalone/crates/failproofaid/Cargo.toml +0 -18
  346. package/.next/standalone/crates/failproofaid/src/lock.rs +0 -81
  347. package/.next/standalone/crates/failproofaid/src/main.rs +0 -83
  348. package/.next/standalone/crates/failproofaid/src/paths.rs +0 -172
  349. package/.next/standalone/crates/failproofaid/src/server.rs +0 -502
  350. package/.next/standalone/crates/failproofaid/src/worker.rs +0 -400
  351. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +0 -241
  352. package/.next/standalone/crates/fpai-ipc/Cargo.toml +0 -16
  353. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +0 -175
  354. package/.next/standalone/crates/fpai-ipc/src/framing.rs +0 -177
  355. package/.next/standalone/crates/fpai-ipc/src/lib.rs +0 -10
  356. package/.next/standalone/crates/fpai-ipc/src/peer.rs +0 -80
  357. package/.next/standalone/docker-hook-sync/Dockerfile +0 -79
  358. package/.next/standalone/docker-hook-sync/entrypoint.sh +0 -109
  359. package/.next/standalone/integration-suite/Dockerfile +0 -47
  360. package/.next/standalone/integration-suite/README.md +0 -129
  361. package/.next/standalone/integration-suite/canary-policies.mjs +0 -45
  362. package/.next/standalone/integration-suite/capture-tokens.sh +0 -48
  363. package/.next/standalone/integration-suite/ci-entrypoint.sh +0 -179
  364. package/.next/standalone/integration-suite/inject-tokens.sh +0 -44
  365. package/.next/standalone/integration-suite/install-clis.sh +0 -136
  366. package/.next/standalone/integration-suite/probe-cli.sh +0 -248
  367. package/.next/standalone/integration-suite/report.js +0 -120
  368. package/.next/standalone/integration-suite/run.sh +0 -159
  369. package/.next/standalone/openclaw-plugin/index.js +0 -223
  370. package/.next/standalone/openclaw-plugin/openclaw.plugin.json +0 -6
  371. package/.next/standalone/openclaw-plugin/package.json +0 -12
  372. package/.next/standalone/osv-scanner.toml +0 -17
  373. package/.next/standalone/pi-extension/index.ts +0 -524
  374. package/.next/standalone/pi-extension/package.json +0 -12
  375. package/.next/standalone/postcss.config.mjs +0 -8
  376. package/.next/standalone/readme-arch-hq.gif +0 -0
  377. package/.next/standalone/rust-toolchain.toml +0 -4
  378. package/src/auth/cli.ts +0 -371
  379. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_buildManifest.js +0 -0
  380. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_clientMiddlewareManifest.js +0 -0
  381. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_ssgManifest.js +0 -0
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Installs/uninstalls/checks failproofaid as a real OS-level user service
3
- * (systemd `--user` on Linux, launchd `LaunchAgent` on macOS) so it's
4
- * "constant" starts at login, restarts on crash — without ever needing
5
- * elevation. User-scope only, matching the daemon itself.
2
+ * Installs, upgrades, checks, and removes failproofaid as a system-managed
3
+ * service. The definition is root-owned and starts at boot, but the daemon
4
+ * process itself runs as the user who configured failproofai.
6
5
  *
7
6
  * No public `failproofai daemon install`-style subcommand exists —
8
7
  * `configure-wizard.ts` calls the functions here directly, the same
@@ -18,15 +17,17 @@ import {
18
17
  rmSync,
19
18
  } from "node:fs";
20
19
  import { homedir, tmpdir, userInfo } from "node:os";
21
- import { resolve, dirname } from "node:path";
20
+ import { resolve } from "node:path";
22
21
  import { execFileSync } from "node:child_process";
23
22
  import { hookLogWarn } from "./hook-logger";
24
- import { getConfigPathForScope } from "./hooks-config";
25
- import { downloadFailproofaidBinary, installedBinaryPath } from "./daemon-download";
23
+ import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download";
24
+ import { logsDir } from "./fp-home";
25
+ import { version } from "../../package.json";
26
+ import { readVersionFile, updateConfig, writeVersionFile } from "./fp-config";
26
27
 
27
28
  /**
28
- * Every `systemctl --user` / `launchctl` call is bounded. Both talk to a
29
- * per-user session bus or to launchd, and a wedged session makes an
29
+ * Every `systemctl` / `launchctl` call is bounded. A wedged service manager
30
+ * makes an
30
31
  * unbounded `execFileSync` block forever — inside the interactive wizard
31
32
  * that reads as a hang with no output at all (`stdio: "ignore"`), right
32
33
  * after the user pressed "apply". A timeout throws instead, which the
@@ -36,7 +37,7 @@ const SERVICE_CMD_TIMEOUT_MS = 10_000;
36
37
 
37
38
  /**
38
39
  * How long to wait for the service manager to actually get the daemon into
39
- * a running state after `enable --now` / `load -w`. Both commands return as
40
+ * a running state after restart/load. Those commands return as
40
41
  * soon as the job is accepted, which is well before the process has proven
41
42
  * it can stay up.
42
43
  */
@@ -44,7 +45,7 @@ const SERVICE_START_TIMEOUT_MS = 5_000;
44
45
  const SERVICE_START_POLL_MS = 100;
45
46
  /**
46
47
  * How long a unit has to still be running after it first reports running.
47
- * `systemctl --user is-active` calls a `Type=simple` unit active the moment
48
+ * `systemctl is-active` calls a `Type=simple` unit active the moment
48
49
  * it forks, so a daemon that dies immediately still reports active once —
49
50
  * a single check would wave through exactly the crash-at-startup case this
50
51
  * is here to catch. Comfortably longer than the unit's `RestartSec=2`
@@ -76,25 +77,56 @@ const SUDO_PROMPT_TIMEOUT_MS = 120_000;
76
77
  * Global scope only: whether *this machine* runs a daemon is not a
77
78
  * per-project setting.
78
79
  */
79
- export function setDaemonConfigured(value: boolean): void {
80
- const path = getConfigPathForScope("user");
81
- let config: Record<string, unknown> = {};
80
+ export function setDaemonConfigured(value: boolean, installedVersion?: string): void {
82
81
  try {
83
- if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
84
- } catch {
85
- return; // a malformed global config is the install path's problem, not ours
86
- }
87
- if (value) config.daemonConfigured = true;
88
- else delete config.daemonConfigured;
89
- try {
90
- mkdirSync(dirname(path), { recursive: true });
91
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
82
+ updateConfig({ daemon: { configured: value } });
83
+ // The version lives in VERSION, not here. One file is ABOUT versions and
84
+ // one copy cannot disagree with itself; keeping a second in config.toml
85
+ // would be a field that drifts out of step with the file beside it —
86
+ // exactly the class of bug the single path module exists to prevent.
87
+ // Cleared on uninstall: a recorded binary version for a service that is no
88
+ // longer configured is a claim about this machine that is no longer true.
89
+ writeVersionFile({ daemon: value ? installedVersion : undefined, clearDaemon: !value });
92
90
  } catch {
93
91
  /* best-effort: never fail a completed setup (or uninstall) over this flag */
94
92
  }
95
93
  }
96
94
 
97
- export type DaemonServiceStatus = "running" | "stopped" | "not-installed" | "unsupported-platform";
95
+ export type DaemonServiceStatus =
96
+ | "running"
97
+ | "stopped"
98
+ /**
99
+ * Installed, but systemd evaluated its `ConditionPathExists=` and refused to
100
+ * start it — the daemon binary or the worker script is gone.
101
+ *
102
+ * Deliberately its own state rather than folded into "stopped". "Stopped" is
103
+ * ambiguous on purpose (a restart in flight looks identical), which is why
104
+ * nothing is allowed to act destructively on it. A failed condition carries
105
+ * no such ambiguity: systemd has already decided this unit will not run, and
106
+ * will keep deciding that at every boot until the missing path returns. That
107
+ * is provable enough to clear `daemonConfigured` on, and leaving it
108
+ * indistinguishable from a transient stop is what kept a machine denying
109
+ * every tool call with no explanation.
110
+ */
111
+ | "condition-failed"
112
+ | "not-installed"
113
+ /**
114
+ * Installed, and its state could not be READ — distinct from any claim about
115
+ * whether it is running.
116
+ *
117
+ * Only macOS reaches this. A LaunchDaemon lives in launchd's system domain,
118
+ * so unlike `systemctl is-active` (which any user can run against a system
119
+ * unit) reading its state needs the same elevation installing it did, and the
120
+ * CLI will not prompt for a password from under a TUI. An expired sudo cache
121
+ * — five minutes by default — therefore means "cannot check", and folding
122
+ * that into "stopped" made a perfectly healthy daemon look dead: the wizard
123
+ * then demanded sudo and bounced the service, which on a `daemonConfigured`
124
+ * machine is a real fail-closed window, to fix nothing. Callers that need the
125
+ * truth should ask the daemon itself (`probeDaemonEndToEnd`), which needs no
126
+ * privileges at all.
127
+ */
128
+ | "unknown"
129
+ | "unsupported-platform";
98
130
 
99
131
  /** Linux + macOS only, per the plan's platform scope — full stop. */
100
132
  export function isDaemonSupportedPlatform(): boolean {
@@ -145,13 +177,19 @@ export function resolveFailproofaidBinaryPath(): string | null {
145
177
  }
146
178
 
147
179
  /**
148
- * Resolves the binary, downloading it from this version's release if it is
149
- * not already on disk.
180
+ * Resolves the binary, installing one if it is not already on disk.
150
181
  *
151
- * Only the install path calls this. The npm package ships no binary — it is
152
- * one CLI tarball for every platform so `failproofai config` choosing the
153
- * global scope is the moment a machine that opted into a daemon actually
154
- * acquires one.
182
+ * Only the install path calls this `failproofai config` choosing the global
183
+ * scope is the moment a machine that opted into a daemon actually acquires
184
+ * one. Two channels, in this order:
185
+ *
186
+ * 1. The `@failproofai/failproofaid-<os>-<arch>` platform package, which a
187
+ * plain `npm install failproofai` already brought down as an optional
188
+ * dependency. No network, so it is also the only channel that works
189
+ * air-gapped or behind a proxy that blocks github.com.
190
+ * 2. The GitHub Release asset for this exact version, checksum-verified.
191
+ * Covers installs that skipped optional dependencies, older tarballs, and
192
+ * anyone installing the daemon standalone.
155
193
  */
156
194
  export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> {
157
195
  const existing = resolveFailproofaidBinaryPath();
@@ -162,9 +200,18 @@ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reaso
162
200
  return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` };
163
201
  }
164
202
 
203
+ const fromNpm = installFromNpmPackage(key);
204
+ if (fromNpm.path) return { path: fromNpm.path };
205
+
165
206
  const result = await downloadFailproofaidBinary(key);
166
207
  if (result.path) return { path: result.path };
167
- return { reason: result.error ?? "failproofaid binary could not be downloaded" };
208
+ // Both channels are worth reporting: "not installed" alone reads as a broken
209
+ // package, and the download error alone hides that npm could have supplied it.
210
+ return {
211
+ reason: result.error
212
+ ? `${result.error} (${fromNpm.error})`
213
+ : "failproofaid binary could not be installed",
214
+ };
168
215
  }
169
216
 
170
217
  /**
@@ -210,12 +257,116 @@ export function resolveWorkerCommand(): string | null {
210
257
  return `${shellQuote(process.execPath)} ${shellQuote(workerScript)}`;
211
258
  }
212
259
 
260
+ /**
261
+ * Resolves the command the daemon should use to run a one-shot `failproofai`
262
+ * CLI task — today the scheduled audit — passed through as
263
+ * `FAILPROOFAI_CLI_CMD` in the service's own environment.
264
+ *
265
+ * Everything `resolveWorkerCommand` says about `process.execPath` and the
266
+ * shell quoting applies here verbatim, for the same reason: a system-scope
267
+ * unit gets no login environment, so its PATH is the system default, and the
268
+ * single most common Node install (nvm) is on no system PATH. The difference
269
+ * is the consequence of getting it wrong. A worker that cannot start makes the
270
+ * daemon visibly unhealthy; an audit that cannot start is SILENT — the config
271
+ * says the scan is on, nothing ever runs, and the only symptom is a dashboard
272
+ * that quietly stops moving.
273
+ *
274
+ * `dist/cli.mjs` (package.json's `bin.failproofai`), not `bin/failproofai.mjs`
275
+ * — the latter has a `#!/usr/bin/env bun` shebang and uses syntax node cannot
276
+ * load (a bare `import … from "../package.json"`, extensionless `.ts`
277
+ * specifiers). Only the bundle is node-runnable, and it sits beside the
278
+ * `dist/worker.mjs` the worker command already points at.
279
+ */
280
+ export function resolveCliCommand(): string | null {
281
+ if (process.env.FAILPROOFAI_CLI_CMD) return process.env.FAILPROOFAI_CLI_CMD;
282
+
283
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
284
+ if (!packageRoot) return null;
285
+ const cliScript = resolve(packageRoot, "dist", "cli.mjs");
286
+ if (!existsSync(cliScript)) return null;
287
+ return `${shellQuote(process.execPath)} ${shellQuote(cliScript)}`;
288
+ }
289
+
213
290
  /** POSIX single-quoting: everything is literal inside '…', and a literal
214
291
  * quote is closed, escaped and reopened. */
215
292
  function shellQuote(value: string): string {
216
293
  return `'${value.replace(/'/g, `'\\''`)}'`;
217
294
  }
218
295
 
296
+ /**
297
+ * The raw, unquoted path to the worker script the service will run — the same
298
+ * file `resolveWorkerCommand` builds its shell command around.
299
+ *
300
+ * It exists separately because the unit needs the path as a PATH (for
301
+ * `ConditionPathExists=`), not as a shell word, and recovering one from the
302
+ * other means unparsing POSIX quoting for a value we already had.
303
+ *
304
+ * Returns null when `FAILPROOFAI_WORKER_CMD` is set: that value is an arbitrary
305
+ * shell command — a wrapper script, an interpreter with flags, `exec`ing
306
+ * something else entirely — and guessing which token in it is "the file that
307
+ * must exist" would gate the service on a path nobody promised. An absent
308
+ * condition is the correct answer to a question we cannot answer.
309
+ */
310
+ export function workerScriptPath(): string | null {
311
+ if (process.env.FAILPROOFAI_WORKER_CMD) return null;
312
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
313
+ if (!packageRoot) return null;
314
+ const workerScript = resolve(packageRoot, "dist", "worker.mjs");
315
+ return existsSync(workerScript) ? workerScript : null;
316
+ }
317
+
318
+ /**
319
+ * The environment the service definition carries, built once so the systemd
320
+ * and launchd renderers cannot drift apart — a variable added to one and not
321
+ * the other is invisible until somebody runs the other platform.
322
+ *
323
+ * Order is fixed rather than incidental: `upgradedServiceDefinition` rebuilds
324
+ * a definition from these renderers, so a stable order is what makes a
325
+ * rewritten unit byte-identical to a freshly installed one rather than merely
326
+ * equivalent — which is the difference between a diff a human can read and one
327
+ * they stop reading.
328
+ */
329
+ /**
330
+ * Characters that cannot appear in a value interpolated into a systemd unit.
331
+ *
332
+ * A newline ENDS THE DIRECTIVE, so a path containing one injects arbitrary
333
+ * settings into a file that is installed root-owned at `/etc/systemd/system`
334
+ * and loaded at every boot. A double quote closes `Environment="..."` early and
335
+ * does the same; a backslash is systemd's own escape character inside a quoted
336
+ * value and makes the result unpredictable rather than merely wrong.
337
+ *
338
+ * The mechanism is demonstrated by this repo's own test, which sets
339
+ * `FAILPROOFAI_CLI_CMD` to `/usr/bin/true"\nUser=failproofai-no-such-user` and
340
+ * relies on systemd HONOURING the injected `User=` — it passes only because the
341
+ * injected user does not exist. A valid one (`User=root`, or an added
342
+ * `ExecStartPre=`) would have succeeded silently, undoing the
343
+ * "root-installed but never root-run" invariant the whole design rests on.
344
+ *
345
+ * These values are resolved paths and commands; none can legitimately contain
346
+ * any of these characters. So this REJECTS rather than escaping — a unit that
347
+ * refuses to be written is a clear failure, and inventing an escaping scheme
348
+ * for systemd's grammar is a larger surface than the problem.
349
+ */
350
+ const UNIT_UNSAFE_RE = /["\\\r\n]/;
351
+
352
+ /** Throws when a value cannot be safely interpolated into a unit file. */
353
+ function assertUnitSafe(value: string, field: string): string {
354
+ if (UNIT_UNSAFE_RE.test(value)) {
355
+ throw new Error(
356
+ `refusing to write a service definition: ${field} contains a quote, backslash or newline ` +
357
+ `(${JSON.stringify(value)}), which would inject directives into a root-owned unit file`,
358
+ );
359
+ }
360
+ return value;
361
+ }
362
+
363
+ function serviceEnvironment(workerCmd: string | null, cliCmd: string | null): [string, string][] {
364
+ const entries: [string, string][] = [];
365
+ if (workerCmd) entries.push(["FAILPROOFAI_WORKER_CMD", workerCmd]);
366
+ if (cliCmd) entries.push(["FAILPROOFAI_CLI_CMD", cliCmd]);
367
+ return entries;
368
+ }
369
+
219
370
  /**
220
371
  * The account the daemon runs as. The service is root-*installed* but never
221
372
  * root-*run*: everything it touches (the socket, the lock, the policy config)
@@ -240,8 +391,25 @@ function systemdUnitName(user: string = serviceUser()): string {
240
391
  return `failproofaid@${user}.service`;
241
392
  }
242
393
 
394
+ /**
395
+ * The unit directory, redirectable for tests ONLY.
396
+ *
397
+ * It was hardcoded, which left a test asserting "a failed install writes
398
+ * nothing" checking the REAL `/etc/systemd/system` — so it passed in CI, where
399
+ * nothing is installed, and failed on the machine of anyone who actually uses
400
+ * failproofai, whose genuine unit file is sitting right there. That is the bug
401
+ * class `handler.test.ts`'s header already documents: CI is green, so the red is
402
+ * only ever seen locally, by exactly the people who most need to trust the suite.
403
+ *
404
+ * Read through a function rather than captured at module load, because a test
405
+ * sets it after importing this module.
406
+ */
407
+ function systemdUnitDir(): string {
408
+ return process.env.FAILPROOFAI_SYSTEMD_DIR || "/etc/systemd/system";
409
+ }
410
+
243
411
  function systemdUnitPath(user: string = serviceUser()): string {
244
- return resolve("/etc/systemd/system", systemdUnitName(user));
412
+ return resolve(systemdUnitDir(), systemdUnitName(user));
245
413
  }
246
414
 
247
415
  /**
@@ -306,6 +474,22 @@ export function daemonStatusCommand(): string | null {
306
474
  : `sudo launchctl print system/${launchdLabel()}`;
307
475
  }
308
476
 
477
+ /**
478
+ * The command that restarts the daemon, for the places where a config change
479
+ * only takes effect on the next start.
480
+ *
481
+ * `--disconnect` is the motivating case: the collector manager starts once for
482
+ * the daemon's lifetime and the uploader caches its bearer key at construction,
483
+ * so removing the credential file changes nothing about the process already
484
+ * running.
485
+ */
486
+ export function daemonRestartCommand(): string | null {
487
+ if (!isDaemonSupportedPlatform()) return null;
488
+ return process.platform === "linux"
489
+ ? `sudo systemctl restart ${systemdUnitName()}`
490
+ : `sudo launchctl kickstart -k system/${launchdLabel()}`;
491
+ }
492
+
309
493
  /**
310
494
  * Acquires sudo credentials up front, prompting in the terminal if needed.
311
495
  *
@@ -337,8 +521,16 @@ export function primeElevation(): boolean {
337
521
  }
338
522
  }
339
523
 
340
- /** True when privileged commands can run without prompting for a password. */
341
- function canElevate(): boolean {
524
+ /**
525
+ * True when privileged commands can run without prompting for a password.
526
+ *
527
+ * Exported because onboarding re-checks it: `needs_root` is the most common
528
+ * reason setup aborts, and it is the one most likely to stop being true (the
529
+ * user gets sudo rights, or primes their timestamp in another terminal). One
530
+ * `sudo -n true`, no prompt, milliseconds — and never on the hook path, which
531
+ * does not reach the first-run gate at all.
532
+ */
533
+ export function canElevate(): boolean {
342
534
  if (typeof process.getuid === "function" && process.getuid() === 0) return true;
343
535
  try {
344
536
  execFileSync("sudo", ["-n", "true"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
@@ -442,24 +634,79 @@ function removeLegacyUserService(): void {
442
634
  }
443
635
  }
444
636
 
445
- export function systemdUnitContents(binaryPath: string, workerCmd: string | null): string {
446
- // Quoted because both values contain a space or a path — systemd's
637
+ export function systemdUnitContents(
638
+ binaryPath: string,
639
+ workerCmd: string | null,
640
+ cliCmd: string | null = null,
641
+ ): string {
642
+ // Quoted because every value here contains a space or a path — systemd's
447
643
  // Environment= requires quoting whenever the value does.
448
- const envLine = workerCmd ? `Environment="FAILPROOFAI_WORKER_CMD=${workerCmd}"\n` : "";
449
- const user = serviceUser();
644
+ const envLines = serviceEnvironment(workerCmd, cliCmd)
645
+ .map(([key, value]) => `Environment="${key}=${assertUnitSafe(value, key)}"\n`)
646
+ .join("");
647
+ const user = assertUnitSafe(serviceUser(), "User");
648
+ assertUnitSafe(binaryPath, "ExecStart");
649
+ assertUnitSafe(homedir(), "HOME");
650
+
651
+ // Gate the unit on the two files it cannot run without, so that an install
652
+ // which is no longer there STOPS rather than thrashes.
653
+ //
654
+ // `npm rm -g failproofai` is the case this is for, and npm runs no uninstall
655
+ // script — see the note on `failproofai uninstall`. It deletes the package,
656
+ // which takes `dist/worker.mjs` with it, while the daemon binary under
657
+ // ~/.failproofai survives. Without a condition systemd keeps a daemon alive
658
+ // whose worker cannot spawn; with a deleted BINARY it is worse, because
659
+ // ExecStart fails 203/EXEC under `Restart=on-failure` and cycles until it
660
+ // trips the start-limit and latches into "start request repeated too
661
+ // quickly" — a state that then refuses a legitimate restart later.
662
+ //
663
+ // A failed condition is not a failure: systemd SKIPS the job, leaves the unit
664
+ // inactive, and `systemctl status` names the exact path that was missing.
665
+ // That turns an unexplained crash-loop into a one-line diagnosis, and
666
+ // `daemonServiceStatus()` reads it back as `condition-failed` so the next CLI
667
+ // command can clear `daemonConfigured` instead of leaving the machine denying
668
+ // every tool call.
669
+ //
670
+ // This deliberately does NOT soften the hook path's fail-closed deny. A
671
+ // machine that was configured to require the daemon still denies while the
672
+ // daemon is absent — being skipped by systemd is not consent to stop
673
+ // enforcing. It shortens how long that lasts and explains why.
674
+ // Only paths that EXIST right now are gated on, and that filter is load-
675
+ // bearing rather than belt-and-braces.
676
+ //
677
+ // `binaryPath` is an ExecStart value, not necessarily a bare path: systemd
678
+ // accepts arguments there, and `FAILPROOFAI_DAEMON_BINARY` is documented as
679
+ // "someone named a binary explicitly" — this repo's own systemd tests set it
680
+ // to `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so
681
+ // gating on that string looks for a file literally named "sleep infinity",
682
+ // never finds it, and skips a unit that would have run perfectly. Splitting
683
+ // on whitespace to recover the binary is worse, because a path may legally
684
+ // contain spaces and there is no way to tell the two apart from here.
685
+ //
686
+ // Existence at render time answers it without guessing: a real binary is on
687
+ // disk when its unit is written (installDaemonService just put it there), and
688
+ // a command-with-arguments is not. Same rule the worker script already
689
+ // follows — never gate on a path that is not there, or the freshly installed
690
+ // unit skips on its very first start.
691
+ const conditionPaths = [binaryPath, workerScriptPath()].filter(
692
+ (p): p is string => typeof p === "string" && existsSync(p),
693
+ );
694
+ const conditionLines = conditionPaths
695
+ .map((p) => `ConditionPathExists=${assertUnitSafe(p, "ConditionPathExists")}\n`)
696
+ .join("");
697
+
450
698
  return `[Unit]
451
699
  Description=failproofai background daemon (failproofaid) for ${user}
452
700
  After=network.target
453
-
701
+ ${conditionLines}
454
702
  [Service]
455
703
  Type=simple
456
704
  User=${user}
457
705
  # Set explicitly rather than relying on systemd deriving it from User=:
458
- # failproofaid is user-scope by construction and refuses to start without
459
- # HOME ("HOME is not set; failproofaid is user-scope only"), so the one
460
- # variable it cannot do without is not left to a version-dependent default.
706
+ # failproofaid stores per-user state and refuses to start without HOME, so this
707
+ # required variable is not left to a version-dependent default.
461
708
  Environment="HOME=${homedir()}"
462
- ${envLine}ExecStart=${binaryPath}
709
+ ${envLines}ExecStart=${binaryPath}
463
710
  Restart=on-failure
464
711
  RestartSec=2
465
712
 
@@ -475,12 +722,27 @@ function escapeXml(s: string): string {
475
722
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
476
723
  }
477
724
 
478
- function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: string | null): string {
479
- const envBlock = workerCmd
725
+ function unescapeXml(s: string): string {
726
+ // &amp; last, or an escaped `&amp;lt;` would come back as `<`.
727
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
728
+ }
729
+
730
+ /**
731
+ * Exported for the same reason `systemdUnitContents` is: it is the only way to
732
+ * assert the plist's shape from a Linux CI runner, and this is the half of the
733
+ * pair no Linux test run can otherwise reach.
734
+ */
735
+ export function launchdPlistContents(
736
+ binaryPath: string,
737
+ logDir: string,
738
+ workerCmd: string | null,
739
+ cliCmd: string | null = null,
740
+ ): string {
741
+ const env = serviceEnvironment(workerCmd, cliCmd);
742
+ const envBlock = env.length
480
743
  ? ` <key>EnvironmentVariables</key>
481
744
  <dict>
482
- <key>FAILPROOFAI_WORKER_CMD</key>
483
- <string>${escapeXml(workerCmd)}</string>
745
+ ${env.map(([key, value]) => ` <key>${key}</key>\n <string>${escapeXml(value)}</string>`).join("\n")}
484
746
  </dict>
485
747
  `
486
748
  : "";
@@ -523,12 +785,21 @@ export interface DaemonInstallResult {
523
785
  * machine that cannot elevate can be told exactly what to run rather than
524
786
  * just that something failed.
525
787
  */
526
- function daemonInstallCommands(binaryPath: string, workerCmd: string | null): string[] {
788
+ function daemonInstallCommands(
789
+ binaryPath: string,
790
+ workerCmd: string | null,
791
+ cliCmd: string | null,
792
+ ): string[] {
527
793
  if (process.platform === "linux") {
528
794
  return [
529
- `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd)}EOF`,
795
+ `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd, cliCmd)}EOF`,
530
796
  "sudo systemctl daemon-reload",
531
- `sudo systemctl enable --now ${systemdUnitName()}`,
797
+ `sudo systemctl enable ${systemdUnitName()}`,
798
+ // `restart`, not `enable --now`, for the reason the install path gives:
799
+ // `--now` does nothing to an already-active unit, and these commands are
800
+ // handed to someone whose daemon may well be running — an upgrade is the
801
+ // most likely reason they are reading them.
802
+ `sudo systemctl restart ${systemdUnitName()}`,
532
803
  ];
533
804
  }
534
805
  return [
@@ -538,18 +809,23 @@ function daemonInstallCommands(binaryPath: string, workerCmd: string | null): st
538
809
  }
539
810
 
540
811
  /**
541
- * Writes and enables the service unit, starting it immediately. Safe to
542
- * call repeatedly re-running replaces the unit file (picking up a
543
- * changed binary path after an upgrade) and re-enables it.
812
+ * Writes and enables the service unit, and leaves the machine running THAT
813
+ * unitnot merely a unit.
814
+ *
815
+ * Safe to call repeatedly: re-running replaces the unit file (picking up a
816
+ * changed binary path after an upgrade) and restarts the service so the
817
+ * running process is the one the file describes. It used to say it did that
818
+ * and did not: `enable --now` is a no-op against an active unit, so every
819
+ * reinstall over a live daemon rewrote the file and left the old process in
820
+ * place. See the `restartSystemdUnit()` call below.
544
821
  */
545
822
  export async function installDaemonService(): Promise<DaemonInstallResult> {
546
823
  if (!isDaemonSupportedPlatform()) {
547
824
  return { installed: false, reason: `failproofaid is not supported on ${process.platform} yet` };
548
825
  }
549
826
 
550
- // May reach the network: the npm package carries no binary, so this is
551
- // where a machine opting into the daemon fetches the one built for its
552
- // platform from this version's release.
827
+ // May reach the network when the matching optional platform package is not
828
+ // installed. The GitHub release asset is the verified fallback channel.
553
829
  const { path: binaryPath, reason: binaryReason } = await ensureFailproofaidBinary();
554
830
  if (!binaryPath) {
555
831
  return { installed: false, reason: binaryReason ?? "failproofaid binary not found for this platform" };
@@ -562,6 +838,11 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
562
838
  // environment is what makes a *service-managed* daemon actually find its
563
839
  // worker regardless of what cwd the service manager starts it from.
564
840
  const workerCmd = resolveWorkerCommand();
841
+ // Same best-effort footing as the worker command, and null is survivable in
842
+ // the same way — the daemon just has no way to run an audit. Unlike the
843
+ // worker, there is no relative-path fallback to degrade to, which is exactly
844
+ // why it is resolved here where FAILPROOFAI_PACKAGE_ROOT is reliably set.
845
+ const cliCmd = resolveCliCommand();
565
846
 
566
847
  // The service is installed system-wide, which needs root. Check before
567
848
  // writing anything, so a machine that cannot elevate gets the exact
@@ -573,7 +854,7 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
573
854
  "root privileges are required to install the failproofaid system service, and sudo credentials were not available. " +
574
855
  "Re-run `failproofai config` and approve the sudo prompt — do NOT run the CLI itself under sudo, which would " +
575
856
  "configure root's account instead of yours. To install by hand: " +
576
- daemonInstallCommands(binaryPath, workerCmd).join(" && "),
857
+ daemonInstallCommands(binaryPath, workerCmd, cliCmd).join(" && "),
577
858
  };
578
859
  }
579
860
 
@@ -585,12 +866,36 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
585
866
 
586
867
  try {
587
868
  if (process.platform === "linux") {
588
- writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd));
869
+ writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd, cliCmd));
589
870
  runPrivileged("systemctl", ["daemon-reload"]);
590
- runPrivileged("systemctl", ["enable", "--now", systemdUnitName()]);
871
+ // `enable` for boot persistence, then `restart` — NOT `enable --now`.
872
+ //
873
+ // `--now` starts a unit that is stopped and does NOTHING to one that is
874
+ // already active. So on every install over a live daemon it returned
875
+ // success having changed nothing, and the machine kept running the OLD
876
+ // binary from the OLD unit — the same trap `ensureDaemonServiceCurrent`
877
+ // already documents and avoids, which this path never inherited.
878
+ //
879
+ // Version skew is where that mattered. The wizard's `daemonBroken` is
880
+ // `daemonUpToDate && !daemonAnswers`, and `daemonUpToDate` requires
881
+ // `daemonSkew === null`, so skew can never set it — the
882
+ // uninstall-then-reinstall path does not fire and this install runs
883
+ // straight over the still-live old process. `probeDaemon()` then reads a
884
+ // protocol-mismatch reply from that survivor as `ok`, deliberately,
885
+ // because it is "acted on elsewhere" — elsewhere being here. The CLI
886
+ // recorded `daemonConfigured` at the NEW version, and
887
+ // `pruneOldDaemonBinaries()` was then free to delete the binary the
888
+ // running process had been started from. The documented recovery for a
889
+ // `PROTOCOL_VERSION` bump (`npm update -g failproofai` → `failproofai
890
+ // config`) therefore left the machine exactly as skewed as it began.
891
+ //
892
+ // `restart` also covers the fresh-install case: it starts a unit that is
893
+ // not running, so this is strictly `--now` plus the case `--now` missed.
894
+ runPrivileged("systemctl", ["enable", systemdUnitName()]);
895
+ restartSystemdUnit();
591
896
  } else {
592
897
  const plistPath = launchdPlistPath();
593
- const logDir = resolve(homedir(), ".failproofai", "logs");
898
+ const logDir = logsDir();
594
899
  mkdirSync(logDir, { recursive: true });
595
900
  // Unload any previously-loaded copy first — reloading with a changed
596
901
  // binary path (e.g. after an upgrade) is a no-op under plain `load`
@@ -600,7 +905,7 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
600
905
  } catch {
601
906
  // Wasn't loaded — fine, this is the common case on a fresh install.
602
907
  }
603
- writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd));
908
+ writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd));
604
909
  runPrivileged("launchctl", ["load", "-w", plistPath]);
605
910
  }
606
911
  } catch (err) {
@@ -621,23 +926,550 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
621
926
  hookLogWarn(`daemon service install failed: ${reason}`);
622
927
  return { installed: false, reason };
623
928
  }
929
+
930
+ // NOTE: this reports that the SERVICE is installed and running, which is all
931
+ // it can honestly claim. Whether the daemon can actually evaluate a hook is a
932
+ // separate question, answered by `probeDaemonEndToEnd()` — and the caller
933
+ // must ask it before setting `daemonConfigured`, because that flag is what
934
+ // makes an unanswering daemon deny every tool call. `configure-wizard.ts`
935
+ // does exactly that. Keeping the two apart matters: "the unit was written and
936
+ // systemd started it" is testable against a stub binary, and folding the
937
+ // probe in here would mean this function could never be tested without a real
938
+ // failproofaid.
624
939
  return { installed: true };
625
940
  }
626
941
 
627
942
  /**
628
- * Waits for the service to report running, then re-checks after a settle
629
- * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
630
- * pass on the strength of one optimistic reading.
943
+ * How long the health probe waits for the daemon to answer a real hook.
944
+ *
945
+ * Far below the hook path's 30s: this runs from an interactive command, and
946
+ * the cold start it is most likely to be waiting on (`worker.rs` pre-warms the
947
+ * worker, ~700ms of Node startup) is comfortably inside it. A daemon that
948
+ * cannot answer a trivial event in this long is not one to hand a machine's
949
+ * fail-closed enforcement to.
631
950
  */
632
- async function waitForDaemonRunning(): Promise<boolean> {
633
- const deadline = Date.now() + SERVICE_START_TIMEOUT_MS;
951
+ const DAEMON_PROBE_TIMEOUT_MS = 5_000;
952
+
953
+ /**
954
+ * How long the probe keeps waiting for the socket to come up before calling the
955
+ * daemon unreachable. Generous on purpose: this runs once, from an interactive
956
+ * setup command, and the cost of being impatient is aborting setup at a machine
957
+ * whose daemon is merely still starting.
958
+ */
959
+ const DAEMON_PROBE_READY_TIMEOUT_MS = 10_000;
960
+ const DAEMON_PROBE_RETRY_MS = 150;
961
+
962
+ /**
963
+ * Ask the daemon to evaluate a real hook, end to end.
964
+ *
965
+ * The thing this catches that nothing else did: a unit that is *running* while
966
+ * the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
967
+ * and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
968
+ * systemd reports as perfectly active and a worker that dies on every spawn.
969
+ * Every existing check passes that machine — `waitForDaemonRunning()` asks the
970
+ * service manager, `Ping` is answered in `server.rs` without ever touching the
971
+ * worker, a null `resolveWorkerCommand()` is treated as best-effort, and
972
+ * `Worker::warm()` swallows its own failure. `daemonConfigured` then gets set,
973
+ * and because that flag makes every hook fail closed, the machine denies every
974
+ * tool call across all 12 CLIs — including `UserPromptSubmit`, so the user
975
+ * cannot even talk to their agent to ask why.
976
+ *
977
+ * `SessionStart` rather than a tool event: it traverses the identical path
978
+ * (socket → daemon → worker → `evaluateHookEvent`) but describes nothing a
979
+ * policy would deny and records no tool decision, so probing cannot itself
980
+ * change what the machine does.
981
+ */
982
+ export type DaemonProbe =
983
+ | { ok: true }
984
+ | {
985
+ ok: false;
986
+ /**
987
+ * `unreachable` — nothing ever accepted a connection on the socket.
988
+ * `worker` — the daemon accepted a connection but could not answer a
989
+ * hook, which is the worker failing to run.
990
+ *
991
+ * Kept apart because the remedies are different, and because reporting
992
+ * "your worker will not start" at someone whose worker is fine sends them
993
+ * to inspect a healthy process. `DaemonFailure` cannot make this
994
+ * distinction: it reports `unreachable` for a refused connection AND for
995
+ * a request that was accepted and never answered.
996
+ */
997
+ reason: "unreachable" | "worker";
998
+ };
999
+
1000
+ /**
1001
+ * Ask the daemon to evaluate a real hook, end to end.
1002
+ *
1003
+ * The thing this catches that nothing else did: a unit that is *running* while
1004
+ * the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
1005
+ * and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
1006
+ * systemd reports as perfectly active and a worker that dies on every spawn.
1007
+ *
1008
+ * **Why this retries.** It is called moments after `systemctl enable --now`, and
1009
+ * a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before
1010
+ * the daemon has bound its socket. A single attempt therefore raced the bind
1011
+ * and, because the hook path's connect budget is deliberately 150ms, lost that
1012
+ * race on any loaded machine. Setup then aborted with "its worker process could
1013
+ * not be run" at a daemon that was seconds away from serving happily — the
1014
+ * worker had already logged that it was listening. Waiting for the socket is
1015
+ * the fix; relaxing the 150ms is NOT, because that budget is what keeps a dead
1016
+ * daemon from adding latency to every tool call on the hook path.
1017
+ */
1018
+ export async function probeDaemon(): Promise<DaemonProbe> {
1019
+ try {
1020
+ const { attemptDaemonHook, daemonAcceptsConnections } = await import("./daemon-client");
1021
+ const deadline = Date.now() + DAEMON_PROBE_READY_TIMEOUT_MS;
1022
+ let everConnected = false;
1023
+
1024
+ for (;;) {
1025
+ if (await daemonAcceptsConnections()) {
1026
+ everConnected = true;
1027
+ const attempt = await attemptDaemonHook(
1028
+ {
1029
+ hookEvent: "SessionStart",
1030
+ cli: "claude",
1031
+ stdin: JSON.stringify({
1032
+ hook_event_name: "SessionStart",
1033
+ source: "failproofai-health-probe",
1034
+ }),
1035
+ },
1036
+ { responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
1037
+ );
1038
+ // A protocol mismatch is a REACHABLE daemon of the wrong vintage, which
1039
+ // is a version problem rather than the lockout this probe exists to
1040
+ // find. It is reported, and acted on, elsewhere.
1041
+ if (attempt.ok || attempt.failure === "protocol-mismatch") return { ok: true };
1042
+ }
1043
+ if (Date.now() >= deadline) break;
1044
+ await new Promise((r) => setTimeout(r, DAEMON_PROBE_RETRY_MS));
1045
+ }
1046
+ return { ok: false, reason: everConnected ? "worker" : "unreachable" };
1047
+ } catch {
1048
+ return { ok: false, reason: "unreachable" };
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * "Is anything listening on the daemon socket?" — never throws.
1054
+ *
1055
+ * Split out because `waitForDaemonRunning` needs the connect check WITHOUT the
1056
+ * hook evaluation: it runs inside a settle loop, and a failed import or a
1057
+ * transient refusal there must read as "not yet", never as an error.
1058
+ */
1059
+ async function daemonAcceptsConnectionsQuietly(): Promise<boolean> {
1060
+ try {
1061
+ const { daemonAcceptsConnections } = await import("./daemon-client");
1062
+ return await daemonAcceptsConnections();
1063
+ } catch {
1064
+ return false;
1065
+ }
1066
+ }
1067
+
1068
+ /** Boolean form, for callers that only branch on healthy/not. */
1069
+ export async function probeDaemonEndToEnd(): Promise<boolean> {
1070
+ return (await probeDaemon()).ok;
1071
+ }
1072
+
1073
+ /**
1074
+ * Waits for the service to report running and to HOLD it — a `Type=simple` unit
1075
+ * is active the moment it forks, so one optimistic reading passes a daemon that
1076
+ * died at startup.
1077
+ *
1078
+ * WATCHED, not slept through. This used to sleep `SERVICE_SETTLE_MS` blind and
1079
+ * read the status once at the end, which was wrong in both directions on a
1080
+ * healthy machine and a broken one:
1081
+ *
1082
+ * • Healthy: the socket is up in ~13ms and answers a real hook in ~125ms, and
1083
+ * setup still sat there for the remaining ~600ms with the answer already in
1084
+ * hand. Setup runs this twice on the repair path (uninstall, reinstall), so
1085
+ * it was over a second of dead wait every time.
1086
+ * • Broken: a daemon that died at 100ms was not noticed until 750ms, because
1087
+ * nothing looked until the sleep was over.
1088
+ *
1089
+ * Now the window is polled. Leaving `running` at any point fails immediately,
1090
+ * and the wait ends early once the daemon has answered a real hook — a reply is
1091
+ * strictly stronger evidence of "did not die at startup" than "still active
1092
+ * after an arbitrary sleep", which is all the settle ever established.
1093
+ */
1094
+ export interface WaitForDaemonDeps {
1095
+ status?: () => DaemonServiceStatus;
1096
+ accepts?: () => Promise<boolean>;
1097
+ sleep?: (ms: number) => Promise<void>;
1098
+ now?: () => number;
1099
+ }
1100
+
1101
+ export async function waitForDaemonRunning(deps: WaitForDaemonDeps = {}): Promise<boolean> {
1102
+ const status = deps.status ?? daemonServiceStatus;
1103
+ const accepts = deps.accepts ?? daemonAcceptsConnectionsQuietly;
1104
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
1105
+ const now = deps.now ?? Date.now;
1106
+
1107
+ const deadline = now() + SERVICE_START_TIMEOUT_MS;
634
1108
  for (;;) {
635
- if (daemonServiceStatus() === "running") break;
636
- if (Date.now() >= deadline) return false;
637
- await new Promise((r) => setTimeout(r, SERVICE_START_POLL_MS));
1109
+ if (status() === "running") break;
1110
+ if (now() >= deadline) return false;
1111
+ await sleep(SERVICE_START_POLL_MS);
1112
+ }
1113
+
1114
+ const settleUntil = now() + SERVICE_SETTLE_MS;
1115
+ for (;;) {
1116
+ // A unit that has left `running` is dead now; there is nothing to wait out.
1117
+ if (status() !== "running") return false;
1118
+ // A daemon that accepted a connection is a daemon that got past startup and
1119
+ // bound its socket. Deliberately the CHEAP check, not a full hook
1120
+ // evaluation: `probeDaemon` runs the end-to-end one moments later, and
1121
+ // paying for it twice is what this rewrite exists to stop.
1122
+ if (await accepts()) return true;
1123
+ if (now() >= settleUntil) return true;
1124
+ await sleep(SERVICE_START_POLL_MS);
1125
+ }
1126
+ }
1127
+
1128
+ // ── Upgrading a service definition that predates a variable ──────────────────
1129
+ //
1130
+ // A machine that installed the daemon before `FAILPROOFAI_CLI_CMD` existed
1131
+ // keeps its old unit forever: `npm i -g failproofai@latest` replaces the CLI
1132
+ // and never touches /etc/systemd/system, and the wizard's own "already
1133
+ // installed and running — leaving it alone" branch skips it too. The daemon
1134
+ // then has no way to spawn an audit while `config.toml` says the scheduled
1135
+ // scan is on, and nothing anywhere reports a fault. This is the single most
1136
+ // likely way the whole feature ends up dead on real machines, so it gets an
1137
+ // explicit detect-and-rewrite rather than an assumption that reinstalls happen.
1138
+
1139
+ /** Reads the installed unit / plist, or null when no service is installed. */
1140
+ function readInstalledServiceDefinition(): string | null {
1141
+ if (!isDaemonSupportedPlatform()) return null;
1142
+ const path = daemonServiceFilePath();
1143
+ if (!path || !existsSync(path)) return null;
1144
+ try {
1145
+ return readFileSync(path, "utf8");
1146
+ } catch {
1147
+ // Unreadable is not "absent": claiming no service is installed would let a
1148
+ // caller reinstall over something it cannot see. Treated as "nothing to
1149
+ // upgrade" by the one caller below, which is the inert direction.
1150
+ return null;
638
1151
  }
639
- await new Promise((r) => setTimeout(r, SERVICE_SETTLE_MS));
640
- return daemonServiceStatus() === "running";
1152
+ }
1153
+
1154
+ /**
1155
+ * Reads one variable back out of an installed service definition.
1156
+ *
1157
+ * Used to carry values FORWARD across a rewrite. A rewrite resolves each
1158
+ * command from this process, and a resolution that comes back null (no
1159
+ * `FAILPROOFAI_PACKAGE_ROOT`, a `dist/` that moved) must never be allowed to
1160
+ * silently DELETE a working line from a working unit — the rewrite exists to
1161
+ * add a variable, not to drop one.
1162
+ */
1163
+ function installedEnvValue(definition: string, name: string): string | null {
1164
+ if (process.platform === "linux") {
1165
+ const m = new RegExp(`^Environment="${name}=(.*)"$`, "m").exec(definition);
1166
+ return m ? m[1] : null;
1167
+ }
1168
+ const m = new RegExp(`<key>${name}</key>\\s*<string>([^<]*)</string>`).exec(definition);
1169
+ return m ? unescapeXml(m[1]) : null;
1170
+ }
1171
+
1172
+ /**
1173
+ * The binary an installed definition currently starts.
1174
+ *
1175
+ * Preserved across a rewrite rather than re-resolved, because the two disagree
1176
+ * in exactly the upgrade case this is for: `installedBinaryPath()` is
1177
+ * version-stamped (`failproofaid-<version>`), so right after a CLI upgrade the
1178
+ * binary for the NEW version is not on disk yet and resolution returns null,
1179
+ * while the unit still points at the older binary that is there and running.
1180
+ * Re-resolving would either refuse to rewrite or repoint a live service at a
1181
+ * file that does not exist.
1182
+ */
1183
+ function installedExecStart(definition: string): string | null {
1184
+ if (process.platform === "linux") {
1185
+ const m = /^ExecStart=(.+)$/m.exec(definition);
1186
+ return m ? m[1].trim() : null;
1187
+ }
1188
+ const m = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(definition);
1189
+ return m ? unescapeXml(m[1]) : null;
1190
+ }
1191
+
1192
+ /**
1193
+ * True when a service IS installed but its definition predates
1194
+ * `FAILPROOFAI_CLI_CMD`.
1195
+ *
1196
+ * Deliberately a content check against the definition on disk, not a version
1197
+ * number mirrored into `config.toml`. The mirror is the tempting shape — it is
1198
+ * how `daemon.installed_version` is modelled — but that field is declared,
1199
+ * read and cleared and has never once been WRITTEN (both `setDaemonConfigured`
1200
+ * call sites pass no version), which is precisely how a mirror fails: it
1201
+ * reports "current" for a unit nobody updated. A false negative here is the
1202
+ * permanently-inert audit lane this function exists to catch, and the unit
1203
+ * itself cannot lie about what it contains.
1204
+ */
1205
+ export function daemonServiceNeedsUpgrade(): boolean {
1206
+ const definition = readInstalledServiceDefinition();
1207
+ if (definition === null) return false;
1208
+ return !definition.includes("FAILPROOFAI_CLI_CMD");
1209
+ }
1210
+
1211
+ /**
1212
+ * The definition an installed one should become: regenerated by the same
1213
+ * renderers a fresh install uses, carrying forward everything this process
1214
+ * cannot re-resolve. `null` when the input has no readable start command,
1215
+ * which means it is not a definition failproofai wrote and must be left alone.
1216
+ *
1217
+ * Split out from `ensureDaemonServiceCurrent` and kept pure — no privilege, no
1218
+ * service manager, no writes — because the rewrite itself can only be
1219
+ * exercised end-to-end on a machine with root AND the matching service
1220
+ * manager. That is nowhere on a Linux CI runner for the launchd half, and
1221
+ * nowhere at all on a developer box without passwordless sudo, so the one
1222
+ * transformation that must not be wrong would otherwise be the one thing no
1223
+ * ordinary test run ever executes.
1224
+ */
1225
+ export function upgradedServiceDefinition(
1226
+ definition: string,
1227
+ cliCmd: string,
1228
+ logDir: string,
1229
+ ): string | null {
1230
+ const binaryPath = installedExecStart(definition);
1231
+ if (!binaryPath) return null;
1232
+ const workerCmd = resolveWorkerCommand() ?? installedEnvValue(definition, "FAILPROOFAI_WORKER_CMD");
1233
+ try {
1234
+ return process.platform === "linux"
1235
+ ? systemdUnitContents(binaryPath, workerCmd, cliCmd)
1236
+ : launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd);
1237
+ } catch (err) {
1238
+ // A value that cannot be safely interpolated is reported as "no usable
1239
+ // definition" rather than thrown: this runs against a HEALTHY, RUNNING
1240
+ // daemon and the caller's contract is an outcome, not an exception. The
1241
+ // refusal happens before anything is written or stopped, so the machine is
1242
+ // left exactly as it was.
1243
+ hookLogWarn(
1244
+ `service definition not rewritten: ${err instanceof Error ? err.message : String(err)}`,
1245
+ );
1246
+ return null;
1247
+ }
1248
+ }
1249
+
1250
+ export type DaemonUpgradeOutcome =
1251
+ /** No service installed, or its definition already carries the variable. */
1252
+ | "current"
1253
+ /** The definition was rewritten and the service came back running. */
1254
+ | "rewritten"
1255
+ /** It needed a rewrite and did not get one. `reason` says why. */
1256
+ | "failed";
1257
+
1258
+ export interface DaemonUpgradeResult {
1259
+ outcome: DaemonUpgradeOutcome;
1260
+ reason?: string;
1261
+ /**
1262
+ * Whether failproofaid is running when this returns — probed, never
1263
+ * inferred from what the commands returned.
1264
+ *
1265
+ * Set on every outcome that touched the service, because on a
1266
+ * `daemonConfigured` machine a stopped daemon is not a lost feature: every
1267
+ * hook event fails closed, across all 12 CLIs, until somebody hand-edits
1268
+ * `policies-config.json`. The caller needs to know that specifically, not
1269
+ * just that "the refresh failed".
1270
+ */
1271
+ daemonRunning?: boolean;
1272
+ }
1273
+
1274
+ /**
1275
+ * Restart the systemd unit, clearing any start-limit latch first.
1276
+ *
1277
+ * The unit ships `Restart=on-failure` with `RestartSec=2`, so a definition
1278
+ * systemd accepts but cannot run (a poisoned `User=`, a missing binary) does
1279
+ * not fail once — it cycles, and within `DefaultStartLimitIntervalSec` (10s) it
1280
+ * trips `DefaultStartLimitBurst` (5) and latches into "start request repeated
1281
+ * too quickly". On systemd 255 — what ubuntu-24.04 and GitHub's runners ship —
1282
+ * that latch is sticky at the unit level: a later `systemctl restart` is refused
1283
+ * even after the definition on disk has been REPLACED with a good one. A
1284
+ * rollback that restores a perfectly runnable unit then still cannot start it,
1285
+ * and the machine stays fail-closed against a daemon that never comes back.
1286
+ * `reset-failed` clears the failure counter so the restart is deterministic; on
1287
+ * a healthy unit it is a no-op. Best-effort — a `reset-failed` that errors (the
1288
+ * unit was never failed) must never abort the restart that is the real step.
1289
+ */
1290
+ function restartSystemdUnit(): void {
1291
+ try {
1292
+ runPrivileged("systemctl", ["reset-failed", systemdUnitName()]);
1293
+ } catch {
1294
+ // The restart below is the operative step; do not let a reset-failed
1295
+ // that errored on an already-clean unit swallow it.
1296
+ }
1297
+ runPrivileged("systemctl", ["restart", systemdUnitName()]);
1298
+ }
1299
+
1300
+ /**
1301
+ * Puts a previous service definition back and starts the service again,
1302
+ * answering whether failproofaid is running once it has.
1303
+ *
1304
+ * `ensureDaemonServiceCurrent` is the only caller, and it runs exclusively
1305
+ * against a machine whose daemon is UP — which is what makes a half-applied
1306
+ * rewrite so much worse than no rewrite. Stopping a healthy daemon and failing
1307
+ * to start it again trades a dead audit lane for a machine that denies every
1308
+ * tool call, so the refresh must be able to undo itself.
1309
+ *
1310
+ * Best-effort and never throws: it only ever runs where something has already
1311
+ * gone wrong. The return value is `waitForDaemonRunning()` rather than "the
1312
+ * commands succeeded" on purpose — the common rollback failure is a lost sudo
1313
+ * credential, which means nothing was written and the daemon was never down.
1314
+ */
1315
+ async function restoreServiceDefinition(previous: string): Promise<boolean> {
1316
+ try {
1317
+ if (process.platform === "linux") {
1318
+ writePrivilegedFile(systemdUnitPath(), previous);
1319
+ runPrivileged("systemctl", ["daemon-reload"]);
1320
+ // reset-failed first: the failed rewrite we are undoing has been cycling
1321
+ // under Restart=on-failure and may have tripped systemd's start-limit,
1322
+ // which on systemd 255 refuses to start even this restored, good unit.
1323
+ restartSystemdUnit();
1324
+ } else {
1325
+ const plistPath = launchdPlistPath();
1326
+ try {
1327
+ runPrivileged("launchctl", ["unload", plistPath]);
1328
+ } catch {
1329
+ // Already unloaded — getting the old definition loaded is the point.
1330
+ }
1331
+ writePrivilegedFile(plistPath, previous);
1332
+ runPrivileged("launchctl", ["load", "-w", plistPath]);
1333
+ }
1334
+ } catch (err) {
1335
+ hookLogWarn(
1336
+ `daemon service definition rollback failed: ${err instanceof Error ? err.message : String(err)}`,
1337
+ );
1338
+ // Not a verdict on the daemon — fall through to the probe below, which is.
1339
+ }
1340
+ return waitForDaemonRunning();
1341
+ }
1342
+
1343
+ /**
1344
+ * The one exit for every way a refresh can fail after it started touching the
1345
+ * service. Rolls back when there is something to roll back to, then reports
1346
+ * the daemon's actual state.
1347
+ */
1348
+ async function refreshFailed(message: string, previous: string | null): Promise<DaemonUpgradeResult> {
1349
+ hookLogWarn(`daemon service unit refresh failed: ${message}`);
1350
+ const running =
1351
+ previous === null ? daemonServiceStatus() === "running" : await restoreServiceDefinition(previous);
1352
+ return {
1353
+ outcome: "failed",
1354
+ daemonRunning: running,
1355
+ reason: running
1356
+ ? message
1357
+ : `${message}. The previous service definition could not be restored either, so failproofaid is NOT running`,
1358
+ };
1359
+ }
1360
+
1361
+ /**
1362
+ * Brings an already-installed service definition up to date, in place.
1363
+ *
1364
+ * Narrower than `installDaemonService` on purpose, and it is the narrowness
1365
+ * that makes it safe to run against a healthy machine: it never downloads, it
1366
+ * never touches a machine that has no service, and it keeps the ExecStart and
1367
+ * any environment value it cannot re-resolve. Everything past the first
1368
+ * privileged write is undone by `restoreServiceDefinition` if the daemon does
1369
+ * not come back, so the worst outcome really is the machine it started with —
1370
+ * and when even the rollback cannot get the daemon up, that fact rides out on
1371
+ * `daemonRunning` rather than being folded into a generic failure, because the
1372
+ * caller has to stop asserting `daemonConfigured` over a daemon that is gone.
1373
+ *
1374
+ * It DOES restart the service, which `installDaemonService` does not do on
1375
+ * Linux (`enable --now` is a no-op against an already-active unit, so a
1376
+ * rewritten unit's environment reaches the running process only at the next
1377
+ * boot). Without the restart this "fix" would leave the audit lane inert for
1378
+ * however long the machine stays up — which is the bug, not a smaller version
1379
+ * of it. The restart costs a sub-second window in which a hook can find no
1380
+ * daemon and fail closed, so it is confined to the wizard: `failproofai
1381
+ * config` is an explicit, attended reconfiguration, and the same window
1382
+ * already exists on macOS, where install unloads and reloads on every run.
1383
+ */
1384
+ export async function ensureDaemonServiceCurrent(): Promise<DaemonUpgradeResult> {
1385
+ const definition = readInstalledServiceDefinition();
1386
+ if (definition === null || definition.includes("FAILPROOFAI_CLI_CMD")) {
1387
+ return { outcome: "current" };
1388
+ }
1389
+
1390
+ const cliCmd = resolveCliCommand();
1391
+ if (!cliCmd) {
1392
+ return {
1393
+ outcome: "failed",
1394
+ reason:
1395
+ "the failproofai CLI entry point (dist/cli.mjs) could not be located, so there is nothing to " +
1396
+ "write into the unit. Re-run `failproofai config` from a complete install.",
1397
+ };
1398
+ }
1399
+
1400
+ const logDir = logsDir();
1401
+ const upgraded = upgradedServiceDefinition(definition, cliCmd, logDir);
1402
+ if (!upgraded) {
1403
+ return {
1404
+ outcome: "failed",
1405
+ reason:
1406
+ `no safe replacement could be composed for the service definition at ${daemonServiceFilePath()} — ` +
1407
+ `either it has no readable start command (it looks hand-edited) or a value that would go into it ` +
1408
+ `contains a quote, backslash or newline. It was left alone.`,
1409
+ // Reported because nothing was touched: the refusal happens before any
1410
+ // write and before any stop, so whatever was running still is. The
1411
+ // wizard branches on this to decide whether to clear `daemonConfigured`,
1412
+ // and omitting it here would have read as "the daemon may be down" on a
1413
+ // machine whose daemon is perfectly healthy — which on a
1414
+ // `daemonConfigured` box is the difference between a lost audit lane and
1415
+ // every tool call denied.
1416
+ daemonRunning: daemonServiceStatus() === "running",
1417
+ };
1418
+ }
1419
+
1420
+ if (!canElevate()) {
1421
+ return {
1422
+ outcome: "failed",
1423
+ reason: `root privileges are required to rewrite ${daemonServiceFilePath()}, and sudo credentials were not available`,
1424
+ };
1425
+ }
1426
+
1427
+ // What to put back if this goes wrong, and from which point. Set BEFORE the
1428
+ // step that can leave the machine changed, not after it succeeds: on Linux
1429
+ // `writePrivilegedFile` is an `install` copy straight onto the unit path, so
1430
+ // a failure part-way through leaves a truncated unit behind, and on macOS
1431
+ // the unload has already stopped the daemon before anything is written.
1432
+ let previous: string | null = null;
1433
+
1434
+ try {
1435
+ if (process.platform === "linux") {
1436
+ previous = definition;
1437
+ writePrivilegedFile(systemdUnitPath(), upgraded);
1438
+ runPrivileged("systemctl", ["daemon-reload"]);
1439
+ // `restart`, not `enable --now`: the unit is already enabled and active,
1440
+ // so `--now` would return success having changed nothing and the daemon
1441
+ // would keep the environment it was started with. reset-failed first so a
1442
+ // machine that arrived here with an already-latched start-limit (a prior
1443
+ // failed refresh) can still be upgraded.
1444
+ restartSystemdUnit();
1445
+ } else {
1446
+ const plistPath = launchdPlistPath();
1447
+ mkdirSync(logDir, { recursive: true });
1448
+ previous = definition;
1449
+ try {
1450
+ runPrivileged("launchctl", ["unload", plistPath]);
1451
+ } catch {
1452
+ // Not loaded — the rewrite is still the point.
1453
+ }
1454
+ writePrivilegedFile(plistPath, upgraded);
1455
+ runPrivileged("launchctl", ["load", "-w", plistPath]);
1456
+ }
1457
+ } catch (err) {
1458
+ return refreshFailed(err instanceof Error ? err.message : String(err), previous);
1459
+ }
1460
+
1461
+ // The service was running when this started, so anything short of running
1462
+ // now is a regression this call caused — and on a daemon-configured machine
1463
+ // that is every tool call failing closed. A unit that parses but will not
1464
+ // start is exactly the case `systemctl restart`'s exit code does not catch,
1465
+ // so this is the check that hands it to the rollback.
1466
+ if (!(await waitForDaemonRunning())) {
1467
+ return refreshFailed(
1468
+ `the service definition was refreshed but failproofaid did not come back within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`,
1469
+ previous,
1470
+ );
1471
+ }
1472
+ return { outcome: "rewritten", daemonRunning: true };
641
1473
  }
642
1474
 
643
1475
  /**
@@ -651,6 +1483,129 @@ async function waitForDaemonRunning(): Promise<boolean> {
651
1483
  * event on the machine with no recovery short of hand-editing
652
1484
  * `~/.failproofai/policies-config.json`.
653
1485
  */
1486
+ /**
1487
+ * Bring the installed daemon up to this CLI's version — the second half of an
1488
+ * upgrade that npm does not do.
1489
+ *
1490
+ * `npm i -g` replaces the CLI and nothing else. The binary lives at
1491
+ * `~/.failproofai/bin/failproofaid-<version>` precisely so an upgrade cannot swap
1492
+ * it under a running service, which means after an npm upgrade the two halves are
1493
+ * different versions until something like this runs. `daemonVersionSkew()` has
1494
+ * been reporting that state on every command; this is the command that fixes it.
1495
+ *
1496
+ * Composed from the pieces that already exist rather than reimplementing any of
1497
+ * them: `ensureFailproofaidBinary()` puts the right file on disk (npm optional
1498
+ * dependency first, release asset second, SHA-256 verified either way),
1499
+ * `ensureDaemonServiceCurrent()` rewrites the unit if its shape has changed, and
1500
+ * `waitForDaemonRunning()` is what distinguishes "started" from "started and
1501
+ * still up" — a `Type=simple` unit reports active the moment it forks.
1502
+ *
1503
+ * NEVER clears `daemonConfigured`. A refresh that fails leaves a machine
1504
+ * fail-closed against a daemon that may be down, and that is the correct, loud
1505
+ * state: the remedy is `failproofai config`, and silently downgrading the machine
1506
+ * to in-process evaluation would trade a visible failure for an invisible one.
1507
+ * The lines say so.
1508
+ */
1509
+ export async function refreshDaemonToCliVersion(
1510
+ /**
1511
+ * Injectable for tests, matching `waitForDaemonRunning`'s shape in this file.
1512
+ * A `vi.spyOn` on the module namespace cannot intercept these — the module
1513
+ * calls its own local bindings — so without a seam a test asserting either
1514
+ * branch passes for the wrong reason: on a machine with no service, "no-op"
1515
+ * and "the spy worked" are indistinguishable.
1516
+ */
1517
+ deps: {
1518
+ status?: () => DaemonServiceStatus;
1519
+ install?: () => Promise<DaemonInstallResult>;
1520
+ prime?: () => boolean;
1521
+ interactive?: () => boolean;
1522
+ } = {},
1523
+ ): Promise<{ ok: boolean; lines: string[] }> {
1524
+ const status = deps.status ?? daemonServiceStatus;
1525
+ const install = deps.install ?? installDaemonService;
1526
+ const prime = deps.prime ?? primeElevation;
1527
+ const interactive =
1528
+ deps.interactive ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
1529
+ if (status() === "not-installed") {
1530
+ return { ok: true, lines: ["No failproofaid service on this machine; nothing to update."] };
1531
+ }
1532
+
1533
+ // `installDaemonService()`, not a binary fetch plus a restart.
1534
+ //
1535
+ // The first version of this did the latter, and it did not work: it called
1536
+ // `ensureFailproofaidBinary()` (which correctly lands
1537
+ // `bin/failproofaid-<this version>`) and then `ensureDaemonServiceCurrent()`,
1538
+ // whose rewrite goes through `upgradedServiceDefinition` — and that PRESERVES
1539
+ // the existing `ExecStart` via `installedExecStart(definition)`, by design,
1540
+ // because its job is to upgrade the unit's SHAPE without changing which binary
1541
+ // runs. So on a machine coming from an older release the new binary was
1542
+ // downloaded, the unit was rewritten, the service was restarted, and the OLD
1543
+ // binary came back up — under a message that said the daemon had been
1544
+ // refreshed. Worse than not having the command, because it reports success.
1545
+ //
1546
+ // `installDaemonService()` is the function that already knows how to make the
1547
+ // service run THIS CLI's version: it resolves the binary for this version,
1548
+ // writes the unit around that path, removes any legacy user unit, reloads,
1549
+ // enables, and waits for the service to reach AND HOLD a running state rather
1550
+ // than trusting the moment a `Type=simple` unit reports active. It is
1551
+ // idempotent, which is what makes it safe to call on an already-installed
1552
+ // machine.
1553
+ // ASK FOR THE PASSWORD, when there is somebody to ask.
1554
+ //
1555
+ // Writing the unit needs root, and `runPrivileged` uses `sudo -n` — which never
1556
+ // prompts. That rule exists for the WIZARD, whose reason is stated where it is
1557
+ // enforced: a password prompt fired from underneath a full-screen TUI is
1558
+ // unreadable at best. It does not transfer to this command, which is plain line
1559
+ // output with nothing to corrupt, and the wizard itself calls `primeElevation()`
1560
+ // for exactly this at exactly this point.
1561
+ //
1562
+ // Without it, `failproofai update` on the machine it exists for — one with a
1563
+ // daemon, upgrading — failed with "sudo credentials were not available" and a
1564
+ // thirty-line unit file to paste by hand. The only working alternatives were
1565
+ // `sudo -v` first (undocumented) or `failproofai config`, an interactive wizard.
1566
+ // That is not an upgrade path.
1567
+ //
1568
+ // Gated on a TTY, not attempted blindly: on a CI runner or a fleet box there is
1569
+ // nobody to type a password, and `sudo -v` there would block on a prompt nothing
1570
+ // will answer. Those runs still fall through to `sudo -n`, fail, and get the
1571
+ // exact commands to run — which is the right outcome for an unattended machine.
1572
+ // `primeElevation()` is itself a no-op when already root or NOPASSWD.
1573
+ if (interactive()) prime();
1574
+
1575
+ const result = await install();
1576
+ if (!result.installed) {
1577
+ return {
1578
+ ok: false,
1579
+ lines: [
1580
+ `failproofaid ${version} could not be installed: ${result.reason ?? "unknown reason"}`,
1581
+ `The previous daemon is untouched. On a machine configured to require it,`,
1582
+ `enforcement continues; collection and cloud policy may be stale until this`,
1583
+ `succeeds. \`${daemonStatusCommand() ?? "systemctl status"}\` will say more.`,
1584
+ ],
1585
+ };
1586
+ }
1587
+ // RECORD THE VERSION, or the refresh is invisible to everything that asks.
1588
+ //
1589
+ // `installDaemonService()` deliberately does not write it — only the wizard did
1590
+ // (`setDaemonConfigured(true, cliVersion)`), because that is where "this machine
1591
+ // is now configured, at this version" is decided. So without this line the new
1592
+ // binary runs while `VERSION.daemon` still names the old one, and
1593
+ // `daemonVersionSkew()` reads that file: every later CLI command keeps nudging
1594
+ // about a stale daemon that was just replaced, and the wizard's `daemonMaybeUp`
1595
+ // stays false on the skew it should no longer see — so a later `failproofai
1596
+ // config` would tear down and rebuild a perfectly current service.
1597
+ //
1598
+ // `writeVersionFile` rather than `setDaemonConfigured(true, …)`: that helper also
1599
+ // sets `daemon.configured`, and turning on fail-closed enforcement as a SIDE
1600
+ // EFFECT of an update is not this command's decision to make. An update refreshes
1601
+ // what is installed; it does not change whether the machine requires it.
1602
+ writeVersionFile({ daemon: version });
1603
+ return {
1604
+ ok: true,
1605
+ lines: [`failproofaid ${version} installed, service restarted and holding.`],
1606
+ };
1607
+ }
1608
+
654
1609
  export async function uninstallDaemonService(): Promise<void> {
655
1610
  setDaemonConfigured(false);
656
1611
  if (!isDaemonSupportedPlatform()) return;
@@ -700,6 +1655,89 @@ export async function uninstallDaemonService(): Promise<void> {
700
1655
  * file exists — a unit can be installed but crash-looped into a stopped
701
1656
  * state.
702
1657
  */
1658
+ /**
1659
+ * Whether the installed daemon is a different version than this CLI expects.
1660
+ *
1661
+ * The expected version is this CLI's OWN — no lookup, no registry call, no
1662
+ * network. `package.json` and `Cargo.toml` are held identical by CI, and every
1663
+ * downstream path (the download URL, the binary filename, the npm pin) is
1664
+ * derived from that one number, so a CLI can never want a daemon version that
1665
+ * was not published alongside it.
1666
+ *
1667
+ * Returns null when the question does not apply, and the exclusions are the
1668
+ * point:
1669
+ *
1670
+ * • FAILPROOFAI_DAEMON_BINARY — someone named a binary explicitly; its
1671
+ * version is their business, not ours to second-guess.
1672
+ * • a locally-built target/{release,debug} binary — the developer's own
1673
+ * build. Reporting it "stale" on every command would be noise about the
1674
+ * exact setup this repo tells contributors to use.
1675
+ *
1676
+ * Only the managed `~/.failproofai/bin/failproofaid-<version>` path is
1677
+ * checked, because it is the only one whose version we actually own.
1678
+ */
1679
+ export function daemonVersionSkew(): { installed: string; expected: string } | null {
1680
+ if (process.env.FAILPROOFAI_DAEMON_BINARY) return null;
1681
+ const recorded = readVersionFile()?.daemon;
1682
+ if (!recorded) return null;
1683
+ // Managed installs only — see the exclusions above.
1684
+ if (!existsSync(installedBinaryPath(recorded))) return null;
1685
+ return recorded === version ? null : { installed: recorded, expected: version };
1686
+ }
1687
+
1688
+ /**
1689
+ * Why a Linux unit that exists is not active: skipped on a condition, or
1690
+ * merely stopped.
1691
+ *
1692
+ * Only reached once `is-active` has already said "not active", so it costs a
1693
+ * second `systemctl` call on the unhealthy path and nothing at all on the
1694
+ * healthy one.
1695
+ *
1696
+ * `ConditionResult` is systemd's own record of the last condition evaluation
1697
+ * and is `no` only after it actually ran them and one failed. A unit that has
1698
+ * never been started since boot reports `yes` (the field's default), so this
1699
+ * cannot invent a `condition-failed` for a unit systemd has not judged — it
1700
+ * under-reports rather than over-reports, which is the safe direction for a
1701
+ * signal that clears `daemonConfigured`.
1702
+ */
1703
+ function inactiveLinuxStatus(): DaemonServiceStatus {
1704
+ try {
1705
+ return interpretConditionResult(
1706
+ execFileSync(
1707
+ "systemctl",
1708
+ ["show", systemdUnitName(), "--property=ConditionResult", "--value"],
1709
+ { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
1710
+ ).toString(),
1711
+ );
1712
+ } catch {
1713
+ // No systemd, no systemctl, or a version without `--value`. The unit file
1714
+ // exists, so "stopped" remains the honest answer — and it is the
1715
+ // conservative one, because nothing acts destructively on it.
1716
+ return "stopped";
1717
+ }
1718
+ }
1719
+
1720
+ /**
1721
+ * `systemctl show --property=ConditionResult --value` → a status.
1722
+ *
1723
+ * Split out from the subprocess so the interpretation can be tested without
1724
+ * `/etc/systemd/system`, which this code reads at a fixed path and no test may
1725
+ * write. The live behaviour — that systemd actually skips a unit whose
1726
+ * `ConditionPathExists=` fails, and records `no` when it does — is proven
1727
+ * against a real systemd in the container test rather than asserted here.
1728
+ *
1729
+ * Only a literal `no` means condition-failed. Anything else — `yes`, an empty
1730
+ * string from a unit systemd has not evaluated since boot, an unfamiliar word
1731
+ * from a future version — is "stopped", which is the state nothing acts
1732
+ * destructively on. This under-reports rather than over-reports on purpose: the
1733
+ * cost of a missed `condition-failed` is a flag cleared one command later, and
1734
+ * the cost of a false one is a healthy machine silently dropped to the
1735
+ * in-process path.
1736
+ */
1737
+ export function interpretConditionResult(raw: string): DaemonServiceStatus {
1738
+ return raw.trim() === "no" ? "condition-failed" : "stopped";
1739
+ }
1740
+
703
1741
  export function daemonServiceStatus(): DaemonServiceStatus {
704
1742
  if (!isDaemonSupportedPlatform()) return "unsupported-platform";
705
1743
 
@@ -714,14 +1752,14 @@ export function daemonServiceStatus(): DaemonServiceStatus {
714
1752
  })
715
1753
  .toString()
716
1754
  .trim();
717
- return out === "active" ? "running" : "stopped";
1755
+ return out === "active" ? "running" : inactiveLinuxStatus();
718
1756
  } catch {
719
1757
  // `systemctl is-active` exits non-zero (and execFileSync throws) for
720
1758
  // every non-"active" state — inactive, failed, or the command not
721
- // working at all (no systemd user session, systemctl missing). All
722
- // of those are indistinguishable from "stopped" from here, and the
723
- // unit file existing is what already ruled out "not-installed".
724
- return "stopped";
1759
+ // working at all (no systemd user session, systemctl missing). The unit
1760
+ // file existing already ruled out "not-installed", so the only question
1761
+ // left is whether systemd skipped it on a condition.
1762
+ return inactiveLinuxStatus();
725
1763
  }
726
1764
  }
727
1765
 
@@ -732,6 +1770,13 @@ export function daemonServiceStatus(): DaemonServiceStatus {
732
1770
  // `launchctl list` cannot see — unlike systemd, reading the state needs
733
1771
  // the same elevation installing it did.
734
1772
  const root = typeof process.getuid === "function" && process.getuid() === 0;
1773
+ // Whether we can elevate AT ALL is checked first, because it decides what a
1774
+ // failure below means. Without it, `sudo -n` refusing for want of a cached
1775
+ // credential was indistinguishable from launchctl reporting the job absent,
1776
+ // and both became "stopped" — so a five-minute-old sudo timestamp made a
1777
+ // running daemon read as dead, and the wizard then demanded a password to
1778
+ // unload and reload a service that was fine.
1779
+ if (!root && !canElevate()) return "unknown";
735
1780
  const args = ["print", `system/${launchdLabel()}`];
736
1781
  const out = root
737
1782
  ? execFileSync("launchctl", args, {
@@ -746,6 +1791,9 @@ export function daemonServiceStatus(): DaemonServiceStatus {
746
1791
  // prints `state = not running` and must not read as healthy.
747
1792
  return /state\s*=\s*running/.test(out) ? "running" : "stopped";
748
1793
  } catch {
1794
+ // We could elevate a moment ago, so this is launchctl's own answer: the
1795
+ // label is not loaded in the system domain. The plist exists (checked
1796
+ // above), so that is genuinely "installed but not running".
749
1797
  return "stopped";
750
1798
  }
751
1799
  }