failproofai 1.0.0-beta.1 → 1.0.0-beta.10

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 (357) 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 +2 -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]__06hexd0._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g0vcwt._.js +3 -0
  106. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ky_n0r._.js} +2 -2
  107. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0lk_935._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__0pbp9fe._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__10oa-v9._.js → [root-of-the-server]__0v2fpxe._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +3 -3
  112. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__1_ps0jw._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__1a7i2q4._.js} +2 -2
  114. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1iny24d._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tyedjt._.js +3 -0
  118. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +5 -0
  119. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +5 -0
  120. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +5 -0
  121. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
  122. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  123. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  124. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  126. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04pwh4t._.js +3 -0
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06bp_fg._.js +5 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__074qmkp._.js +3 -0
  129. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  130. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__10butu6._.js +3 -0
  131. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1a2hcb_._.js +3 -0
  132. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  133. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__1rdruw4._.js} +2 -2
  134. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +3 -0
  135. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tyf0tc._.js +5 -0
  136. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
  137. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +3 -0
  138. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1yw368c._.js} +2 -2
  139. package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
  140. package/.next/standalone/.next/server/chunks/ssr/_04mekdy._.js +3 -0
  141. package/.next/standalone/.next/server/chunks/ssr/_0r-dsre._.js +3 -0
  142. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
  143. package/.next/standalone/.next/server/chunks/ssr/_0se484m._.js +3 -0
  144. package/.next/standalone/.next/server/chunks/ssr/_0uz7o9y._.js +3 -0
  145. package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  147. package/.next/standalone/.next/server/chunks/ssr/_1fahg9e._.js +3 -0
  148. package/.next/standalone/.next/server/chunks/ssr/_1gmzkwj._.js +5 -0
  149. package/.next/standalone/.next/server/chunks/ssr/_1w8sola._.js +3 -0
  150. package/.next/standalone/.next/server/chunks/ssr/_1w_6p9g._.js +3 -0
  151. package/.next/standalone/.next/server/chunks/ssr/_1y4qr7u._.js +3 -0
  152. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  153. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  154. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  155. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dwo94s.js +4 -0
  156. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  157. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +3 -0
  158. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +5 -0
  159. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  160. package/.next/standalone/.next/server/middleware.js +2 -2
  161. package/.next/standalone/.next/server/pages/404.html +1 -1
  162. package/.next/standalone/.next/server/pages/500.html +1 -1
  163. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  164. package/.next/standalone/.next/server/server-reference-manifest.json +74 -20
  165. package/.next/standalone/.next/static/chunks/00adubtgov21s.js +1 -0
  166. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  167. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  168. package/.next/standalone/.next/static/chunks/{1b3a1scouf40o.js → 0yudogeqddpzr.js} +1 -1
  169. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  170. package/.next/standalone/.next/static/chunks/1bm0q12hgs22l.css +2 -0
  171. package/.next/standalone/.next/static/chunks/{24gs8ip6_spcb.js → 1fu90ch80qngv.js} +1 -1
  172. package/.next/standalone/.next/static/chunks/{2ovmv4esqalpb.js → 1m416lie3qwsj.js} +1 -1
  173. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
  174. package/.next/standalone/.next/static/chunks/1ypqzvoal8j56.js +6 -0
  175. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  176. package/.next/standalone/.next/static/chunks/{191njgoik03fm.js → 2h4sbmp0ad78w.js} +1 -1
  177. package/.next/standalone/.next/static/chunks/2obwg4tterjpa.js +1 -0
  178. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
  179. package/.next/standalone/.next/static/chunks/{35584kn409ad1.js → 30ufy4_e2n78e.js} +22 -22
  180. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  181. package/.next/standalone/.next/static/chunks/{07ix3lb4gib0x.js → 3p4j0l1o_p-nm.js} +1 -1
  182. package/.next/standalone/.next/static/chunks/{30is-6r00sbd7.js → 3uxnxwxcctyj0.js} +1 -1
  183. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  184. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  185. package/.next/standalone/app/actions/get-hooks-config.ts +2 -1
  186. package/.next/standalone/app/actions/get-scheduled-audit.ts +70 -0
  187. package/.next/standalone/app/actions/update-scheduled-audit.ts +44 -0
  188. package/.next/standalone/app/api/audit/run/route.ts +28 -0
  189. package/.next/standalone/app/api/audit/status/route.ts +13 -1
  190. package/.next/standalone/app/api/auth/status/route.ts +1 -1
  191. package/.next/standalone/app/audit/_components/auth-dialog.css +159 -0
  192. package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
  193. package/.next/standalone/app/audit/audit-styles.css +6 -149
  194. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  195. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  196. package/.next/standalone/app/settings/page.tsx +31 -0
  197. package/.next/standalone/app/settings/settings-client.tsx +488 -0
  198. package/.next/standalone/components/navbar.tsx +2 -0
  199. package/.next/standalone/lib/auth/api-server-client.ts +2 -3
  200. package/.next/standalone/lib/auth/auth-store.ts +4 -4
  201. package/.next/standalone/lib/codex-sessions.ts +2 -1
  202. package/.next/standalone/lib/dashboard-host.ts +101 -0
  203. package/.next/standalone/lib/install-check.ts +22 -7
  204. package/.next/standalone/lib/pi-sessions.ts +99 -14
  205. package/.next/standalone/lib/telemetry-enabled.ts +54 -0
  206. package/.next/standalone/lib/telemetry-id.ts +52 -1
  207. package/.next/standalone/lib/telemetry.ts +8 -4
  208. package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
  209. package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
  210. package/.next/standalone/node_modules/react/package.json +3 -3
  211. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
  212. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
  213. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
  214. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
  215. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
  216. package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
  217. package/.next/standalone/node_modules/react-dom/package.json +4 -4
  218. package/.next/standalone/package.json +17 -10
  219. package/.next/standalone/proxy.ts +89 -1
  220. package/.next/standalone/server.js +1 -1
  221. package/bin/failproofai.mjs +518 -79
  222. package/dist/cli.mjs +19128 -13790
  223. package/dist/worker.mjs +2019 -272
  224. package/lib/auth/api-server-client.ts +2 -3
  225. package/lib/auth/auth-store.ts +4 -4
  226. package/lib/codex-sessions.ts +2 -1
  227. package/lib/dashboard-host.ts +101 -0
  228. package/lib/install-check.ts +22 -7
  229. package/lib/pi-sessions.ts +99 -14
  230. package/lib/telemetry-enabled.ts +54 -0
  231. package/lib/telemetry-id.ts +52 -1
  232. package/lib/telemetry.ts +8 -4
  233. package/package.json +17 -10
  234. package/scripts/build-daemon-packages.mjs +210 -0
  235. package/scripts/daemon-platforms.mjs +42 -0
  236. package/scripts/install-telemetry.mjs +8 -1
  237. package/scripts/launch.ts +25 -3
  238. package/scripts/parse-script-args.ts +17 -1
  239. package/scripts/prune-standalone.mjs +26 -0
  240. package/scripts/publish-aliases.mjs +62 -41
  241. package/scripts/repro-npm-install.sh +155 -0
  242. package/scripts/translate-docs/readme-translator.ts +163 -4
  243. package/scripts/validate-mdx.ts +153 -13
  244. package/src/audit/audit-lock.ts +285 -0
  245. package/src/audit/audit-schedule.ts +111 -0
  246. package/src/audit/cache.ts +18 -4
  247. package/src/audit/cli-adapters/goose.ts +2 -1
  248. package/src/audit/cli-adapters/hermes.ts +28 -10
  249. package/src/audit/cli.ts +191 -35
  250. package/src/audit/dashboard-cache.ts +2 -1
  251. package/src/hooks/backfill-cli.ts +247 -0
  252. package/src/hooks/builtin-policies.ts +390 -2
  253. package/src/hooks/cloud-connection.ts +354 -0
  254. package/src/hooks/cloud-enrollment-cli.ts +325 -0
  255. package/src/hooks/cloud-enrollment.ts +243 -0
  256. package/src/hooks/cloud-introspect.ts +147 -0
  257. package/src/hooks/cloud-managed-policies.ts +169 -0
  258. package/src/hooks/collector-config.ts +234 -0
  259. package/src/hooks/configure-wizard.ts +911 -158
  260. package/src/hooks/custom-hooks-loader.ts +191 -33
  261. package/src/hooks/daemon-client.ts +136 -17
  262. package/src/hooks/daemon-download.ts +192 -30
  263. package/src/hooks/daemon-service.ts +964 -67
  264. package/src/hooks/first-run-gate.ts +50 -0
  265. package/src/hooks/fp-config.ts +497 -0
  266. package/src/hooks/fp-home.ts +375 -0
  267. package/src/hooks/fp-reset.ts +464 -0
  268. package/src/hooks/handler.ts +185 -6
  269. package/src/hooks/hook-activity-store.ts +85 -20
  270. package/src/hooks/hook-logger.ts +2 -1
  271. package/src/hooks/hook-telemetry.ts +5 -1
  272. package/src/hooks/hooks-config.ts +4 -3
  273. package/src/hooks/loader-utils.ts +171 -8
  274. package/src/hooks/manager.ts +5 -4
  275. package/src/hooks/onboarding-attempt.ts +179 -0
  276. package/src/hooks/onboarding-lock.ts +119 -0
  277. package/src/hooks/policy-types.ts +28 -0
  278. package/src/hooks/session-pause-cli.ts +159 -0
  279. package/src/hooks/session-pause.ts +268 -0
  280. package/src/hooks/setup-state.ts +256 -0
  281. package/src/hooks/tui.ts +119 -4
  282. package/src/hooks/uninstall-cli.ts +345 -0
  283. package/src/hooks/worker-server.ts +65 -3
  284. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  285. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  286. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  287. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0longh-._.js +0 -3
  288. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +0 -3
  289. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +0 -3
  290. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +0 -3
  291. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +0 -3
  292. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +0 -3
  293. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  294. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +0 -4
  295. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
  296. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
  297. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
  298. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
  299. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
  300. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
  301. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
  302. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
  303. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ig795d._.js +0 -3
  304. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1yo1gyz._.js +0 -3
  305. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +0 -4
  306. package/.next/standalone/.next/server/chunks/ssr/_1m_p7pi._.js +0 -3
  307. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_14q3-7b.js +0 -3
  308. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +0 -5
  309. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +0 -5
  310. package/.next/standalone/.next/static/chunks/0fdx7ikab08-g.js +0 -6
  311. package/.next/standalone/.next/static/chunks/0h7auy7hzjyhw.js +0 -1
  312. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  313. package/.next/standalone/.next/static/chunks/1d71o1_tav721.js +0 -1
  314. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  315. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  316. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  317. package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
  318. package/.next/standalone/Cargo.lock +0 -428
  319. package/.next/standalone/Cargo.toml +0 -9
  320. package/.next/standalone/crates/PROTOCOL.md +0 -122
  321. package/.next/standalone/crates/failproofaid/Cargo.toml +0 -18
  322. package/.next/standalone/crates/failproofaid/src/lock.rs +0 -81
  323. package/.next/standalone/crates/failproofaid/src/main.rs +0 -83
  324. package/.next/standalone/crates/failproofaid/src/paths.rs +0 -172
  325. package/.next/standalone/crates/failproofaid/src/server.rs +0 -502
  326. package/.next/standalone/crates/failproofaid/src/worker.rs +0 -400
  327. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +0 -241
  328. package/.next/standalone/crates/fpai-ipc/Cargo.toml +0 -16
  329. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +0 -175
  330. package/.next/standalone/crates/fpai-ipc/src/framing.rs +0 -177
  331. package/.next/standalone/crates/fpai-ipc/src/lib.rs +0 -10
  332. package/.next/standalone/crates/fpai-ipc/src/peer.rs +0 -80
  333. package/.next/standalone/docker-hook-sync/Dockerfile +0 -79
  334. package/.next/standalone/docker-hook-sync/entrypoint.sh +0 -109
  335. package/.next/standalone/integration-suite/Dockerfile +0 -47
  336. package/.next/standalone/integration-suite/README.md +0 -129
  337. package/.next/standalone/integration-suite/canary-policies.mjs +0 -45
  338. package/.next/standalone/integration-suite/capture-tokens.sh +0 -48
  339. package/.next/standalone/integration-suite/ci-entrypoint.sh +0 -179
  340. package/.next/standalone/integration-suite/inject-tokens.sh +0 -44
  341. package/.next/standalone/integration-suite/install-clis.sh +0 -136
  342. package/.next/standalone/integration-suite/probe-cli.sh +0 -248
  343. package/.next/standalone/integration-suite/report.js +0 -120
  344. package/.next/standalone/integration-suite/run.sh +0 -159
  345. package/.next/standalone/openclaw-plugin/index.js +0 -223
  346. package/.next/standalone/openclaw-plugin/openclaw.plugin.json +0 -6
  347. package/.next/standalone/openclaw-plugin/package.json +0 -12
  348. package/.next/standalone/osv-scanner.toml +0 -17
  349. package/.next/standalone/pi-extension/index.ts +0 -524
  350. package/.next/standalone/pi-extension/package.json +0 -12
  351. package/.next/standalone/postcss.config.mjs +0 -8
  352. package/.next/standalone/readme-arch-hq.gif +0 -0
  353. package/.next/standalone/rust-toolchain.toml +0 -4
  354. package/src/auth/cli.ts +0 -371
  355. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pBBcTvE6_VueYGbacy1kg}/_buildManifest.js +0 -0
  356. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pBBcTvE6_VueYGbacy1kg}/_clientMiddlewareManifest.js +0 -0
  357. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pBBcTvE6_VueYGbacy1kg}/_ssgManifest.js +0 -0
@@ -8,13 +8,24 @@
8
8
  * `configure-wizard.ts` calls the functions here directly, the same
9
9
  * relationship it already has with `manager.ts`'s `installHooks()`.
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ mkdtempSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ unlinkSync,
18
+ rmSync,
19
+ } from "node:fs";
12
20
  import { homedir, tmpdir, userInfo } from "node:os";
13
21
  import { resolve, dirname } from "node:path";
14
22
  import { execFileSync } from "node:child_process";
15
23
  import { hookLogWarn } from "./hook-logger";
16
24
  import { getConfigPathForScope } from "./hooks-config";
17
- import { downloadFailproofaidBinary, installedBinaryPath } from "./daemon-download";
25
+ import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download";
26
+ import { logsDir } from "./fp-home";
27
+ import { version } from "../../package.json";
28
+ import { readVersionFile, updateConfig, writeVersionFile } from "./fp-config";
18
29
 
19
30
  /**
20
31
  * Every `systemctl --user` / `launchctl` call is bounded. Both talk to a
@@ -45,6 +56,14 @@ const SERVICE_START_POLL_MS = 100;
45
56
  */
46
57
  const SERVICE_SETTLE_MS = 750;
47
58
 
59
+ /**
60
+ * How long `sudo -v` may sit at its password prompt. Unlike every other
61
+ * timeout here, this one is waiting on a human typing, not on a service
62
+ * manager — three attempts at a password is well inside two minutes, and
63
+ * sudo gives up on its own long before that.
64
+ */
65
+ const SUDO_PROMPT_TIMEOUT_MS = 120_000;
66
+
48
67
  /**
49
68
  * Writes (or clears) the machine-wide `daemonConfigured` marker in
50
69
  * `~/.failproofai/policies-config.json`.
@@ -60,25 +79,40 @@ const SERVICE_SETTLE_MS = 750;
60
79
  * Global scope only: whether *this machine* runs a daemon is not a
61
80
  * per-project setting.
62
81
  */
63
- export function setDaemonConfigured(value: boolean): void {
64
- const path = getConfigPathForScope("user");
65
- let config: Record<string, unknown> = {};
66
- try {
67
- if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
68
- } catch {
69
- return; // a malformed global config is the install path's problem, not ours
70
- }
71
- if (value) config.daemonConfigured = true;
72
- else delete config.daemonConfigured;
82
+ export function setDaemonConfigured(value: boolean, installedVersion?: string): void {
73
83
  try {
74
- mkdirSync(dirname(path), { recursive: true });
75
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
84
+ updateConfig({ daemon: { configured: value } });
85
+ // The version lives in VERSION, not here. One file is ABOUT versions and
86
+ // one copy cannot disagree with itself; keeping a second in config.toml
87
+ // would be a field that drifts out of step with the file beside it —
88
+ // exactly the class of bug the single path module exists to prevent.
89
+ // Cleared on uninstall: a recorded binary version for a service that is no
90
+ // longer configured is a claim about this machine that is no longer true.
91
+ writeVersionFile({ daemon: value ? installedVersion : undefined, clearDaemon: !value });
76
92
  } catch {
77
93
  /* best-effort: never fail a completed setup (or uninstall) over this flag */
78
94
  }
79
95
  }
80
96
 
81
- export type DaemonServiceStatus = "running" | "stopped" | "not-installed" | "unsupported-platform";
97
+ export type DaemonServiceStatus =
98
+ | "running"
99
+ | "stopped"
100
+ /**
101
+ * Installed, but systemd evaluated its `ConditionPathExists=` and refused to
102
+ * start it — the daemon binary or the worker script is gone.
103
+ *
104
+ * Deliberately its own state rather than folded into "stopped". "Stopped" is
105
+ * ambiguous on purpose (a restart in flight looks identical), which is why
106
+ * nothing is allowed to act destructively on it. A failed condition carries
107
+ * no such ambiguity: systemd has already decided this unit will not run, and
108
+ * will keep deciding that at every boot until the missing path returns. That
109
+ * is provable enough to clear `daemonConfigured` on, and leaving it
110
+ * indistinguishable from a transient stop is what kept a machine denying
111
+ * every tool call with no explanation.
112
+ */
113
+ | "condition-failed"
114
+ | "not-installed"
115
+ | "unsupported-platform";
82
116
 
83
117
  /** Linux + macOS only, per the plan's platform scope — full stop. */
84
118
  export function isDaemonSupportedPlatform(): boolean {
@@ -129,13 +163,19 @@ export function resolveFailproofaidBinaryPath(): string | null {
129
163
  }
130
164
 
131
165
  /**
132
- * Resolves the binary, downloading it from this version's release if it is
133
- * not already on disk.
166
+ * Resolves the binary, installing one if it is not already on disk.
134
167
  *
135
- * Only the install path calls this. The npm package ships no binary — it is
136
- * one CLI tarball for every platform — so `failproofai config` choosing the
137
- * global scope is the moment a machine that opted into a daemon actually
138
- * acquires one.
168
+ * Only the install path calls this — `failproofai config` choosing the global
169
+ * scope is the moment a machine that opted into a daemon actually acquires
170
+ * one. Two channels, in this order:
171
+ *
172
+ * 1. The `@failproofai/failproofaid-<os>-<arch>` platform package, which a
173
+ * plain `npm install failproofai` already brought down as an optional
174
+ * dependency. No network, so it is also the only channel that works
175
+ * air-gapped or behind a proxy that blocks github.com.
176
+ * 2. The GitHub Release asset for this exact version, checksum-verified.
177
+ * Covers installs that skipped optional dependencies, older tarballs, and
178
+ * anyone installing the daemon standalone.
139
179
  */
140
180
  export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> {
141
181
  const existing = resolveFailproofaidBinaryPath();
@@ -146,9 +186,18 @@ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reaso
146
186
  return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` };
147
187
  }
148
188
 
189
+ const fromNpm = installFromNpmPackage(key);
190
+ if (fromNpm.path) return { path: fromNpm.path };
191
+
149
192
  const result = await downloadFailproofaidBinary(key);
150
193
  if (result.path) return { path: result.path };
151
- return { reason: result.error ?? "failproofaid binary could not be downloaded" };
194
+ // Both channels are worth reporting: "not installed" alone reads as a broken
195
+ // package, and the download error alone hides that npm could have supplied it.
196
+ return {
197
+ reason: result.error
198
+ ? `${result.error} (${fromNpm.error})`
199
+ : "failproofaid binary could not be installed",
200
+ };
152
201
  }
153
202
 
154
203
  /**
@@ -175,6 +224,14 @@ export function resolveWorkerCommand(): string | null {
175
224
  if (!packageRoot) return null;
176
225
  const workerScript = resolve(packageRoot, "dist", "worker.mjs");
177
226
  if (!existsSync(workerScript)) return null;
227
+ // Both paths are shell-quoted: the daemon runs this value through `sh -c`
228
+ // (`WorkerCommand::Shell` in crates/failproofaid/src/worker.rs), so an
229
+ // unquoted path splits on its spaces and the worker never starts. Not
230
+ // hypothetical on macOS, where `/Users/First Last/...` is ordinary — and
231
+ // more exposed since execPath replaced a bare `node`, because execPath is
232
+ // home-derived. systemd's own `Environment="..."` quoting does not help
233
+ // here; that protects the unit parse, not the later shell split.
234
+ //
178
235
  // `process.execPath`, not a bare `node`. A system-scope service does not
179
236
  // inherit a login environment, so its PATH is the system default — and the
180
237
  // single most common way to install Node is nvm, which puts it under
@@ -183,7 +240,117 @@ export function resolveWorkerCommand(): string | null {
183
240
  // silently, on exactly the machines least likely to notice. execPath is
184
241
  // whatever runtime is executing this CLI right now (node for the published
185
242
  // bin, bun in a source checkout), absolute either way.
186
- return `${process.execPath} ${workerScript}`;
243
+ return `${shellQuote(process.execPath)} ${shellQuote(workerScript)}`;
244
+ }
245
+
246
+ /**
247
+ * Resolves the command the daemon should use to run a one-shot `failproofai`
248
+ * CLI task — today the scheduled audit — passed through as
249
+ * `FAILPROOFAI_CLI_CMD` in the service's own environment.
250
+ *
251
+ * Everything `resolveWorkerCommand` says about `process.execPath` and the
252
+ * shell quoting applies here verbatim, for the same reason: a system-scope
253
+ * unit gets no login environment, so its PATH is the system default, and the
254
+ * single most common Node install (nvm) is on no system PATH. The difference
255
+ * is the consequence of getting it wrong. A worker that cannot start makes the
256
+ * daemon visibly unhealthy; an audit that cannot start is SILENT — the config
257
+ * says the scan is on, nothing ever runs, and the only symptom is a dashboard
258
+ * that quietly stops moving.
259
+ *
260
+ * `dist/cli.mjs` (package.json's `bin.failproofai`), not `bin/failproofai.mjs`
261
+ * — the latter has a `#!/usr/bin/env bun` shebang and uses syntax node cannot
262
+ * load (a bare `import … from "../package.json"`, extensionless `.ts`
263
+ * specifiers). Only the bundle is node-runnable, and it sits beside the
264
+ * `dist/worker.mjs` the worker command already points at.
265
+ */
266
+ export function resolveCliCommand(): string | null {
267
+ if (process.env.FAILPROOFAI_CLI_CMD) return process.env.FAILPROOFAI_CLI_CMD;
268
+
269
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
270
+ if (!packageRoot) return null;
271
+ const cliScript = resolve(packageRoot, "dist", "cli.mjs");
272
+ if (!existsSync(cliScript)) return null;
273
+ return `${shellQuote(process.execPath)} ${shellQuote(cliScript)}`;
274
+ }
275
+
276
+ /** POSIX single-quoting: everything is literal inside '…', and a literal
277
+ * quote is closed, escaped and reopened. */
278
+ function shellQuote(value: string): string {
279
+ return `'${value.replace(/'/g, `'\\''`)}'`;
280
+ }
281
+
282
+ /**
283
+ * The raw, unquoted path to the worker script the service will run — the same
284
+ * file `resolveWorkerCommand` builds its shell command around.
285
+ *
286
+ * It exists separately because the unit needs the path as a PATH (for
287
+ * `ConditionPathExists=`), not as a shell word, and recovering one from the
288
+ * other means unparsing POSIX quoting for a value we already had.
289
+ *
290
+ * Returns null when `FAILPROOFAI_WORKER_CMD` is set: that value is an arbitrary
291
+ * shell command — a wrapper script, an interpreter with flags, `exec`ing
292
+ * something else entirely — and guessing which token in it is "the file that
293
+ * must exist" would gate the service on a path nobody promised. An absent
294
+ * condition is the correct answer to a question we cannot answer.
295
+ */
296
+ export function workerScriptPath(): string | null {
297
+ if (process.env.FAILPROOFAI_WORKER_CMD) return null;
298
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
299
+ if (!packageRoot) return null;
300
+ const workerScript = resolve(packageRoot, "dist", "worker.mjs");
301
+ return existsSync(workerScript) ? workerScript : null;
302
+ }
303
+
304
+ /**
305
+ * The environment the service definition carries, built once so the systemd
306
+ * and launchd renderers cannot drift apart — a variable added to one and not
307
+ * the other is invisible until somebody runs the other platform.
308
+ *
309
+ * Order is fixed rather than incidental: `upgradedServiceDefinition` rebuilds
310
+ * a definition from these renderers, so a stable order is what makes a
311
+ * rewritten unit byte-identical to a freshly installed one rather than merely
312
+ * equivalent — which is the difference between a diff a human can read and one
313
+ * they stop reading.
314
+ */
315
+ /**
316
+ * Characters that cannot appear in a value interpolated into a systemd unit.
317
+ *
318
+ * A newline ENDS THE DIRECTIVE, so a path containing one injects arbitrary
319
+ * settings into a file that is installed root-owned at `/etc/systemd/system`
320
+ * and loaded at every boot. A double quote closes `Environment="..."` early and
321
+ * does the same; a backslash is systemd's own escape character inside a quoted
322
+ * value and makes the result unpredictable rather than merely wrong.
323
+ *
324
+ * The mechanism is demonstrated by this repo's own test, which sets
325
+ * `FAILPROOFAI_CLI_CMD` to `/usr/bin/true"\nUser=failproofai-no-such-user` and
326
+ * relies on systemd HONOURING the injected `User=` — it passes only because the
327
+ * injected user does not exist. A valid one (`User=root`, or an added
328
+ * `ExecStartPre=`) would have succeeded silently, undoing the
329
+ * "root-installed but never root-run" invariant the whole design rests on.
330
+ *
331
+ * These values are resolved paths and commands; none can legitimately contain
332
+ * any of these characters. So this REJECTS rather than escaping — a unit that
333
+ * refuses to be written is a clear failure, and inventing an escaping scheme
334
+ * for systemd's grammar is a larger surface than the problem.
335
+ */
336
+ const UNIT_UNSAFE_RE = /["\\\r\n]/;
337
+
338
+ /** Throws when a value cannot be safely interpolated into a unit file. */
339
+ function assertUnitSafe(value: string, field: string): string {
340
+ if (UNIT_UNSAFE_RE.test(value)) {
341
+ throw new Error(
342
+ `refusing to write a service definition: ${field} contains a quote, backslash or newline ` +
343
+ `(${JSON.stringify(value)}), which would inject directives into a root-owned unit file`,
344
+ );
345
+ }
346
+ return value;
347
+ }
348
+
349
+ function serviceEnvironment(workerCmd: string | null, cliCmd: string | null): [string, string][] {
350
+ const entries: [string, string][] = [];
351
+ if (workerCmd) entries.push(["FAILPROOFAI_WORKER_CMD", workerCmd]);
352
+ if (cliCmd) entries.push(["FAILPROOFAI_CLI_CMD", cliCmd]);
353
+ return entries;
187
354
  }
188
355
 
189
356
  /**
@@ -224,15 +391,33 @@ function legacySystemdUserUnitPath(): string {
224
391
  return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service");
225
392
  }
226
393
 
227
- const LAUNCHD_LABEL = "ai.failproof.failproofaid";
394
+ /**
395
+ * Per user, for the same reason the systemd unit is: a LaunchDaemon's
396
+ * contents are entirely user-specific — `UserName`, the ExecStart path under
397
+ * that user's `~/.failproofai/bin`, the log paths under that user's home — so
398
+ * one shared label means the second user's install silently overwrites the
399
+ * first's daemon, and their uninstall deletes it.
400
+ */
401
+ function launchdLabel(user: string = serviceUser()): string {
402
+ return `ai.failproof.failproofaid.${user}`;
403
+ }
228
404
 
229
- function launchdPlistPath(): string {
230
- return `/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist`;
405
+ function launchdPlistPath(user: string = serviceUser()): string {
406
+ return `/Library/LaunchDaemons/${launchdLabel(user)}.plist`;
231
407
  }
232
408
 
233
- /** The pre-1.0.0-beta.1 LaunchAgent — same migration problem as above. */
409
+ /** The pre-1.0.0-beta.1 LaunchAgent. */
234
410
  function legacyLaunchAgentPlistPath(): string {
235
- return resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
411
+ return resolve(homedir(), "Library", "LaunchAgents", "ai.failproof.failproofaid.plist");
412
+ }
413
+
414
+ /**
415
+ * The 1.0.0-beta.1 LaunchDaemon, before the label was namespaced. Removed on
416
+ * install like the LaunchAgent is: it holds the same singleton flock, so
417
+ * leaving it would leave two daemons racing for one socket.
418
+ */
419
+ function legacySharedLaunchDaemonPath(): string {
420
+ return "/Library/LaunchDaemons/ai.failproof.failproofaid.plist";
236
421
  }
237
422
 
238
423
  /**
@@ -255,11 +440,66 @@ export function daemonStatusCommand(): string | null {
255
440
  if (!isDaemonSupportedPlatform()) return null;
256
441
  return process.platform === "linux"
257
442
  ? `systemctl status ${systemdUnitName()}`
258
- : `sudo launchctl print system/${LAUNCHD_LABEL}`;
443
+ : `sudo launchctl print system/${launchdLabel()}`;
444
+ }
445
+
446
+ /**
447
+ * The command that restarts the daemon, for the places where a config change
448
+ * only takes effect on the next start.
449
+ *
450
+ * `--disconnect` is the motivating case: the collector manager starts once for
451
+ * the daemon's lifetime and the uploader caches its bearer key at construction,
452
+ * so removing the credential file changes nothing about the process already
453
+ * running.
454
+ */
455
+ export function daemonRestartCommand(): string | null {
456
+ if (!isDaemonSupportedPlatform()) return null;
457
+ return process.platform === "linux"
458
+ ? `sudo systemctl restart ${systemdUnitName()}`
459
+ : `sudo launchctl kickstart -k system/${launchdLabel()}`;
460
+ }
461
+
462
+ /**
463
+ * Acquires sudo credentials up front, prompting in the terminal if needed.
464
+ *
465
+ * `sudo -v` refreshes the user's sudo timestamp and returns; every later
466
+ * `sudo -n` in this run then succeeds against that cached credential. Doing it
467
+ * here, once, at a known point in the wizard is what makes the rest of the
468
+ * install non-interactive — the alternative, an interactive sudo firing from
469
+ * underneath a half-drawn TUI screen, is a password prompt the user cannot
470
+ * see.
471
+ *
472
+ * NOT the same as running the whole CLI under sudo, which is actively wrong:
473
+ * `homedir()` would become /root, so the hooks, the policy config, the
474
+ * downloaded binary and the unit's own `User=` would all be root's rather
475
+ * than the user's, quietly configuring the wrong account.
476
+ *
477
+ * `stdio: "inherit"` so the prompt and the typed password go straight to the
478
+ * real terminal. The timeout is generous because a human is typing.
479
+ */
480
+ export function primeElevation(): boolean {
481
+ if (typeof process.getuid === "function" && process.getuid() === 0) return true;
482
+ if (canElevate()) return true; // NOPASSWD, or already primed
483
+ try {
484
+ execFileSync("sudo", ["-v"], { stdio: "inherit", timeout: SUDO_PROMPT_TIMEOUT_MS });
485
+ return canElevate();
486
+ } catch {
487
+ // Wrong password, no sudo rights, or the user pressed ctrl-C. All of them
488
+ // mean the same thing here: carry on without a daemon.
489
+ return false;
490
+ }
259
491
  }
260
492
 
261
- /** True when privileged commands can run without prompting for a password. */
262
- function canElevate(): boolean {
493
+ /**
494
+ * True when privileged commands can run without prompting for a password.
495
+ *
496
+ * Exported because onboarding re-checks it: `needs_root` is the most common
497
+ * reason setup aborts, and it is the one most likely to stop being true (the
498
+ * user gets sudo rights, or primes their timestamp in another terminal). One
499
+ * `sudo -n true`, no prompt, milliseconds — and never on the hook path, which
500
+ * does not reach the first-run gate at all.
501
+ */
502
+ export function canElevate(): boolean {
263
503
  if (typeof process.getuid === "function" && process.getuid() === 0) return true;
264
504
  try {
265
505
  execFileSync("sudo", ["-n", "true"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
@@ -289,12 +529,19 @@ function runPrivileged(command: string, args: string[]): void {
289
529
  * mode in the same step, so the file is never briefly world-writable.
290
530
  */
291
531
  function writePrivilegedFile(destination: string, contents: string, mode = "0644"): void {
292
- const staging = resolve(tmpdir(), `failproofaid-${process.pid}-${Date.now()}.tmp`);
532
+ // mkdtempSync, not a name built from pid + timestamp in the shared tmpdir.
533
+ // That name is guessable, and `writeFileSync` follows a symlink already
534
+ // sitting at the path — so on a multi-user box another local user could
535
+ // pre-create it and have `install` copy content they control into
536
+ // /etc/systemd/system as root. mkdtemp gives a 0700 directory with a name
537
+ // they cannot predict, so there is nothing to pre-create.
538
+ const stagingDir = mkdtempSync(resolve(tmpdir(), "failproofaid-stage-"));
539
+ const staging = resolve(stagingDir, "service-definition");
293
540
  try {
294
541
  writeFileSync(staging, contents, "utf8");
295
542
  runPrivileged("install", ["-m", mode, staging, destination]);
296
543
  } finally {
297
- rmSync(staging, { force: true });
544
+ rmSync(stagingDir, { recursive: true, force: true });
298
545
  }
299
546
  }
300
547
 
@@ -326,32 +573,101 @@ function removeLegacyUserService(): void {
326
573
  /* best-effort */
327
574
  }
328
575
  } else {
329
- const legacy = legacyLaunchAgentPlistPath();
330
- if (!existsSync(legacy)) return;
331
- try {
332
- execFileSync("launchctl", ["unload", "-w", legacy], {
333
- stdio: "ignore",
334
- timeout: SERVICE_CMD_TIMEOUT_MS,
335
- });
336
- } catch {
337
- /* not loaded */
576
+ const legacyAgent = legacyLaunchAgentPlistPath();
577
+ if (existsSync(legacyAgent)) {
578
+ try {
579
+ execFileSync("launchctl", ["unload", "-w", legacyAgent], {
580
+ stdio: "ignore",
581
+ timeout: SERVICE_CMD_TIMEOUT_MS,
582
+ });
583
+ } catch {
584
+ /* not loaded */
585
+ }
586
+ unlinkSync(legacyAgent);
587
+ }
588
+
589
+ // The 1.0.0-beta.1 daemon, before the label was per-user. Privileged,
590
+ // and only reachable on a path that has already elevated.
591
+ const legacyDaemon = legacySharedLaunchDaemonPath();
592
+ if (existsSync(legacyDaemon)) {
593
+ try {
594
+ runPrivileged("launchctl", ["unload", "-w", legacyDaemon]);
595
+ } catch {
596
+ /* not loaded */
597
+ }
598
+ runPrivileged("rm", ["-f", legacyDaemon]);
338
599
  }
339
- unlinkSync(legacy);
340
600
  }
341
601
  } catch (err) {
342
602
  hookLogWarn(`could not remove the legacy user-scope daemon: ${err instanceof Error ? err.message : String(err)}`);
343
603
  }
344
604
  }
345
605
 
346
- export function systemdUnitContents(binaryPath: string, workerCmd: string | null): string {
347
- // Quoted because both values contain a space or a path — systemd's
606
+ export function systemdUnitContents(
607
+ binaryPath: string,
608
+ workerCmd: string | null,
609
+ cliCmd: string | null = null,
610
+ ): string {
611
+ // Quoted because every value here contains a space or a path — systemd's
348
612
  // Environment= requires quoting whenever the value does.
349
- const envLine = workerCmd ? `Environment="FAILPROOFAI_WORKER_CMD=${workerCmd}"\n` : "";
350
- const user = serviceUser();
613
+ const envLines = serviceEnvironment(workerCmd, cliCmd)
614
+ .map(([key, value]) => `Environment="${key}=${assertUnitSafe(value, key)}"\n`)
615
+ .join("");
616
+ const user = assertUnitSafe(serviceUser(), "User");
617
+ assertUnitSafe(binaryPath, "ExecStart");
618
+ assertUnitSafe(homedir(), "HOME");
619
+
620
+ // Gate the unit on the two files it cannot run without, so that an install
621
+ // which is no longer there STOPS rather than thrashes.
622
+ //
623
+ // `npm rm -g failproofai` is the case this is for, and npm runs no uninstall
624
+ // script — see the note on `failproofai uninstall`. It deletes the package,
625
+ // which takes `dist/worker.mjs` with it, while the daemon binary under
626
+ // ~/.failproofai survives. Without a condition systemd keeps a daemon alive
627
+ // whose worker cannot spawn; with a deleted BINARY it is worse, because
628
+ // ExecStart fails 203/EXEC under `Restart=on-failure` and cycles until it
629
+ // trips the start-limit and latches into "start request repeated too
630
+ // quickly" — a state that then refuses a legitimate restart later.
631
+ //
632
+ // A failed condition is not a failure: systemd SKIPS the job, leaves the unit
633
+ // inactive, and `systemctl status` names the exact path that was missing.
634
+ // That turns an unexplained crash-loop into a one-line diagnosis, and
635
+ // `daemonServiceStatus()` reads it back as `condition-failed` so the next CLI
636
+ // command can clear `daemonConfigured` instead of leaving the machine denying
637
+ // every tool call.
638
+ //
639
+ // This deliberately does NOT soften the hook path's fail-closed deny. A
640
+ // machine that was configured to require the daemon still denies while the
641
+ // daemon is absent — being skipped by systemd is not consent to stop
642
+ // enforcing. It shortens how long that lasts and explains why.
643
+ // Only paths that EXIST right now are gated on, and that filter is load-
644
+ // bearing rather than belt-and-braces.
645
+ //
646
+ // `binaryPath` is an ExecStart value, not necessarily a bare path: systemd
647
+ // accepts arguments there, and `FAILPROOFAI_DAEMON_BINARY` is documented as
648
+ // "someone named a binary explicitly" — this repo's own systemd tests set it
649
+ // to `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so
650
+ // gating on that string looks for a file literally named "sleep infinity",
651
+ // never finds it, and skips a unit that would have run perfectly. Splitting
652
+ // on whitespace to recover the binary is worse, because a path may legally
653
+ // contain spaces and there is no way to tell the two apart from here.
654
+ //
655
+ // Existence at render time answers it without guessing: a real binary is on
656
+ // disk when its unit is written (installDaemonService just put it there), and
657
+ // a command-with-arguments is not. Same rule the worker script already
658
+ // follows — never gate on a path that is not there, or the freshly installed
659
+ // unit skips on its very first start.
660
+ const conditionPaths = [binaryPath, workerScriptPath()].filter(
661
+ (p): p is string => typeof p === "string" && existsSync(p),
662
+ );
663
+ const conditionLines = conditionPaths
664
+ .map((p) => `ConditionPathExists=${assertUnitSafe(p, "ConditionPathExists")}\n`)
665
+ .join("");
666
+
351
667
  return `[Unit]
352
668
  Description=failproofai background daemon (failproofaid) for ${user}
353
669
  After=network.target
354
-
670
+ ${conditionLines}
355
671
  [Service]
356
672
  Type=simple
357
673
  User=${user}
@@ -360,7 +676,7 @@ User=${user}
360
676
  # HOME ("HOME is not set; failproofaid is user-scope only"), so the one
361
677
  # variable it cannot do without is not left to a version-dependent default.
362
678
  Environment="HOME=${homedir()}"
363
- ${envLine}ExecStart=${binaryPath}
679
+ ${envLines}ExecStart=${binaryPath}
364
680
  Restart=on-failure
365
681
  RestartSec=2
366
682
 
@@ -376,12 +692,27 @@ function escapeXml(s: string): string {
376
692
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
377
693
  }
378
694
 
379
- function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: string | null): string {
380
- const envBlock = workerCmd
695
+ function unescapeXml(s: string): string {
696
+ // &amp; last, or an escaped `&amp;lt;` would come back as `<`.
697
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
698
+ }
699
+
700
+ /**
701
+ * Exported for the same reason `systemdUnitContents` is: it is the only way to
702
+ * assert the plist's shape from a Linux CI runner, and this is the half of the
703
+ * pair no Linux test run can otherwise reach.
704
+ */
705
+ export function launchdPlistContents(
706
+ binaryPath: string,
707
+ logDir: string,
708
+ workerCmd: string | null,
709
+ cliCmd: string | null = null,
710
+ ): string {
711
+ const env = serviceEnvironment(workerCmd, cliCmd);
712
+ const envBlock = env.length
381
713
  ? ` <key>EnvironmentVariables</key>
382
714
  <dict>
383
- <key>FAILPROOFAI_WORKER_CMD</key>
384
- <string>${escapeXml(workerCmd)}</string>
715
+ ${env.map(([key, value]) => ` <key>${key}</key>\n <string>${escapeXml(value)}</string>`).join("\n")}
385
716
  </dict>
386
717
  `
387
718
  : "";
@@ -390,7 +721,7 @@ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: str
390
721
  <plist version="1.0">
391
722
  <dict>
392
723
  <key>Label</key>
393
- <string>${LAUNCHD_LABEL}</string>
724
+ <string>${escapeXml(launchdLabel())}</string>
394
725
  <key>ProgramArguments</key>
395
726
  <array>
396
727
  <string>${escapeXml(binaryPath)}</string>
@@ -424,10 +755,14 @@ export interface DaemonInstallResult {
424
755
  * machine that cannot elevate can be told exactly what to run rather than
425
756
  * just that something failed.
426
757
  */
427
- function daemonInstallCommands(binaryPath: string, workerCmd: string | null): string[] {
758
+ function daemonInstallCommands(
759
+ binaryPath: string,
760
+ workerCmd: string | null,
761
+ cliCmd: string | null,
762
+ ): string[] {
428
763
  if (process.platform === "linux") {
429
764
  return [
430
- `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd)}EOF`,
765
+ `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd, cliCmd)}EOF`,
431
766
  "sudo systemctl daemon-reload",
432
767
  `sudo systemctl enable --now ${systemdUnitName()}`,
433
768
  ];
@@ -463,6 +798,11 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
463
798
  // environment is what makes a *service-managed* daemon actually find its
464
799
  // worker regardless of what cwd the service manager starts it from.
465
800
  const workerCmd = resolveWorkerCommand();
801
+ // Same best-effort footing as the worker command, and null is survivable in
802
+ // the same way — the daemon just has no way to run an audit. Unlike the
803
+ // worker, there is no relative-path fallback to degrade to, which is exactly
804
+ // why it is resolved here where FAILPROOFAI_PACKAGE_ROOT is reliably set.
805
+ const cliCmd = resolveCliCommand();
466
806
 
467
807
  // The service is installed system-wide, which needs root. Check before
468
808
  // writing anything, so a machine that cannot elevate gets the exact
@@ -471,8 +811,10 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
471
811
  return {
472
812
  installed: false,
473
813
  reason:
474
- "root privileges are required to install the failproofaid system service, and sudo is not available without a password. " +
475
- `Run: sudo failproofai config (or install manually: ${daemonInstallCommands(binaryPath, workerCmd).join(" && ")})`,
814
+ "root privileges are required to install the failproofaid system service, and sudo credentials were not available. " +
815
+ "Re-run `failproofai config` and approve the sudo prompt — do NOT run the CLI itself under sudo, which would " +
816
+ "configure root's account instead of yours. To install by hand: " +
817
+ daemonInstallCommands(binaryPath, workerCmd, cliCmd).join(" && "),
476
818
  };
477
819
  }
478
820
 
@@ -484,12 +826,12 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
484
826
 
485
827
  try {
486
828
  if (process.platform === "linux") {
487
- writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd));
829
+ writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd, cliCmd));
488
830
  runPrivileged("systemctl", ["daemon-reload"]);
489
831
  runPrivileged("systemctl", ["enable", "--now", systemdUnitName()]);
490
832
  } else {
491
833
  const plistPath = launchdPlistPath();
492
- const logDir = resolve(homedir(), ".failproofai", "logs");
834
+ const logDir = logsDir();
493
835
  mkdirSync(logDir, { recursive: true });
494
836
  // Unload any previously-loaded copy first — reloading with a changed
495
837
  // binary path (e.g. after an upgrade) is a no-op under plain `load`
@@ -499,7 +841,7 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
499
841
  } catch {
500
842
  // Wasn't loaded — fine, this is the common case on a fresh install.
501
843
  }
502
- writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd));
844
+ writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd));
503
845
  runPrivileged("launchctl", ["load", "-w", plistPath]);
504
846
  }
505
847
  } catch (err) {
@@ -520,9 +862,134 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
520
862
  hookLogWarn(`daemon service install failed: ${reason}`);
521
863
  return { installed: false, reason };
522
864
  }
865
+
866
+ // NOTE: this reports that the SERVICE is installed and running, which is all
867
+ // it can honestly claim. Whether the daemon can actually evaluate a hook is a
868
+ // separate question, answered by `probeDaemonEndToEnd()` — and the caller
869
+ // must ask it before setting `daemonConfigured`, because that flag is what
870
+ // makes an unanswering daemon deny every tool call. `configure-wizard.ts`
871
+ // does exactly that. Keeping the two apart matters: "the unit was written and
872
+ // systemd started it" is testable against a stub binary, and folding the
873
+ // probe in here would mean this function could never be tested without a real
874
+ // failproofaid.
523
875
  return { installed: true };
524
876
  }
525
877
 
878
+ /**
879
+ * How long the health probe waits for the daemon to answer a real hook.
880
+ *
881
+ * Far below the hook path's 30s: this runs from an interactive command, and
882
+ * the cold start it is most likely to be waiting on (`worker.rs` pre-warms the
883
+ * worker, ~700ms of Node startup) is comfortably inside it. A daemon that
884
+ * cannot answer a trivial event in this long is not one to hand a machine's
885
+ * fail-closed enforcement to.
886
+ */
887
+ const DAEMON_PROBE_TIMEOUT_MS = 5_000;
888
+
889
+ /**
890
+ * How long the probe keeps waiting for the socket to come up before calling the
891
+ * daemon unreachable. Generous on purpose: this runs once, from an interactive
892
+ * setup command, and the cost of being impatient is aborting setup at a machine
893
+ * whose daemon is merely still starting.
894
+ */
895
+ const DAEMON_PROBE_READY_TIMEOUT_MS = 10_000;
896
+ const DAEMON_PROBE_RETRY_MS = 150;
897
+
898
+ /**
899
+ * Ask the daemon to evaluate a real hook, end to end.
900
+ *
901
+ * The thing this catches that nothing else did: a unit that is *running* while
902
+ * the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
903
+ * and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
904
+ * systemd reports as perfectly active and a worker that dies on every spawn.
905
+ * Every existing check passes that machine — `waitForDaemonRunning()` asks the
906
+ * service manager, `Ping` is answered in `server.rs` without ever touching the
907
+ * worker, a null `resolveWorkerCommand()` is treated as best-effort, and
908
+ * `Worker::warm()` swallows its own failure. `daemonConfigured` then gets set,
909
+ * and because that flag makes every hook fail closed, the machine denies every
910
+ * tool call across all 12 CLIs — including `UserPromptSubmit`, so the user
911
+ * cannot even talk to their agent to ask why.
912
+ *
913
+ * `SessionStart` rather than a tool event: it traverses the identical path
914
+ * (socket → daemon → worker → `evaluateHookEvent`) but describes nothing a
915
+ * policy would deny and records no tool decision, so probing cannot itself
916
+ * change what the machine does.
917
+ */
918
+ export type DaemonProbe =
919
+ | { ok: true }
920
+ | {
921
+ ok: false;
922
+ /**
923
+ * `unreachable` — nothing ever accepted a connection on the socket.
924
+ * `worker` — the daemon accepted a connection but could not answer a
925
+ * hook, which is the worker failing to run.
926
+ *
927
+ * Kept apart because the remedies are different, and because reporting
928
+ * "your worker will not start" at someone whose worker is fine sends them
929
+ * to inspect a healthy process. `DaemonFailure` cannot make this
930
+ * distinction: it reports `unreachable` for a refused connection AND for
931
+ * a request that was accepted and never answered.
932
+ */
933
+ reason: "unreachable" | "worker";
934
+ };
935
+
936
+ /**
937
+ * Ask the daemon to evaluate a real hook, end to end.
938
+ *
939
+ * The thing this catches that nothing else did: a unit that is *running* while
940
+ * the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
941
+ * and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
942
+ * systemd reports as perfectly active and a worker that dies on every spawn.
943
+ *
944
+ * **Why this retries.** It is called moments after `systemctl enable --now`, and
945
+ * a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before
946
+ * the daemon has bound its socket. A single attempt therefore raced the bind
947
+ * and, because the hook path's connect budget is deliberately 150ms, lost that
948
+ * race on any loaded machine. Setup then aborted with "its worker process could
949
+ * not be run" at a daemon that was seconds away from serving happily — the
950
+ * worker had already logged that it was listening. Waiting for the socket is
951
+ * the fix; relaxing the 150ms is NOT, because that budget is what keeps a dead
952
+ * daemon from adding latency to every tool call on the hook path.
953
+ */
954
+ export async function probeDaemon(): Promise<DaemonProbe> {
955
+ try {
956
+ const { attemptDaemonHook, daemonAcceptsConnections } = await import("./daemon-client");
957
+ const deadline = Date.now() + DAEMON_PROBE_READY_TIMEOUT_MS;
958
+ let everConnected = false;
959
+
960
+ for (;;) {
961
+ if (await daemonAcceptsConnections()) {
962
+ everConnected = true;
963
+ const attempt = await attemptDaemonHook(
964
+ {
965
+ hookEvent: "SessionStart",
966
+ cli: "claude",
967
+ stdin: JSON.stringify({
968
+ hook_event_name: "SessionStart",
969
+ source: "failproofai-health-probe",
970
+ }),
971
+ },
972
+ { responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
973
+ );
974
+ // A protocol mismatch is a REACHABLE daemon of the wrong vintage, which
975
+ // is a version problem rather than the lockout this probe exists to
976
+ // find. It is reported, and acted on, elsewhere.
977
+ if (attempt.ok || attempt.failure === "protocol-mismatch") return { ok: true };
978
+ }
979
+ if (Date.now() >= deadline) break;
980
+ await new Promise((r) => setTimeout(r, DAEMON_PROBE_RETRY_MS));
981
+ }
982
+ return { ok: false, reason: everConnected ? "worker" : "unreachable" };
983
+ } catch {
984
+ return { ok: false, reason: "unreachable" };
985
+ }
986
+ }
987
+
988
+ /** Boolean form, for callers that only branch on healthy/not. */
989
+ export async function probeDaemonEndToEnd(): Promise<boolean> {
990
+ return (await probeDaemon()).ok;
991
+ }
992
+
526
993
  /**
527
994
  * Waits for the service to report running, then re-checks after a settle
528
995
  * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
@@ -539,6 +1006,353 @@ async function waitForDaemonRunning(): Promise<boolean> {
539
1006
  return daemonServiceStatus() === "running";
540
1007
  }
541
1008
 
1009
+ // ── Upgrading a service definition that predates a variable ──────────────────
1010
+ //
1011
+ // A machine that installed the daemon before `FAILPROOFAI_CLI_CMD` existed
1012
+ // keeps its old unit forever: `npm i -g failproofai@latest` replaces the CLI
1013
+ // and never touches /etc/systemd/system, and the wizard's own "already
1014
+ // installed and running — leaving it alone" branch skips it too. The daemon
1015
+ // then has no way to spawn an audit while `config.toml` says the scheduled
1016
+ // scan is on, and nothing anywhere reports a fault. This is the single most
1017
+ // likely way the whole feature ends up dead on real machines, so it gets an
1018
+ // explicit detect-and-rewrite rather than an assumption that reinstalls happen.
1019
+
1020
+ /** Reads the installed unit / plist, or null when no service is installed. */
1021
+ function readInstalledServiceDefinition(): string | null {
1022
+ if (!isDaemonSupportedPlatform()) return null;
1023
+ const path = daemonServiceFilePath();
1024
+ if (!path || !existsSync(path)) return null;
1025
+ try {
1026
+ return readFileSync(path, "utf8");
1027
+ } catch {
1028
+ // Unreadable is not "absent": claiming no service is installed would let a
1029
+ // caller reinstall over something it cannot see. Treated as "nothing to
1030
+ // upgrade" by the one caller below, which is the inert direction.
1031
+ return null;
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Reads one variable back out of an installed service definition.
1037
+ *
1038
+ * Used to carry values FORWARD across a rewrite. A rewrite resolves each
1039
+ * command from this process, and a resolution that comes back null (no
1040
+ * `FAILPROOFAI_PACKAGE_ROOT`, a `dist/` that moved) must never be allowed to
1041
+ * silently DELETE a working line from a working unit — the rewrite exists to
1042
+ * add a variable, not to drop one.
1043
+ */
1044
+ function installedEnvValue(definition: string, name: string): string | null {
1045
+ if (process.platform === "linux") {
1046
+ const m = new RegExp(`^Environment="${name}=(.*)"$`, "m").exec(definition);
1047
+ return m ? m[1] : null;
1048
+ }
1049
+ const m = new RegExp(`<key>${name}</key>\\s*<string>([^<]*)</string>`).exec(definition);
1050
+ return m ? unescapeXml(m[1]) : null;
1051
+ }
1052
+
1053
+ /**
1054
+ * The binary an installed definition currently starts.
1055
+ *
1056
+ * Preserved across a rewrite rather than re-resolved, because the two disagree
1057
+ * in exactly the upgrade case this is for: `installedBinaryPath()` is
1058
+ * version-stamped (`failproofaid-<version>`), so right after a CLI upgrade the
1059
+ * binary for the NEW version is not on disk yet and resolution returns null,
1060
+ * while the unit still points at the older binary that is there and running.
1061
+ * Re-resolving would either refuse to rewrite or repoint a live service at a
1062
+ * file that does not exist.
1063
+ */
1064
+ function installedExecStart(definition: string): string | null {
1065
+ if (process.platform === "linux") {
1066
+ const m = /^ExecStart=(.+)$/m.exec(definition);
1067
+ return m ? m[1].trim() : null;
1068
+ }
1069
+ const m = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(definition);
1070
+ return m ? unescapeXml(m[1]) : null;
1071
+ }
1072
+
1073
+ /**
1074
+ * True when a service IS installed but its definition predates
1075
+ * `FAILPROOFAI_CLI_CMD`.
1076
+ *
1077
+ * Deliberately a content check against the definition on disk, not a revision
1078
+ * number mirrored into `config.toml`. The mirror is the tempting shape — it is
1079
+ * how `daemon.installed_version` is modelled — but that field is declared,
1080
+ * read and cleared and has never once been WRITTEN (both `setDaemonConfigured`
1081
+ * call sites pass no version), which is precisely how a mirror fails: it
1082
+ * reports "current" for a unit nobody updated. A false negative here is the
1083
+ * permanently-inert audit lane this function exists to catch, and the unit
1084
+ * itself cannot lie about what it contains.
1085
+ */
1086
+ export function daemonServiceNeedsUpgrade(): boolean {
1087
+ const definition = readInstalledServiceDefinition();
1088
+ if (definition === null) return false;
1089
+ return !definition.includes("FAILPROOFAI_CLI_CMD");
1090
+ }
1091
+
1092
+ /**
1093
+ * The definition an installed one should become: regenerated by the same
1094
+ * renderers a fresh install uses, carrying forward everything this process
1095
+ * cannot re-resolve. `null` when the input has no readable start command,
1096
+ * which means it is not a definition failproofai wrote and must be left alone.
1097
+ *
1098
+ * Split out from `ensureDaemonServiceCurrent` and kept pure — no privilege, no
1099
+ * service manager, no writes — because the rewrite itself can only be
1100
+ * exercised end-to-end on a machine with root AND the matching service
1101
+ * manager. That is nowhere on a Linux CI runner for the launchd half, and
1102
+ * nowhere at all on a developer box without passwordless sudo, so the one
1103
+ * transformation that must not be wrong would otherwise be the one thing no
1104
+ * ordinary test run ever executes.
1105
+ */
1106
+ export function upgradedServiceDefinition(
1107
+ definition: string,
1108
+ cliCmd: string,
1109
+ logDir: string,
1110
+ ): string | null {
1111
+ const binaryPath = installedExecStart(definition);
1112
+ if (!binaryPath) return null;
1113
+ const workerCmd = resolveWorkerCommand() ?? installedEnvValue(definition, "FAILPROOFAI_WORKER_CMD");
1114
+ try {
1115
+ return process.platform === "linux"
1116
+ ? systemdUnitContents(binaryPath, workerCmd, cliCmd)
1117
+ : launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd);
1118
+ } catch (err) {
1119
+ // A value that cannot be safely interpolated is reported as "no usable
1120
+ // definition" rather than thrown: this runs against a HEALTHY, RUNNING
1121
+ // daemon and the caller's contract is an outcome, not an exception. The
1122
+ // refusal happens before anything is written or stopped, so the machine is
1123
+ // left exactly as it was.
1124
+ hookLogWarn(
1125
+ `service definition not rewritten: ${err instanceof Error ? err.message : String(err)}`,
1126
+ );
1127
+ return null;
1128
+ }
1129
+ }
1130
+
1131
+ export type DaemonUpgradeOutcome =
1132
+ /** No service installed, or its definition already carries the variable. */
1133
+ | "current"
1134
+ /** The definition was rewritten and the service came back running. */
1135
+ | "rewritten"
1136
+ /** It needed a rewrite and did not get one. `reason` says why. */
1137
+ | "failed";
1138
+
1139
+ export interface DaemonUpgradeResult {
1140
+ outcome: DaemonUpgradeOutcome;
1141
+ reason?: string;
1142
+ /**
1143
+ * Whether failproofaid is running when this returns — probed, never
1144
+ * inferred from what the commands returned.
1145
+ *
1146
+ * Set on every outcome that touched the service, because on a
1147
+ * `daemonConfigured` machine a stopped daemon is not a lost feature: every
1148
+ * hook event fails closed, across all 12 CLIs, until somebody hand-edits
1149
+ * `policies-config.json`. The caller needs to know that specifically, not
1150
+ * just that "the refresh failed".
1151
+ */
1152
+ daemonRunning?: boolean;
1153
+ }
1154
+
1155
+ /**
1156
+ * Restart the systemd unit, clearing any start-limit latch first.
1157
+ *
1158
+ * The unit ships `Restart=on-failure` with `RestartSec=2`, so a definition
1159
+ * systemd accepts but cannot run (a poisoned `User=`, a missing binary) does
1160
+ * not fail once — it cycles, and within `DefaultStartLimitIntervalSec` (10s) it
1161
+ * trips `DefaultStartLimitBurst` (5) and latches into "start request repeated
1162
+ * too quickly". On systemd 255 — what ubuntu-24.04 and GitHub's runners ship —
1163
+ * that latch is sticky at the unit level: a later `systemctl restart` is refused
1164
+ * even after the definition on disk has been REPLACED with a good one. A
1165
+ * rollback that restores a perfectly runnable unit then still cannot start it,
1166
+ * and the machine stays fail-closed against a daemon that never comes back.
1167
+ * `reset-failed` clears the failure counter so the restart is deterministic; on
1168
+ * a healthy unit it is a no-op. Best-effort — a `reset-failed` that errors (the
1169
+ * unit was never failed) must never abort the restart that is the real step.
1170
+ */
1171
+ function restartSystemdUnit(): void {
1172
+ try {
1173
+ runPrivileged("systemctl", ["reset-failed", systemdUnitName()]);
1174
+ } catch {
1175
+ // The restart below is the operative step; do not let a reset-failed
1176
+ // that errored on an already-clean unit swallow it.
1177
+ }
1178
+ runPrivileged("systemctl", ["restart", systemdUnitName()]);
1179
+ }
1180
+
1181
+ /**
1182
+ * Puts a previous service definition back and starts the service again,
1183
+ * answering whether failproofaid is running once it has.
1184
+ *
1185
+ * `ensureDaemonServiceCurrent` is the only caller, and it runs exclusively
1186
+ * against a machine whose daemon is UP — which is what makes a half-applied
1187
+ * rewrite so much worse than no rewrite. Stopping a healthy daemon and failing
1188
+ * to start it again trades a dead audit lane for a machine that denies every
1189
+ * tool call, so the refresh must be able to undo itself.
1190
+ *
1191
+ * Best-effort and never throws: it only ever runs where something has already
1192
+ * gone wrong. The return value is `waitForDaemonRunning()` rather than "the
1193
+ * commands succeeded" on purpose — the common rollback failure is a lost sudo
1194
+ * credential, which means nothing was written and the daemon was never down.
1195
+ */
1196
+ async function restoreServiceDefinition(previous: string): Promise<boolean> {
1197
+ try {
1198
+ if (process.platform === "linux") {
1199
+ writePrivilegedFile(systemdUnitPath(), previous);
1200
+ runPrivileged("systemctl", ["daemon-reload"]);
1201
+ // reset-failed first: the failed rewrite we are undoing has been cycling
1202
+ // under Restart=on-failure and may have tripped systemd's start-limit,
1203
+ // which on systemd 255 refuses to start even this restored, good unit.
1204
+ restartSystemdUnit();
1205
+ } else {
1206
+ const plistPath = launchdPlistPath();
1207
+ try {
1208
+ runPrivileged("launchctl", ["unload", plistPath]);
1209
+ } catch {
1210
+ // Already unloaded — getting the old definition loaded is the point.
1211
+ }
1212
+ writePrivilegedFile(plistPath, previous);
1213
+ runPrivileged("launchctl", ["load", "-w", plistPath]);
1214
+ }
1215
+ } catch (err) {
1216
+ hookLogWarn(
1217
+ `daemon service definition rollback failed: ${err instanceof Error ? err.message : String(err)}`,
1218
+ );
1219
+ // Not a verdict on the daemon — fall through to the probe below, which is.
1220
+ }
1221
+ return waitForDaemonRunning();
1222
+ }
1223
+
1224
+ /**
1225
+ * The one exit for every way a refresh can fail after it started touching the
1226
+ * service. Rolls back when there is something to roll back to, then reports
1227
+ * the daemon's actual state.
1228
+ */
1229
+ async function refreshFailed(message: string, previous: string | null): Promise<DaemonUpgradeResult> {
1230
+ hookLogWarn(`daemon service unit refresh failed: ${message}`);
1231
+ const running =
1232
+ previous === null ? daemonServiceStatus() === "running" : await restoreServiceDefinition(previous);
1233
+ return {
1234
+ outcome: "failed",
1235
+ daemonRunning: running,
1236
+ reason: running
1237
+ ? message
1238
+ : `${message}. The previous service definition could not be restored either, so failproofaid is NOT running`,
1239
+ };
1240
+ }
1241
+
1242
+ /**
1243
+ * Brings an already-installed service definition up to date, in place.
1244
+ *
1245
+ * Narrower than `installDaemonService` on purpose, and it is the narrowness
1246
+ * that makes it safe to run against a healthy machine: it never downloads, it
1247
+ * never touches a machine that has no service, and it keeps the ExecStart and
1248
+ * any environment value it cannot re-resolve. Everything past the first
1249
+ * privileged write is undone by `restoreServiceDefinition` if the daemon does
1250
+ * not come back, so the worst outcome really is the machine it started with —
1251
+ * and when even the rollback cannot get the daemon up, that fact rides out on
1252
+ * `daemonRunning` rather than being folded into a generic failure, because the
1253
+ * caller has to stop asserting `daemonConfigured` over a daemon that is gone.
1254
+ *
1255
+ * It DOES restart the service, which `installDaemonService` does not do on
1256
+ * Linux (`enable --now` is a no-op against an already-active unit, so a
1257
+ * rewritten unit's environment reaches the running process only at the next
1258
+ * boot). Without the restart this "fix" would leave the audit lane inert for
1259
+ * however long the machine stays up — which is the bug, not a smaller version
1260
+ * of it. The restart costs a sub-second window in which a hook can find no
1261
+ * daemon and fail closed, so it is confined to the wizard: `failproofai
1262
+ * config` is an explicit, attended reconfiguration, and the same window
1263
+ * already exists on macOS, where install unloads and reloads on every run.
1264
+ */
1265
+ export async function ensureDaemonServiceCurrent(): Promise<DaemonUpgradeResult> {
1266
+ const definition = readInstalledServiceDefinition();
1267
+ if (definition === null || definition.includes("FAILPROOFAI_CLI_CMD")) {
1268
+ return { outcome: "current" };
1269
+ }
1270
+
1271
+ const cliCmd = resolveCliCommand();
1272
+ if (!cliCmd) {
1273
+ return {
1274
+ outcome: "failed",
1275
+ reason:
1276
+ "the failproofai CLI entry point (dist/cli.mjs) could not be located, so there is nothing to " +
1277
+ "write into the unit. Re-run `failproofai config` from a complete install.",
1278
+ };
1279
+ }
1280
+
1281
+ const logDir = logsDir();
1282
+ const upgraded = upgradedServiceDefinition(definition, cliCmd, logDir);
1283
+ if (!upgraded) {
1284
+ return {
1285
+ outcome: "failed",
1286
+ reason:
1287
+ `no safe replacement could be composed for the service definition at ${daemonServiceFilePath()} — ` +
1288
+ `either it has no readable start command (it looks hand-edited) or a value that would go into it ` +
1289
+ `contains a quote, backslash or newline. It was left alone.`,
1290
+ // Reported because nothing was touched: the refusal happens before any
1291
+ // write and before any stop, so whatever was running still is. The
1292
+ // wizard branches on this to decide whether to clear `daemonConfigured`,
1293
+ // and omitting it here would have read as "the daemon may be down" on a
1294
+ // machine whose daemon is perfectly healthy — which on a
1295
+ // `daemonConfigured` box is the difference between a lost audit lane and
1296
+ // every tool call denied.
1297
+ daemonRunning: daemonServiceStatus() === "running",
1298
+ };
1299
+ }
1300
+
1301
+ if (!canElevate()) {
1302
+ return {
1303
+ outcome: "failed",
1304
+ reason: `root privileges are required to rewrite ${daemonServiceFilePath()}, and sudo credentials were not available`,
1305
+ };
1306
+ }
1307
+
1308
+ // What to put back if this goes wrong, and from which point. Set BEFORE the
1309
+ // step that can leave the machine changed, not after it succeeds: on Linux
1310
+ // `writePrivilegedFile` is an `install` copy straight onto the unit path, so
1311
+ // a failure part-way through leaves a truncated unit behind, and on macOS
1312
+ // the unload has already stopped the daemon before anything is written.
1313
+ let previous: string | null = null;
1314
+
1315
+ try {
1316
+ if (process.platform === "linux") {
1317
+ previous = definition;
1318
+ writePrivilegedFile(systemdUnitPath(), upgraded);
1319
+ runPrivileged("systemctl", ["daemon-reload"]);
1320
+ // `restart`, not `enable --now`: the unit is already enabled and active,
1321
+ // so `--now` would return success having changed nothing and the daemon
1322
+ // would keep the environment it was started with. reset-failed first so a
1323
+ // machine that arrived here with an already-latched start-limit (a prior
1324
+ // failed refresh) can still be upgraded.
1325
+ restartSystemdUnit();
1326
+ } else {
1327
+ const plistPath = launchdPlistPath();
1328
+ mkdirSync(logDir, { recursive: true });
1329
+ previous = definition;
1330
+ try {
1331
+ runPrivileged("launchctl", ["unload", plistPath]);
1332
+ } catch {
1333
+ // Not loaded — the rewrite is still the point.
1334
+ }
1335
+ writePrivilegedFile(plistPath, upgraded);
1336
+ runPrivileged("launchctl", ["load", "-w", plistPath]);
1337
+ }
1338
+ } catch (err) {
1339
+ return refreshFailed(err instanceof Error ? err.message : String(err), previous);
1340
+ }
1341
+
1342
+ // The service was running when this started, so anything short of running
1343
+ // now is a regression this call caused — and on a daemon-configured machine
1344
+ // that is every tool call failing closed. A unit that parses but will not
1345
+ // start is exactly the case `systemctl restart`'s exit code does not catch,
1346
+ // so this is the check that hands it to the rollback.
1347
+ if (!(await waitForDaemonRunning())) {
1348
+ return refreshFailed(
1349
+ `the service definition was refreshed but failproofaid did not come back within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`,
1350
+ previous,
1351
+ );
1352
+ }
1353
+ return { outcome: "rewritten", daemonRunning: true };
1354
+ }
1355
+
542
1356
  /**
543
1357
  * Stops and removes the service, and clears the `daemonConfigured` marker
544
1358
  * so this machine goes back to in-process evaluation. Best-effort: never
@@ -599,6 +1413,89 @@ export async function uninstallDaemonService(): Promise<void> {
599
1413
  * file exists — a unit can be installed but crash-looped into a stopped
600
1414
  * state.
601
1415
  */
1416
+ /**
1417
+ * Whether the installed daemon is a different version than this CLI expects.
1418
+ *
1419
+ * The expected version is this CLI's OWN — no lookup, no registry call, no
1420
+ * network. `package.json` and `Cargo.toml` are held identical by CI, and every
1421
+ * downstream path (the download URL, the binary filename, the npm pin) is
1422
+ * derived from that one number, so a CLI can never want a daemon version that
1423
+ * was not published alongside it.
1424
+ *
1425
+ * Returns null when the question does not apply, and the exclusions are the
1426
+ * point:
1427
+ *
1428
+ * • FAILPROOFAI_DAEMON_BINARY — someone named a binary explicitly; its
1429
+ * version is their business, not ours to second-guess.
1430
+ * • a locally-built target/{release,debug} binary — the developer's own
1431
+ * build. Reporting it "stale" on every command would be noise about the
1432
+ * exact setup this repo tells contributors to use.
1433
+ *
1434
+ * Only the managed `~/.failproofai/bin/failproofaid-<version>` path is
1435
+ * checked, because it is the only one whose version we actually own.
1436
+ */
1437
+ export function daemonVersionSkew(): { installed: string; expected: string } | null {
1438
+ if (process.env.FAILPROOFAI_DAEMON_BINARY) return null;
1439
+ const recorded = readVersionFile()?.daemon;
1440
+ if (!recorded) return null;
1441
+ // Managed installs only — see the exclusions above.
1442
+ if (!existsSync(installedBinaryPath(recorded))) return null;
1443
+ return recorded === version ? null : { installed: recorded, expected: version };
1444
+ }
1445
+
1446
+ /**
1447
+ * Why a Linux unit that exists is not active: skipped on a condition, or
1448
+ * merely stopped.
1449
+ *
1450
+ * Only reached once `is-active` has already said "not active", so it costs a
1451
+ * second `systemctl` call on the unhealthy path and nothing at all on the
1452
+ * healthy one.
1453
+ *
1454
+ * `ConditionResult` is systemd's own record of the last condition evaluation
1455
+ * and is `no` only after it actually ran them and one failed. A unit that has
1456
+ * never been started since boot reports `yes` (the field's default), so this
1457
+ * cannot invent a `condition-failed` for a unit systemd has not judged — it
1458
+ * under-reports rather than over-reports, which is the safe direction for a
1459
+ * signal that clears `daemonConfigured`.
1460
+ */
1461
+ function inactiveLinuxStatus(): DaemonServiceStatus {
1462
+ try {
1463
+ return interpretConditionResult(
1464
+ execFileSync(
1465
+ "systemctl",
1466
+ ["show", systemdUnitName(), "--property=ConditionResult", "--value"],
1467
+ { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
1468
+ ).toString(),
1469
+ );
1470
+ } catch {
1471
+ // No systemd, no systemctl, or a version without `--value`. The unit file
1472
+ // exists, so "stopped" remains the honest answer — and it is the
1473
+ // conservative one, because nothing acts destructively on it.
1474
+ return "stopped";
1475
+ }
1476
+ }
1477
+
1478
+ /**
1479
+ * `systemctl show --property=ConditionResult --value` → a status.
1480
+ *
1481
+ * Split out from the subprocess so the interpretation can be tested without
1482
+ * `/etc/systemd/system`, which this code reads at a fixed path and no test may
1483
+ * write. The live behaviour — that systemd actually skips a unit whose
1484
+ * `ConditionPathExists=` fails, and records `no` when it does — is proven
1485
+ * against a real systemd in the container test rather than asserted here.
1486
+ *
1487
+ * Only a literal `no` means condition-failed. Anything else — `yes`, an empty
1488
+ * string from a unit systemd has not evaluated since boot, an unfamiliar word
1489
+ * from a future version — is "stopped", which is the state nothing acts
1490
+ * destructively on. This under-reports rather than over-reports on purpose: the
1491
+ * cost of a missed `condition-failed` is a flag cleared one command later, and
1492
+ * the cost of a false one is a healthy machine silently dropped to the
1493
+ * in-process path.
1494
+ */
1495
+ export function interpretConditionResult(raw: string): DaemonServiceStatus {
1496
+ return raw.trim() === "no" ? "condition-failed" : "stopped";
1497
+ }
1498
+
602
1499
  export function daemonServiceStatus(): DaemonServiceStatus {
603
1500
  if (!isDaemonSupportedPlatform()) return "unsupported-platform";
604
1501
 
@@ -613,14 +1510,14 @@ export function daemonServiceStatus(): DaemonServiceStatus {
613
1510
  })
614
1511
  .toString()
615
1512
  .trim();
616
- return out === "active" ? "running" : "stopped";
1513
+ return out === "active" ? "running" : inactiveLinuxStatus();
617
1514
  } catch {
618
1515
  // `systemctl is-active` exits non-zero (and execFileSync throws) for
619
1516
  // every non-"active" state — inactive, failed, or the command not
620
- // working at all (no systemd user session, systemctl missing). All
621
- // of those are indistinguishable from "stopped" from here, and the
622
- // unit file existing is what already ruled out "not-installed".
623
- return "stopped";
1517
+ // working at all (no systemd user session, systemctl missing). The unit
1518
+ // file existing already ruled out "not-installed", so the only question
1519
+ // left is whether systemd skipped it on a condition.
1520
+ return inactiveLinuxStatus();
624
1521
  }
625
1522
  }
626
1523
 
@@ -631,7 +1528,7 @@ export function daemonServiceStatus(): DaemonServiceStatus {
631
1528
  // `launchctl list` cannot see — unlike systemd, reading the state needs
632
1529
  // the same elevation installing it did.
633
1530
  const root = typeof process.getuid === "function" && process.getuid() === 0;
634
- const args = ["print", `system/${LAUNCHD_LABEL}`];
1531
+ const args = ["print", `system/${launchdLabel()}`];
635
1532
  const out = root
636
1533
  ? execFileSync("launchctl", args, {
637
1534
  stdio: ["ignore", "pipe", "ignore"],