failproofai 1.0.0-beta.0 → 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/{1grd7y94-igz9.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/{1d6ncspjagm53.js → 1fu90ch80qngv.js} +1 -1
  172. package/.next/standalone/.next/static/chunks/{2vr7aazw6k2-n.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/{29k6ef49zppxj.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/{29ydr-8q0zis6.js → 30ufy4_e2n78e.js} +22 -22
  180. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  181. package/.next/standalone/.next/static/chunks/{1s-_swlc80y43.js → 3p4j0l1o_p-nm.js} +1 -1
  182. package/.next/standalone/.next/static/chunks/{0ip1n8xmgxarv.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 +11484 -6046
  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 +915 -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 +1190 -92
  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]__0259h38._.js +0 -3
  296. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
  297. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
  298. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
  299. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
  300. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
  301. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
  302. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
  303. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
  304. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zj-87g._.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_1-upb14.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/0h7auy7hzjyhw.js +0 -1
  311. package/.next/standalone/.next/static/chunks/0u9hc6oxx-gpj.js +0 -6
  312. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  313. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  314. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  315. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  316. package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
  317. package/.next/standalone/.next/static/chunks/3wrgbih-516uj.js +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/{7Lxtd352rSaHB-pmm6G8M → pBBcTvE6_VueYGbacy1kg}/_buildManifest.js +0 -0
  356. /package/.next/standalone/.next/static/{7Lxtd352rSaHB-pmm6G8M → pBBcTvE6_VueYGbacy1kg}/_clientMiddlewareManifest.js +0 -0
  357. /package/.next/standalone/.next/static/{7Lxtd352rSaHB-pmm6G8M → pBBcTvE6_VueYGbacy1kg}/_ssgManifest.js +0 -0
@@ -1,24 +1,39 @@
1
1
  /**
2
2
  * `failproofai config` — the interactive setup launcher.
3
3
  *
4
- * A single guided flow that sets up the whole failproofai ecosystem, hiding the
5
- * scope / cli / two-layer machinery behind three plain questions:
6
- * 1. Where? global (user) vs this project
7
- * 2. Assistants? multi-select of agent CLIs (detected + install-ahead)
8
- * 3. Policies? multi-select of themed presets (combine any) or Everything
9
- * …then a Review screen that shows exactly which files change, and Apply.
4
+ * A single guided flow that sets up the whole failproofai ecosystem:
5
+ *
6
+ * 0. Daemon — REQUIRED. Asks for sudo first, on a clean terminal.
7
+ * 1. Where — inferred from cwd, then confirmed (global / project / both)
8
+ * 2. Policies — multi-select of themed presets (combine any) or Everything
9
+ * 3. Assistants— multi-select of agent CLIs (detected + install-ahead)
10
+ * 4. Connect — paste an API key, or stay fully local
11
+ * 5. Review — shows exactly which files change, then Apply.
10
12
  *
11
13
  * Selections REPLACE the enabled set at the chosen scope (the picker pre-checks
12
14
  * whatever is already enabled, so unticking removes). Reuses the tested
13
15
  * install/uninstall manager and the existing searchable policy picker.
16
+ *
17
+ * ## Two ordering rules that are not cosmetic
18
+ *
19
+ * **The daemon comes first because it is the only step that needs a password.**
20
+ * `sudo -v` must prompt on a clean terminal, before any TUI frame is drawn —
21
+ * fired from underneath a rendered screen the prompt is invisible and the typed
22
+ * password lands in a redrawn frame.
23
+ *
24
+ * **The daemon is INSTALLED first, before any user config is written.** Setup
25
+ * requires it, so a failure has to leave the machine exactly as it was found
26
+ * rather than half-configured. Writing hooks first and discovering the service
27
+ * will not start afterwards is the one ordering that cannot be undone cleanly.
14
28
  */
15
29
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
16
30
  import { homedir } from "node:os";
17
- import { resolve, sep } from "node:path";
31
+ import { dirname, resolve, sep } from "node:path";
18
32
 
19
33
  import {
20
34
  selectOne,
21
35
  multiSelect,
36
+ promptText,
22
37
  intro,
23
38
  outro,
24
39
  summarize,
@@ -26,6 +41,12 @@ import {
26
41
  type TTYIn,
27
42
  type TTYOut,
28
43
  } from "./tui";
44
+ import {
45
+ DEFAULT_INGEST_URL,
46
+ validateIngestKey,
47
+ writeIngestCredential,
48
+ writeCollectorSettings,
49
+ } from "./collector-config";
29
50
  import {
30
51
  detectInstalledClis,
31
52
  getIntegration,
@@ -40,23 +61,84 @@ import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-load
40
61
  import { trackHookEvent } from "./hook-telemetry";
41
62
  import { getInstanceId } from "../../lib/telemetry-id";
42
63
  import {
64
+ canElevate,
43
65
  isDaemonSupportedPlatform,
44
66
  installDaemonService,
45
67
  daemonServiceFilePath,
68
+ daemonServiceStatus,
69
+ daemonServiceNeedsUpgrade,
70
+ daemonStatusCommand,
71
+ ensureDaemonServiceCurrent,
72
+ daemonVersionSkew,
73
+ primeElevation,
46
74
  setDaemonConfigured,
75
+ probeDaemon,
76
+ probeDaemonEndToEnd,
77
+ uninstallDaemonService,
47
78
  } from "./daemon-service";
48
79
  import { hookLogWarn } from "./hook-logger";
80
+ import {
81
+ readCloudCredentials,
82
+ resolveMachineId,
83
+ resolveMachineLabel,
84
+ validateCloudUrl,
85
+ verifyCloudCredentials,
86
+ writeCloudCredentials,
87
+ } from "./cloud-enrollment";
88
+ import {
89
+ cloudBaseFor,
90
+ ingestUrlFor,
91
+ connectToCloud,
92
+ describeOutcome,
93
+ } from "./cloud-connection";
94
+ import {
95
+ detectSetupState,
96
+ isConfigured,
97
+ buildTargetChoices,
98
+ scopesFor,
99
+ type SetupTarget,
100
+ } from "./setup-state";
101
+ import { customPoliciesDir, launcherMarker } from "./fp-home";
102
+ import { pruneOldDaemonBinaries } from "./daemon-download";
103
+ import { version as cliVersion } from "../../package.json";
104
+ import {
105
+ attemptHintLines,
106
+ blockerCleared,
107
+ clearOnboardingAttempt,
108
+ readOnboardingAttempt,
109
+ recordOnboardingAttempt,
110
+ type RetryProbe,
111
+ } from "./onboarding-attempt";
112
+ import { acquireOnboardingLock } from "./onboarding-lock";
49
113
 
50
114
  export interface WizardIO {
51
115
  stdin?: TTYIn;
52
116
  stdout?: TTYOut;
53
117
  }
54
118
 
119
+ /**
120
+ * Why a wizard run ended without applying. Distinguished so the caller can
121
+ * pick an exit code — a user who pressed Esc did nothing wrong (exit 0), a
122
+ * machine that could not install the required daemon did not get set up
123
+ * (exit 1), and a fleet script needs to tell those apart.
124
+ */
125
+ export type WizardAbort =
126
+ | "cancelled"
127
+ | "needs_root"
128
+ | "daemon_failed"
129
+ | "not_a_tty"
130
+ | "running_as_sudo";
131
+
55
132
  export interface WizardResult {
56
133
  applied: boolean;
57
- scope?: HookScope;
134
+ /** Present only when `applied` is false. */
135
+ abort?: WizardAbort;
136
+ target?: SetupTarget;
137
+ scopes?: HookScope[];
58
138
  clis?: IntegrationType[];
59
139
  policies?: string[];
140
+ daemonInstalled?: boolean;
141
+ connected?: boolean;
60
142
  }
61
143
 
62
144
  async function emit(event: string, props: Record<string, unknown>): Promise<void> {
@@ -79,21 +161,6 @@ function homeify(p: string): string {
79
161
 
80
162
  // ── Pure builders (exported for tests) ───────────────────────────────────────
81
163
 
82
- export function buildScopeChoices(cwd: string) {
83
- return [
84
- {
85
- label: "Everywhere I code",
86
- value: "user" as HookScope,
87
- hint: "global · applies in every project on this machine",
88
- },
89
- {
90
- label: "Just this project",
91
- value: "project" as HookScope,
92
- hint: homeify(cwd),
93
- },
94
- ];
95
- }
96
-
97
164
  /** The CLIs that can actually be configured at `scope`. Hermes and OpenClaw
98
165
  * are gateways with no project-level config, so they are user-scope only. */
99
166
  export function clisSupportingScope(scope: HookScope): IntegrationType[] {
@@ -289,6 +356,10 @@ export function classifyDaemonInstallFailure(reason: string | undefined): string
289
356
  // signal, "disabled" is a deliberate air-gapped opt-out, and a plain fetch
290
357
  // failure is usually a proxy or an offline box — three very different
291
358
  // stories that would otherwise all land in `service_manager_error`.
359
+ // The service is installed system-wide now, so "couldn't become root" is a
360
+ // first-class outcome with a specific remedy (re-run under sudo) rather
361
+ // than an opaque service_manager_error.
362
+ if (/root privileges are required/.test(reason)) return "needs_root";
292
363
  if (/checksum mismatch|has no entry for/.test(reason)) return "checksum_mismatch";
293
364
  if (/downloads are disabled/.test(reason)) return "downloads_disabled";
294
365
  if (/failed to download/.test(reason)) return "download_failed";
@@ -320,7 +391,12 @@ export function describeCustomPolicies(cwd: string): {
320
391
  let fileCount = 0;
321
392
  const dirs: Array<{ dir: string; label: string }> = [
322
393
  { dir: resolve(cwd, ".failproofai", "policies"), label: "project" },
323
- { dir: resolve(homedir(), ".failproofai", "policies"), label: "global" },
394
+ // `customPoliciesDir()`, not layout 1's `~/.failproofai/policies`. This
395
+ // scanned the old location while `custom-hooks-loader.ts` loads from the
396
+ // new one, so the wizard reported "no personal policies" to users whose
397
+ // policies were being enforced, and would have reported the opposite after
398
+ // the layout reset moved them.
399
+ { dir: customPoliciesDir(), label: "global" },
324
400
  ];
325
401
  for (const { dir, label } of dirs) {
326
402
  const found = discoverPolicyFiles(dir);
@@ -340,26 +416,52 @@ export function describeCustomPolicies(cwd: string): {
340
416
  }
341
417
 
342
418
  export function reviewLines(state: {
343
- scope: HookScope;
419
+ /** What the scope step resolved to. Expands to one or two real scopes. */
420
+ target: SetupTarget;
344
421
  clis: IntegrationType[];
345
422
  policies: string[];
346
423
  cwd: string;
347
424
  /** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
348
425
  customEnabled?: boolean;
426
+ /**
427
+ * Whether a daemon will be installed by this run. False when one is already
428
+ * healthy (nothing to do) or the platform has no service manager — showing
429
+ * it any other way would promise work the apply will not perform.
430
+ */
431
+ installDaemon?: boolean;
432
+ /** Whether an API key will be written and reporting turned on. */
433
+ connect?: boolean;
349
434
  }): string[] {
350
- const { scope, clis, policies, cwd, customEnabled } = state;
435
+ const { target, clis, policies, cwd, customEnabled, installDaemon, connect } = state;
436
+ const scopes = scopesFor(target);
351
437
  const where =
352
- scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)";
438
+ target === "both"
439
+ ? `Global, plus this project (${homeify(cwd)})`
440
+ : target === "project"
441
+ ? `This project (${homeify(cwd)})`
442
+ : "Everywhere (global)";
353
443
  const lines: string[] = [];
354
444
  const assistantNames = clis.map((c) => getIntegration(c).displayName);
355
445
  lines.push(` Where : ${where}`);
356
446
  lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
357
- lines.push(` Policies : ${policies.length} enabled`);
358
- if (scope === "user" && isDaemonSupportedPlatform()) {
447
+ // Zero is a deliberate answer, not a failed step — say so, and say where to
448
+ // change it, so the review screen doesn't read like the wizard lost the
449
+ // selection. Hooks still install; only the builtin set is empty.
450
+ lines.push(
451
+ policies.length === 0
452
+ ? " Policies : none enabled (add later: failproofai policies --install)"
453
+ : ` Policies : ${policies.length} enabled`,
454
+ );
455
+ if (installDaemon && isDaemonSupportedPlatform()) {
359
456
  lines.push(
360
- ` Daemon : failproofaid will be installed/started as a background service`,
457
+ ` Daemon : failproofaid, installed as a system service running as you`,
361
458
  );
362
459
  }
460
+ lines.push(
461
+ connect
462
+ ? " Reporting : on — policy decisions and session transcripts"
463
+ : " Reporting : off — nothing leaves this machine",
464
+ );
363
465
 
364
466
  // Reflect the Custom decision, not just what is on disk. Reporting
365
467
  // "1 file (project) (auto-loaded)" after the user had just unticked the row
@@ -376,18 +478,32 @@ export function reviewLines(state: {
376
478
 
377
479
  lines.push("");
378
480
  lines.push(" This will update:");
379
- for (const cli of clis) {
380
- const integration = getIntegration(cli);
381
- // Usually one path; Hermes lists one per profile so the operator sees every
382
- // home dir that is about to be written.
383
- for (const p of settingsPathsFor(integration, scope, cwd)) {
384
- lines.push(` ${homeify(p)} ${integration.displayName} hooks`);
481
+ // Deduplicated across scopes: a CLI that supports only user scope resolves to
482
+ // the same settings file under both halves of "Both", and listing it twice
483
+ // reads as two separate writes.
484
+ const seen = new Set<string>();
485
+ for (const scope of scopes) {
486
+ for (const cli of clis) {
487
+ const integration = getIntegration(cli);
488
+ if (!integration.scopes.includes(scope)) continue;
489
+ // Usually one path; Hermes lists one per profile so the operator sees
490
+ // every home dir that is about to be written.
491
+ for (const p of settingsPathsFor(integration, scope, cwd)) {
492
+ if (seen.has(p)) continue;
493
+ seen.add(p);
494
+ lines.push(` ${homeify(p)} ${integration.displayName} hooks`);
495
+ }
385
496
  }
386
497
  }
387
- lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`);
388
- if (scope === "user" && isDaemonSupportedPlatform()) {
498
+ for (const scope of scopes) {
499
+ const configPath = getConfigPathForScope(scope, cwd);
500
+ if (seen.has(configPath)) continue;
501
+ seen.add(configPath);
502
+ lines.push(` ${homeify(configPath)} ${policies.length} policies`);
503
+ }
504
+ if (installDaemon && isDaemonSupportedPlatform()) {
389
505
  const servicePath = daemonServiceFilePath();
390
- if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service`);
506
+ if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service (needs root)`);
391
507
  }
392
508
  return lines;
393
509
  }
@@ -395,7 +511,7 @@ export function reviewLines(state: {
395
511
  // ── First-run redirect ───────────────────────────────────────────────────────
396
512
 
397
513
  function firstRunMarkerPath(): string {
398
- return resolve(homedir(), ".failproofai", ".launcher-configured");
514
+ return launcherMarker();
399
515
  }
400
516
 
401
517
  export function hasSeenLauncher(): boolean {
@@ -404,30 +520,16 @@ export function hasSeenLauncher(): boolean {
404
520
 
405
521
  export function markLauncherSeen(): void {
406
522
  try {
407
- mkdirSync(resolve(homedir(), ".failproofai"), { recursive: true });
523
+ // Layout 2 puts this under state/ with the rest of the daemon-adjacent
524
+ // markers, so the parent is a directory deeper than the home and has to be
525
+ // created rather than assumed.
526
+ mkdirSync(dirname(firstRunMarkerPath()), { recursive: true });
408
527
  writeFileSync(firstRunMarkerPath(), "1", "utf8");
409
528
  } catch {
410
529
  // best-effort
411
530
  }
412
531
  }
413
532
 
414
- /**
415
- * Whether failproofai is already set up GLOBALLY (user scope) for any agent.
416
- * Deliberately ignores project scope: project-scoped hooks in whatever repo the
417
- * user happens to be in shouldn't suppress the one-time global welcome. The
418
- * marker file is the primary "seen" gate; this is the "already set up" shortcut.
419
- */
420
- function anyHooksInstalledGlobally(): boolean {
421
- for (const id of INTEGRATION_TYPES) {
422
- try {
423
- if (getIntegration(id).hooksInstalledInSettings("user")) return true;
424
- } catch {
425
- // ignore broken settings files
426
- }
427
- }
428
- return false;
429
- }
430
-
431
533
  /**
432
534
  * On the FIRST bare `failproofai` invocation, redirect the user into the
433
535
  * configure wizard instead of the dashboard. Returns true when it handled the
@@ -439,45 +541,139 @@ function anyHooksInstalledGlobally(): boolean {
439
541
  * • non-TTY (CI/pipe) → print a one-line hint, go to dashboard
440
542
  * • fresh + TTY → mark seen, run the wizard, done
441
543
  */
442
- export async function maybeFirstRunConfigure(io: WizardIO = {}): Promise<boolean> {
544
+ export interface FirstRunOptions {
545
+ /**
546
+ * Run the post-setup audit after a completed apply. The caller sets this to
547
+ * false when the command it is about to run is `audit` itself, which would
548
+ * otherwise scan the entire history twice back to back.
549
+ */
550
+ postSetupAudit?: boolean;
551
+ /**
552
+ * Run setup even though the machine reads as already configured.
553
+ *
554
+ * Set by the caller after a layout reset. `isConfigured()` is a union that
555
+ * counts live user-scope hooks in any agent CLI, and the reset deliberately
556
+ * leaves those settings files alone — so a machine whose policy config was
557
+ * just deleted still answers "configured", the wizard is skipped, and
558
+ * `markLauncherSeen()` below back-fills the marker so every later run skips
559
+ * it too. The result is hooks firing on every tool call against no policies,
560
+ * with nothing to say so.
561
+ */
562
+ force?: boolean;
563
+ }
564
+
565
+ /**
566
+ * The live values `blockerCleared` compares an earlier attempt against.
567
+ *
568
+ * Built here rather than imported into `onboarding-attempt.ts` so that module
569
+ * stays pure and unit-testable without a service manager or a sudo binary.
570
+ */
571
+ function retryProbe(): RetryProbe {
572
+ return {
573
+ canElevate: () => {
574
+ try {
575
+ return canElevate();
576
+ } catch {
577
+ // No sudo binary at all is a blocker that has not cleared.
578
+ return false;
579
+ }
580
+ },
581
+ daemonStatus: () => {
582
+ try {
583
+ return daemonServiceStatus();
584
+ } catch {
585
+ return "";
586
+ }
587
+ },
588
+ cliVersion,
589
+ };
590
+ }
591
+
592
+ export async function maybeFirstRunConfigure(
593
+ io: WizardIO = {},
594
+ opts: FirstRunOptions = {},
595
+ ): Promise<boolean> {
443
596
  if (process.env.FAILPROOFAI_NO_FIRST_RUN === "1") return false;
444
- if (hasSeenLauncher()) return false;
445
597
 
446
598
  const stdin: TTYIn = io.stdin ?? process.stdin;
447
599
  const stdout: TTYOut = io.stdout ?? process.stdout;
448
600
 
449
- if (anyHooksInstalledGlobally()) {
450
- markLauncherSeen();
601
+ // One state read covering all three "already set up" signals — a config
602
+ // file, live user-scope hooks, or the legacy marker. See `isConfigured`.
603
+ const state = detectSetupState();
604
+ if (isConfigured(state) && !opts.force) {
605
+ // Back-fill the marker for a machine that is demonstrably configured but
606
+ // predates it, so later runs settle this with a single stat instead of
607
+ // walking every integration's settings file on every invocation.
608
+ if (!state.hasLegacyMarker) markLauncherSeen();
609
+ return false;
610
+ }
611
+
612
+ // A previous attempt that ABORTED. Setup writes nothing on those paths, by
613
+ // design, so without this record the machine is indistinguishable from one
614
+ // that has never been offered setup — and the wizard relaunches on every
615
+ // command forever. `--force` (an explicit `failproofai config`) never reaches
616
+ // here, so asking for setup by name always gets it.
617
+ const attempt = opts.force ? null : readOnboardingAttempt();
618
+ if (attempt && !blockerCleared(attempt, retryProbe())) {
619
+ for (const line of attemptHintLines(attempt)) stdout.write(`${line}\n`);
451
620
  return false;
452
621
  }
453
622
 
454
623
  if (!stdin.isTTY || !stdout.isTTY) {
624
+ // Never launch a wizard nobody can answer. This is the CI / piped path,
625
+ // and it must stay a hint rather than a failure: the command the user
626
+ // actually typed still runs.
455
627
  stdout.write(
456
628
  `\n[failproofai] Not set up yet — run \`failproofai config\` to get started.\n\n`,
457
629
  );
458
630
  return false;
459
631
  }
460
632
 
461
- // Fire-and-forget: never block the wizard's first paint on telemetry.
462
- void emit("first_run_configure_shown", {});
463
- // runConfigureWizard marks the launcher as seen only if the user completes an
464
- // apply — so cancelling keeps redirecting here on the next bare run, and only
465
- // a finished setup sends the user to the dashboard afterwards.
466
- const result = await runConfigureWizard(io);
633
+ // Onboarding now fires on ANY command, so two terminals on a fresh machine
634
+ // is a real shape: both would draw a wizard, race on the same settings
635
+ // files, and both try to install the one system service. Only one gets to.
636
+ const lock = acquireOnboardingLock();
637
+ if (!lock) {
638
+ stdout.write(
639
+ `\n[failproofai] Setup is already running in another terminal — leaving it to finish.\n\n`,
640
+ );
641
+ return false;
642
+ }
643
+
644
+ try {
645
+ // Fire-and-forget: never block the wizard's first paint on telemetry.
646
+ void emit("first_run_configure_shown", {});
647
+ // runConfigureWizard marks the launcher as seen only if the user completes
648
+ // an apply — so cancelling keeps offering setup on the next run rather
649
+ // than silently never mentioning it again.
650
+ const result = await runConfigureWizard(io);
651
+ // Remember WHY, so the next command can hint instead of relaunching. Only
652
+ // on an abort: a completed apply clears the record below.
653
+ if (!result.applied && result.abort) {
654
+ recordOnboardingAttempt(result.abort, cliVersion, daemonServiceStatus());
655
+ }
467
656
 
468
657
  // Onboarding-only: after a completed first-run setup, run the audit pipeline
469
658
  // (scan + cache warm) before the caller boots the dashboard. The explicit
470
659
  // `failproofai config` command does NOT do this — only this first-run path.
471
660
  // Lazy-imported + best-effort; opt out with FAILPROOFAI_NO_AUTO_AUDIT=1.
472
- if (result.applied) {
473
- try {
474
- const { runPostSetupAudit } = await import("../audit/cli");
475
- await runPostSetupAudit();
476
- } catch {
477
- // the audit is a bonus — never let it break onboarding or the dashboard.
661
+ if (result.applied && opts.postSetupAudit !== false) {
662
+ try {
663
+ const { runPostSetupAudit } = await import("../audit/cli");
664
+ await runPostSetupAudit();
665
+ } catch {
666
+ // the audit is a bonus — never let it break onboarding or the dashboard.
667
+ }
478
668
  }
669
+ return true;
670
+ } finally {
671
+ // Released on every path, including a throw from the wizard itself —
672
+ // otherwise a crash mid-setup would leave a lock behind, and although the
673
+ // liveness check reclaims it, doing so needs the next run to reach that
674
+ // check rather than relying on it.
675
+ lock.release();
479
676
  }
480
- return true;
481
677
  }
482
678
 
483
679
  // ── The wizard ───────────────────────────────────────────────────────────────
@@ -496,25 +692,219 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
496
692
  return { applied: false };
497
693
  }
498
694
 
695
+ // Running the wizard itself under sudo configures the WRONG ACCOUNT, and
696
+ // does it silently: homedir() becomes /root, so the hooks land in root's
697
+ // settings, `daemonConfigured` is set for root, the daemon binary downloads
698
+ // to /root/.failproofai/bin, and the unit is generated with User=root —
699
+ // exactly the elevation the design exists to avoid. SUDO_USER is set only
700
+ // when a real user sudo'd here, which distinguishes this mistake from a
701
+ // legitimately root-only environment (a container that has no other user).
702
+ if (typeof process.getuid === "function" && process.getuid() === 0 && process.env.SUDO_USER) {
703
+ stdout.write(
704
+ `Run failproofai config as ${process.env.SUDO_USER}, not under sudo.\n` +
705
+ "Everything it configures is per-user — hooks, policies and the daemon's own\n" +
706
+ "account — so under sudo it would set all of that up for root instead of you.\n" +
707
+ "The one step that needs root (installing the service) asks for your password\n" +
708
+ "on its own.\n",
709
+ );
710
+ return { applied: false };
711
+ }
712
+
499
713
  // Fire-and-forget: never block the wizard's first paint on telemetry.
500
714
  void emit("configure_started", {});
501
715
  intro("let's set up your safety net", stdout);
502
716
 
503
717
  const cancel = (): WizardResult => {
504
718
  outro("Cancelled — nothing was changed.", { ok: false }, stdout);
505
- return { applied: false };
719
+ // Distinguished from the abort reasons: pressing Esc is not a failure, and
720
+ // a caller picking an exit code must not treat it as one.
721
+ return { applied: false, abort: "cancelled" };
506
722
  };
507
723
 
508
- // 1 — Where?
509
- const scope = await selectOne<HookScope>({
510
- message: "Where should this apply?",
511
- choices: buildScopeChoices(cwd),
724
+ // 0 — The background daemon. REQUIRED, and before anything else.
725
+ //
726
+ // First because it is the only step that needs a password: asking here means
727
+ // sudo prompts on a clean terminal, before any question has drawn a screen,
728
+ // instead of firing from underneath a rendered TUI where the prompt is
729
+ // invisible and the typed password lands in a redrawn frame. `sudo -v`
730
+ // caches the credential for the rest of the run, so the actual install at
731
+ // apply time stays non-interactive.
732
+ //
733
+ // Machine-level, so it is deliberately NOT gated on the scope chosen in the
734
+ // next step: one daemon serves every project on this machine.
735
+ //
736
+ // On a platform with no service manager there is nothing to install, so the
737
+ // requirement does not apply — requiring an impossible step would lock those
738
+ // users out of setup entirely rather than protecting anything.
739
+ const daemonSupported = isDaemonSupportedPlatform();
740
+ // An already-healthy daemon needs no install and no password. Re-running
741
+ // setup on a configured machine must not demand sudo for work that is
742
+ // already done.
743
+ // "running" only. A unit that exists but is stopped or crash-looping is
744
+ // exactly the machine that needs this run to reinstall it, and treating
745
+ // "installed" as good enough would skip the repair and then set
746
+ // `daemonConfigured` against a service that is not answering — which fails
747
+ // closed on every tool call.
748
+ // Running is not enough — it must also be the version this CLI ships.
749
+ //
750
+ // Skipping on "running" alone was right for the case it was written for
751
+ // (never demand a password for work already done), and exactly backwards
752
+ // during an upgrade: the OLD daemon is perfectly healthy, so setup skipped
753
+ // it and the stale version survived. That made "just re-run config" — the
754
+ // remedy every message points at — silently do nothing.
755
+ //
756
+ // And running the right VERSION is still not enough: it must be able to
757
+ // answer. `ExecStart` bakes in `process.execPath` and an absolute
758
+ // `dist/worker.mjs`, so an `nvm uninstall 20` after setup leaves a unit
759
+ // systemd calls active whose worker dies on every spawn. That machine reads
760
+ // as "already running", so this wizard — the documented remedy, and the only
761
+ // caller that can rebuild the unit — skipped it and left the box denying
762
+ // every tool call with no route back but hand-editing `config.toml`. A real
763
+ // hook evaluation is the only check that distinguishes the two.
764
+ const daemonSkew = daemonSupported ? daemonVersionSkew() : null;
765
+ const daemonUpToDate =
766
+ daemonSupported && daemonServiceStatus() === "running" && daemonSkew === null;
767
+ const daemonAnswers = daemonUpToDate ? await probeDaemonEndToEnd() : false;
768
+ const daemonAlreadyRunning = daemonUpToDate && daemonAnswers;
769
+ /** Installed and running, but its worker cannot evaluate anything. */
770
+ const daemonBroken = daemonUpToDate && !daemonAnswers;
771
+ let daemonWanted = daemonSupported && !daemonAlreadyRunning;
772
+ // A healthy daemon can still be running a service definition written before
773
+ // FAILPROOFAI_CLI_CMD existed, and nothing else on the machine will ever
774
+ // rewrite it: upgrading the npm package does not touch /etc/systemd/system.
775
+ // Re-running setup is the one moment a user asks for their configuration to
776
+ // be brought up to date, so it is the moment to do it.
777
+ let daemonUnitStale = daemonAlreadyRunning && daemonServiceNeedsUpgrade();
778
+
779
+ if (daemonWanted) {
780
+ stdout.write(
781
+ daemonBroken
782
+ ? "failproofaid is installed and running but cannot evaluate policies — its worker\n" +
783
+ "process will not start, which on this machine denies every tool call. Rebuilding\n" +
784
+ "the service needs root once. Your password goes to sudo, never to us.\n\n"
785
+ : "failproofai runs a small background service (failproofaid) so policy checks\n" +
786
+ "stay warm — without it every tool call pays a fresh startup, about 15x slower.\n" +
787
+ "Installing it needs root once. Your password goes to sudo, never to us.\n\n",
788
+ );
789
+ if (!primeElevation()) {
790
+ // Required means required: write nothing at all, so a machine that could
791
+ // not be set up is left exactly as it was found rather than carrying
792
+ // half a configuration. The commands are printed so an admin can do the
793
+ // privileged half by hand.
794
+ stdout.write(
795
+ "\nCould not get root, so setup stopped before changing anything.\n\n" +
796
+ " Re-run once you can use sudo: failproofai config\n" +
797
+ ` Check what it needs: ${daemonStatusCommand() ?? "n/a"}\n\n`,
798
+ );
799
+ void emit("configure_aborted", { reason: "needs_root" });
800
+ outro("Nothing was changed.", { ok: false }, stdout);
801
+ return { applied: false, abort: "needs_root" };
802
+ }
803
+ } else if (daemonUnitStale) {
804
+ stdout.write(
805
+ "failproofaid is running, but from a service definition written by an older\n" +
806
+ "version — it cannot start a scheduled audit. Refreshing it needs root once.\n\n",
807
+ );
808
+ if (!primeElevation()) {
809
+ // NOT an abort, unlike the install branch above. There is a working
810
+ // daemon here and hooks are enforcing; only the scheduled audit is out
811
+ // of reach. Stopping setup over that would make an upgrade the thing
812
+ // that locked someone out of `failproofai config`.
813
+ stdout.write(
814
+ "\nCould not get root, so the service definition was left as it is.\n" +
815
+ "Everything else is set up as normal; scheduled audits stay off until it is\n" +
816
+ `refreshed. Re-run \`failproofai config\` once you can use sudo.\n\n`,
817
+ );
818
+ daemonUnitStale = false;
819
+ }
820
+ } else if (daemonAlreadyRunning) {
821
+ stdout.write("failproofaid is already installed and running — leaving it alone.\n\n");
822
+ }
823
+ if (daemonSkew) {
824
+ stdout.write(
825
+ `failproofaid is ${daemonSkew.installed} but this CLI ships ${daemonSkew.expected} — ` +
826
+ `reinstalling it.\n\n`,
827
+ );
828
+ }
829
+
830
+ // 1 — Where? Inferred from cwd, then confirmed.
831
+ //
832
+ // Running from inside a project and running from a home directory are two
833
+ // different intents, and asking a context-free "global or project?" made the
834
+ // user restate something they had already expressed by choosing where to run
835
+ // the command. So the choices are built from what actually exists here — and
836
+ // labelled Update vs Set up accordingly — with the likelier target first.
837
+ const setupState = detectSetupState(cwd);
838
+ const targetChoices = buildTargetChoices(setupState);
839
+
840
+ let target: SetupTarget;
841
+ if (targetChoices.length === 1) {
842
+ // From a home directory there is no project to configure, so there is no
843
+ // question to ask. Say what is about to happen rather than silently
844
+ // deciding it.
845
+ target = targetChoices[0].value;
846
+ stdout.write(`Configuring ${targetChoices[0].label.toLowerCase()} — ${targetChoices[0].hint}.\n\n`);
847
+ } else {
848
+ const chosen = await selectOne<SetupTarget>({
849
+ message: "What are we configuring?",
850
+ choices: targetChoices.map((c) => ({
851
+ label: c.label,
852
+ value: c.value,
853
+ hint: c.hint,
854
+ })),
855
+ stdin,
856
+ stdout,
857
+ });
858
+ if (chosen === null) return cancel();
859
+ target = chosen;
860
+ }
861
+ const scopes = scopesFor(target);
862
+ // The scope whose CURRENT state seeds the pickers below. With "Both" the
863
+ // project is the more specific of the two and the one the user is standing
864
+ // in, so it wins; anything it does not define still falls back to global at
865
+ // merge time, which is exactly the layering the policy loader already does.
866
+ const primaryScope: HookScope = scopes.includes("project") ? "project" : "user";
867
+
868
+ // 2 — Which policies? Multi-select of themed presets — additive, so the
869
+ // enabled set is the union of every ticked bundle.
870
+ //
871
+ // Before the assistants step, because "what do you want guarded" is the
872
+ // question the user came here to answer; which CLIs to wire it into is
873
+ // plumbing that follows from it.
874
+ //
875
+ // Seed the Custom checkbox from whatever the config already says, so the
876
+ // wizard shows the current state rather than resetting it every run.
877
+ const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
878
+ const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
879
+ const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
880
+
881
+ // No minimum. Ticking nothing is a real answer — someone who only wants their
882
+ // own custom policies, or who intends to pick bundles later from the
883
+ // dashboard, was previously stuck on this step with no way forward and no
884
+ // explanation beyond "Select at least 1". An empty set is already supported
885
+ // end to end: `installHooksImpl` documents its explicit-array path as "may be
886
+ // empty", `replace: true` makes it the full enabled set, and `summarize([])`
887
+ // renders "none". Hooks still install, so enforcement can be switched on
888
+ // later without re-running setup.
889
+ //
890
+ // The assistants step below keeps its minimum deliberately: an empty CLI list
891
+ // does NOT mean "no assistants" there — `installHooksImpl` falls back to
892
+ // ["claude"], so letting it through would silently install for Claude.
893
+ const presets = await multiSelect<string>({
894
+ message: "What should we guard against?",
895
+ choices: presetChoices,
896
+ summaryNoun: "bundles",
897
+ hint: "space toggles · combine presets · ↵ confirm · none is fine",
512
898
  stdin,
513
899
  stdout,
514
900
  });
515
- if (scope === null) return cancel();
901
+ if (presets === null) return cancel();
902
+ const policies = resolvePresetSelection(presets);
903
+ // Only meaningful when there are files to switch off; with none, the row is
904
+ // locked-unchecked and must not write a disabling flag.
905
+ const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
516
906
 
517
- // 2 — Which assistants? An "Everything available" row protects every supported
907
+ // 3 — Which assistants? An "Everything available" row protects every supported
518
908
  // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes.
519
909
  const clisSel = await multiSelect<string>({
520
910
  message: "Which AI assistants should it protect?",
@@ -524,12 +914,12 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
524
914
  value: ALL_CLIS,
525
915
  // Counts only what this scope can actually take — expanding to all 12
526
916
  // under project scope is what crashed the apply on Hermes.
527
- hint: `protect all ${clisSupportingScope(scope).length} CLIs configurable here`,
917
+ hint: `protect all ${clisSupportingScope(primaryScope).length} CLIs configurable here`,
528
918
  // A selector, not an assistant. Counting it gave "13 assistants" for
529
919
  // the 12 supported CLIs, and listed "Everything available" among them.
530
920
  summaryExclude: true,
531
921
  },
532
- ...buildAgentChoices(scope, cwd),
922
+ ...buildAgentChoices(primaryScope, cwd),
533
923
  ],
534
924
  minSelected: 1,
535
925
  summaryNoun: "assistants",
@@ -538,44 +928,209 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
538
928
  stdout,
539
929
  });
540
930
  if (clisSel === null) return cancel();
541
- // Filter to what this scope supports in BOTH branches: "Everything
542
- // available" must not expand to CLIs that cannot take this scope, and a
543
- // locked row can't be ticked but belt-and-braces keeps the invariant local
544
- // to the one place `clis` is built.
545
- const supported = new Set(clisSupportingScope(scope));
931
+ // Filter to what the chosen scopes support in BOTH branches: "Everything
932
+ // available" must not expand to CLIs that cannot take any selected scope,
933
+ // and a locked row can't be ticked but belt-and-braces keeps the invariant
934
+ // local to the one place `clis` is built.
935
+ //
936
+ // The union across scopes, not the intersection: under "Both", a user-scope-
937
+ // only gateway like Hermes is still installable via the user half, and
938
+ // dropping it because project scope cannot take it would silently protect
939
+ // less than the user asked for. `installHooks` is called per scope below and
940
+ // skips what a given scope cannot take.
941
+ const supported = new Set(scopes.flatMap((s) => clisSupportingScope(s)));
546
942
  const clis: IntegrationType[] = (
547
943
  clisSel.includes(ALL_CLIS)
548
944
  ? [...INTEGRATION_TYPES]
549
945
  : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[])
550
946
  ).filter((id) => supported.has(id));
551
947
 
552
- // 3 — Which policies? Multi-select of themed presets — additive, so the
553
- // enabled set is the union of every ticked bundle.
554
- // Seed the Custom checkbox from whatever the config already says, so the
555
- // wizard shows the current state rather than resetting it every run.
556
- const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
557
- const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
558
- const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
948
+ // 4 — Connect this machine? Last, because by this point the user has decided
949
+ // what to protect, so "would you like to see it in a dashboard?" follows
950
+ // naturally — asking up front interrupts setup with a question about a
951
+ // product they may not have.
952
+ //
953
+ // A pasted API key rather than an interactive sign-in: it is the only form
954
+ // that works on a headless box, in a container or over SSH, and it is the
955
+ // same credential `failproofai config --connect` takes, so a machine set up
956
+ // by the wizard and one set up by hand end up byte-identical on disk.
957
+ //
958
+ // Connecting turns on BOTH streams — policy decisions and session
959
+ // transcripts. That is a real disclosure, not a footnote, so it is stated in
960
+ // the body of the question itself rather than buried in an option hint.
961
+ let connect: { url: string; token: string; machineId: string; machineLabel: string } | null = null;
559
962
 
560
- const presets = await multiSelect<string>({
561
- message: "What should we guard against?",
562
- choices: presetChoices,
563
- minSelected: 1,
564
- summaryNoun: "bundles",
565
- hint: "space toggles · combine presets · ↵ confirm",
566
- stdin,
567
- stdout,
568
- });
569
- if (presets === null) return cancel();
570
- const policies = resolvePresetSelection(presets);
571
- // Only meaningful when there are files to switch off; with none, the row is
572
- // locked-unchecked and must not write a disabling flag.
573
- const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
963
+ {
964
+ const choice = await selectOne<"key" | "local">({
965
+ message: "Connect this machine to Failproof Cloud?",
966
+ body: [
967
+ " Connecting reports this machine's policy decisions AND full session",
968
+ " transcripts — prompts, file contents and command output — to your",
969
+ " dashboard. Staying local sends nothing, anywhere, ever.",
970
+ ],
971
+ choices: [
972
+ {
973
+ label: "Not now — stay local",
974
+ value: "local",
975
+ hint: "policies still enforce · connect later with failproofai config --connect",
976
+ },
977
+ {
978
+ label: "Paste an API key",
979
+ value: "key",
980
+ hint: "reports decisions and transcripts to your dashboard",
981
+ },
982
+ ],
983
+ stdin,
984
+ stdout,
985
+ });
986
+ if (choice === null) return cancel();
987
+
988
+ if (choice === "key") {
989
+ // An already-enrolled machine has a URL and token that usually work.
990
+ // Asking again is the seam that made connecting feel like two products.
991
+ const existing = readCloudCredentials();
992
+ let url: string | null = null;
993
+ let token: string | null = null;
994
+ // Reuse the enrolled id if there is one, else mint a stable key — never
995
+ // the hostname, so two hosts with the same name do not merge. The hostname
996
+ // becomes the human label instead.
997
+ let machineId = resolveMachineId();
998
+ let machineLabel = existing?.machineLabel ?? resolveMachineLabel();
999
+
1000
+ if (existing) {
1001
+ const reuse = await selectOne<"reuse" | "other">({
1002
+ message: `Use this machine's existing connection to ${existing.url}?`,
1003
+ choices: [
1004
+ {
1005
+ label: "Yes — reuse it",
1006
+ value: "reuse",
1007
+ hint: `as ${existing.machineLabel ?? existing.machineId}, same token`,
1008
+ },
1009
+ { label: "No — different endpoint or key", value: "other", hint: "" },
1010
+ ],
1011
+ stdin,
1012
+ stdout,
1013
+ });
1014
+ if (reuse === null) return cancel();
1015
+ if (reuse === "reuse") {
1016
+ url = existing.url;
1017
+ token = existing.token;
1018
+ machineId = existing.machineId;
1019
+ machineLabel = existing.machineLabel ?? machineLabel;
1020
+ }
1021
+ }
1022
+
1023
+ if (url === null) {
1024
+ // NOT asked for. There is exactly one right answer for everybody using
1025
+ // the hosted product, and asking made it look like a decision — which
1026
+ // is how a key ends up pasted into the URL field, and how someone
1027
+ // reasonably types the dashboard's own address and gets a 404 from a
1028
+ // web app that is not the ingest endpoint. Both are real, both happened
1029
+ // within ten minutes of each other, and neither is a mistake the person
1030
+ // making it can be expected to avoid: "Failproof Cloud URL" has no
1031
+ // knowable answer other than the default it was already showing.
1032
+ //
1033
+ // The two audiences that genuinely need a different endpoint keep an
1034
+ // explicit way to say so, and neither is an interactive prompt:
1035
+ //
1036
+ // • local development / self-hosting → FAILPROOFAI_CLOUD_URL, the
1037
+ // same variable the DAEMON already reads for cloud-managed policy
1038
+ // (crates/failproofaid/src/cloud_client.rs), so one export points
1039
+ // the whole machine at one place instead of the wizard and the
1040
+ // daemon disagreeing.
1041
+ // • scripted installs → `failproofai config --connect <url> --token`,
1042
+ // unchanged.
1043
+ //
1044
+ // The env value goes through the SAME `validateCloudUrl` a typed one
1045
+ // did — it is not a trusted back door. http stays loopback-only, so a
1046
+ // bearer token still cannot be exported onto the wire in clear by
1047
+ // setting a variable.
1048
+ const override = process.env.FAILPROOFAI_CLOUD_URL?.trim();
1049
+ if (override) {
1050
+ const validated = validateCloudUrl(cloudBaseFor(override));
1051
+ if (!validated.ok) {
1052
+ // Loud, not silent-fallback-to-hosted: someone who exported this
1053
+ // wants THAT endpoint, and quietly reporting a machine to the
1054
+ // hosted service instead is the one outcome they did not ask for.
1055
+ stdout.write(
1056
+ `\nFAILPROOFAI_CLOUD_URL is set to "${override}", which cannot be used: ` +
1057
+ `${validated.reason}\n`,
1058
+ );
1059
+ return cancel();
1060
+ }
1061
+ url = validated.url;
1062
+ // Named on screen, because an env var is invisible at the moment it
1063
+ // matters and a machine reporting somewhere unexpected is exactly the
1064
+ // thing nobody notices until they go looking for data that is not
1065
+ // there.
1066
+ stdout.write(`\nUsing ${url} (from FAILPROOFAI_CLOUD_URL).\n`);
1067
+ } else {
1068
+ url = cloudBaseFor(DEFAULT_INGEST_URL);
1069
+ }
1070
+ }
574
1071
 
575
- // 4 — Review & apply
1072
+ if (token === null) {
1073
+ token = await promptText({
1074
+ // The destination is in the question now that it is no longer a
1075
+ // question of its own. It is the only remaining place a person can
1076
+ // notice they are about to send a key somewhere they did not mean.
1077
+ message: `API key for ${new URL(url).host}`,
1078
+ hint: "needs events:add · policies:pull enables managed policy too",
1079
+ // Masked: setup is routinely run while screen-sharing, and a pasted
1080
+ // key would otherwise sit in the scrollback of every recording.
1081
+ mask: true,
1082
+ validate: (v) => (v.length >= 8 ? null : "that looks too short to be a key"),
1083
+ stdin,
1084
+ stdout,
1085
+ });
1086
+ if (token === null) return cancel();
1087
+ }
1088
+
1089
+ // Check BEFORE the review screen, so a typo is caught while the user is
1090
+ // still thinking about credentials rather than three screens later. The
1091
+ // apply step re-verifies and is what actually writes — nothing is
1092
+ // persisted here.
1093
+ stdout.write("\nChecking the key… ");
1094
+ const probe = await validateIngestKey({ url: ingestUrlFor(url), key: token });
1095
+ if (!probe.ok) {
1096
+ stdout.write(`\nThat did not work: ${probe.reason}\n`);
1097
+ const retry = await selectOne<"skip" | "anyway">({
1098
+ message: "Carry on without connecting?",
1099
+ choices: [
1100
+ { label: "Yes, skip it", value: "skip", hint: "everything else still applies" },
1101
+ { label: "Save it anyway", value: "anyway", hint: "if you know the server is just down" },
1102
+ ],
1103
+ stdin,
1104
+ stdout,
1105
+ });
1106
+ if (retry === null) return cancel();
1107
+ if (retry === "skip") {
1108
+ stdout.write("Staying local. Connect later with `failproofai config --connect`.\n\n");
1109
+ } else {
1110
+ connect = { url, token, machineId, machineLabel };
1111
+ }
1112
+ } else {
1113
+ stdout.write("looks good.\n\n");
1114
+ connect = { url, token, machineId, machineLabel };
1115
+ }
1116
+ }
1117
+ }
1118
+ // 5 — Review & apply
576
1119
  const decision = await selectOne<"apply" | "cancel">({
577
1120
  message: "Ready to apply?",
578
- body: reviewLines({ scope, clis, policies, cwd, customEnabled }),
1121
+ body: reviewLines({
1122
+ target,
1123
+ clis,
1124
+ policies,
1125
+ cwd,
1126
+ customEnabled,
1127
+ // A stale-unit refresh rewrites the service file and restarts the
1128
+ // daemon, so it belongs on the list of things this run is about to
1129
+ // change. Leaving it off would make the confirmation screen of a
1130
+ // security tool quietly incomplete about a root-owned file.
1131
+ installDaemon: daemonWanted || daemonUnitStale,
1132
+ connect: connect !== null,
1133
+ }),
579
1134
  choices: [
580
1135
  { label: "Yes, apply now", value: "apply", hint: "write the config" },
581
1136
  { label: "Cancel", value: "cancel", hint: "quit, no changes" },
@@ -585,64 +1140,257 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
585
1140
  });
586
1141
  if (decision !== "apply") return cancel();
587
1142
 
588
- // Apply — REPLACE the enabled set at this scope.
1143
+ // ── Apply ─────────────────────────────────────────────────────────────────
1144
+ //
1145
+ // ORDER MATTERS. The daemon goes first, because setup requires it: if it
1146
+ // cannot be installed, this run must leave the machine exactly as it found
1147
+ // it rather than half-configured. Writing hooks first and discovering the
1148
+ // service will not start afterwards is the one ordering whose failure cannot
1149
+ // be undone cleanly — hooks would already be live, pointing at a machine
1150
+ // whose `daemonConfigured` flag we then could not honestly set.
1151
+ let daemonInstalled = daemonAlreadyRunning;
1152
+ if (daemonWanted) {
1153
+ // A unit that is running but cannot answer is torn down before it is
1154
+ // rebuilt, rather than installed over. Its `ExecStart` points at a binary
1155
+ // or interpreter that no longer works, and it holds the singleton flock
1156
+ // the replacement needs — install over the top and the new unit starts,
1157
+ // loses the lock race, and the machine stays exactly as broken. This is
1158
+ // the production path for `uninstallDaemonService`, which until now had
1159
+ // none: it was defined, tested, documented in CLAUDE.md as called from
1160
+ // here, and referenced by nothing.
1161
+ if (daemonBroken) {
1162
+ stdout.write("Removing the failproofaid service that cannot start…\n");
1163
+ try {
1164
+ await uninstallDaemonService();
1165
+ } catch (err) {
1166
+ // Non-fatal: the install below reports its own outcome, and it is that
1167
+ // outcome — not this one — that decides whether setup continues.
1168
+ hookLogWarn(
1169
+ `could not remove the broken failproofaid service: ${err instanceof Error ? err.message : String(err)}`,
1170
+ );
1171
+ }
1172
+ }
1173
+ stdout.write(daemonBroken ? "Reinstalling the failproofaid service…\n" : "Installing the failproofaid service…\n");
1174
+ const daemonResult = await installDaemonService();
1175
+ void emit("configure_daemon_install", {
1176
+ installed: daemonResult.installed,
1177
+ // A bounded classification, never the raw reason: on the failure path
1178
+ // that string is an errno message from writeFileSync/execFileSync
1179
+ // against a homedir()-derived path, so it routinely carries the OS
1180
+ // username and the local filesystem layout. The full text stays local
1181
+ // via the hookLogWarn below.
1182
+ reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
1183
+ platform: process.platform,
1184
+ });
1185
+
1186
+ if (!daemonResult.installed) {
1187
+ hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
1188
+ // Nothing user-facing has been written yet, so there is nothing to roll
1189
+ // back — which is the entire reason this runs first.
1190
+ stdout.write(
1191
+ `\nThe failproofaid service could not be installed:\n ${daemonResult.reason ?? "unknown error"}\n\n` +
1192
+ "Setup stopped before changing anything. Once that is fixed, re-run:\n" +
1193
+ " failproofai config\n\n",
1194
+ );
1195
+ void emit("configure_aborted", { reason: "daemon_failed" });
1196
+ outro("Nothing was changed.", { ok: false }, stdout);
1197
+ return { applied: false, abort: "daemon_failed" };
1198
+ }
1199
+
1200
+ // Installed and running is the service manager's opinion, and it is not
1201
+ // what setup needs to know. `ExecStart` bakes in `process.execPath` and an
1202
+ // absolute `dist/worker.mjs`, so a unit can be perfectly active while the
1203
+ // worker behind it dies on every spawn — and every other check waves that
1204
+ // machine through: `waitForDaemonRunning()` asks systemd, `Ping` is
1205
+ // answered in `server.rs` without touching the worker, a null
1206
+ // `resolveWorkerCommand()` is best-effort, and `Worker::warm()` swallows
1207
+ // its own failure. Setting `daemonConfigured` against it denies every tool
1208
+ // call across all twelve CLIs, `UserPromptSubmit` included, so the user
1209
+ // cannot even ask their agent why.
1210
+ //
1211
+ // Checked HERE rather than inside `installDaemonService`, which can only
1212
+ // honestly report on the service: keeping them apart is also what lets the
1213
+ // install mechanics be tested against a stub binary.
1214
+ const probe = await probeDaemon();
1215
+ if (!probe.ok) {
1216
+ hookLogWarn(
1217
+ `failproofaid was installed and running but did not answer a policy evaluation (${probe.reason})`,
1218
+ );
1219
+ // Two different faults, two different things to go and look at. Saying
1220
+ // "your worker will not start" at a machine whose socket simply never
1221
+ // came up sends someone to inspect a process that is fine.
1222
+ stdout.write(
1223
+ (probe.reason === "worker"
1224
+ ? "\nfailproofaid started but cannot evaluate policies — it is listening, and\n" +
1225
+ "its worker process could not be run.\n"
1226
+ : "\nfailproofaid started but could not be reached on its socket.\n") +
1227
+ "Setup stopped before changing anything, because a machine configured to\n" +
1228
+ "require a daemon that cannot answer denies every tool call.\n\n" +
1229
+ ` Check it with: ${daemonStatusCommand() ?? "systemctl status failproofaid"}\n` +
1230
+ " Then re-run: failproofai config\n\n",
1231
+ );
1232
+ void emit("configure_aborted", { reason: `daemon_not_answering_${probe.reason}` });
1233
+ outro("Nothing was changed.", { ok: false }, stdout);
1234
+ return { applied: false, abort: "daemon_failed" };
1235
+ }
1236
+ daemonInstalled = true;
1237
+ } else if (daemonUnitStale) {
1238
+ // Deliberately after the install branch and never instead of it: this only
1239
+ // ever runs on a machine whose daemon is already up, and it must not be
1240
+ // able to abort a setup that is otherwise fine. A failure here costs the
1241
+ // scheduled audit, nothing else.
1242
+ stdout.write("Refreshing the failproofaid service definition…\n");
1243
+ const upgrade = await ensureDaemonServiceCurrent();
1244
+ void emit("configure_daemon_unit_refresh", {
1245
+ outcome: upgrade.outcome,
1246
+ daemon_running: upgrade.daemonRunning ?? true,
1247
+ platform: process.platform,
1248
+ });
1249
+ if (upgrade.outcome === "failed") {
1250
+ hookLogWarn(`failproofaid service definition could not be refreshed: ${upgrade.reason}`);
1251
+ if (upgrade.daemonRunning === false) {
1252
+ // The refresh stopped a daemon it could not start again, and its own
1253
+ // rollback could not either. Explicitly `=== false`: every failure
1254
+ // that never touched the service leaves this undefined, and those must
1255
+ // not drag a healthy machine down this branch.
1256
+ //
1257
+ // Leaving `daemonConfigured` set here is not "the audit stays off", it
1258
+ // is every tool call across all 12 CLIs denied against a socket
1259
+ // nothing is listening on, recoverable only by hand-editing
1260
+ // policies-config.json. So the machine goes back to in-process
1261
+ // evaluation — the same trade uninstallDaemonService makes, and for
1262
+ // the same reason.
1263
+ daemonInstalled = false;
1264
+ setDaemonConfigured(false);
1265
+ stdout.write(
1266
+ `\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
1267
+ "failproofaid is no longer running, so this machine was switched back to\n" +
1268
+ "in-process evaluation rather than left denying every tool call. Hooks keep\n" +
1269
+ "enforcing. Re-run `failproofai config` to reinstall the service.\n\n",
1270
+ );
1271
+ } else {
1272
+ stdout.write(
1273
+ `\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
1274
+ "Hooks keep enforcing; scheduled audits stay off until it is.\n\n",
1275
+ );
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ // The flag that makes hooks route through the daemon — and, on a machine
1281
+ // where the daemon is unreachable, fail closed. Only ever set after a
1282
+ // verified-running service, never on intent.
1283
+ // The version is recorded HERE, from the CLI that installed it — which is by
1284
+ // construction the version that was installed, since the download URL and the
1285
+ // binary filename are both derived from it.
1286
+ if (daemonInstalled) {
1287
+ setDaemonConfigured(true, cliVersion);
1288
+ // Only now — the unit points at the new binary, so older ones are no
1289
+ // longer referenced by anything. Keeps the previous version for an
1290
+ // offline rollback.
1291
+ pruneOldDaemonBinaries();
1292
+ }
1293
+
589
1294
  // Telemetry runs concurrently with the install (never rejects, 5s-bounded) so
590
1295
  // it doesn't add dead time between "apply" and the config actually writing,
591
1296
  // while still being awaited before the process can exit.
592
1297
  const applied = emit("configure_applied", {
593
- scope,
1298
+ target,
1299
+ scopes,
594
1300
  cli: clis,
595
1301
  cli_count: clis.length,
596
1302
  policy_count: policies.length,
597
1303
  source: presets.join("+"),
1304
+ connected: connect !== null,
598
1305
  });
1306
+
1307
+ // One install per chosen scope, with the CLI list narrowed to what THAT
1308
+ // scope can take.
1309
+ //
1310
+ // The comment here used to claim `installHooks` "already skips CLIs a given
1311
+ // scope cannot take". It does not — `installHooksImpl` validates every CLI
1312
+ // against the scope up front and THROWS `Scope "project" is not supported by
1313
+ // Hermes`. `clis` is the union across scopes (deliberately, so a user-scope-
1314
+ // only gateway is still installed via the user half), so under "Both" +
1315
+ // "Everything available" the project pass got handed hermes/openclaw and
1316
+ // died. Nothing catches it, and by then the daemon is installed,
1317
+ // `daemonConfigured` is set and user-scope hooks are written — so the run
1318
+ // aborted mid-apply, before any project config or the pasted cloud key.
1319
+ // `configure-wizard.test.ts` mocks `installHooks` wholesale, which is why the
1320
+ // real validation path was never exercised.
1321
+ //
599
1322
  // quiet: the wizard renders its own outro; replace: the chosen set becomes
600
- // the full enabled set at this scope (unticking removes).
601
- await installHooks(
602
- policies,
603
- scope,
604
- cwd,
605
- /* includeBeta */ false,
606
- "configure-wizard",
607
- /* customPoliciesPath */ undefined,
608
- /* removeCustomHooks */ false,
609
- clis,
610
- { replace: true, quiet: true },
611
- );
612
- setCustomPoliciesEnabled(scope, cwd, customEnabled);
613
-
614
- // Daemon setup — unconditional on Linux/macOS whenever the global scope
615
- // was chosen (a daemon is per-machine, not per-project), no separate
616
- // opt-in step or command. A failure here (binary not found, no service
617
- // manager, ...) never fails the wizard — the rest of setup already
618
- // applied, and this machine simply stays on the in-process path exactly
619
- // as before, since `daemonConfigured` is only set on success.
620
- let daemonInstalled = false;
621
- if (scope === "user" && isDaemonSupportedPlatform()) {
622
- const daemonResult = await installDaemonService();
623
- if (daemonResult.installed) {
624
- setDaemonConfigured(true);
625
- daemonInstalled = true;
626
- } else {
627
- hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
1323
+ // the full enabled set at that scope (unticking removes).
1324
+ for (const scope of scopes) {
1325
+ const supportedHere = new Set(clisSupportingScope(scope));
1326
+ const clisForScope = clis.filter((id) => supportedHere.has(id));
1327
+ if (clisForScope.length > 0) {
1328
+ await installHooks(
1329
+ policies,
1330
+ scope,
1331
+ cwd,
1332
+ /* includeBeta */ false,
1333
+ "configure-wizard",
1334
+ /* customPoliciesPath */ undefined,
1335
+ /* removeCustomHooks */ false,
1336
+ clisForScope,
1337
+ { replace: true, quiet: true },
1338
+ );
1339
+ }
1340
+ setCustomPoliciesEnabled(scope, cwd, customEnabled);
1341
+ }
1342
+
1343
+ // Cloud connection, written after the daemon exists — the daemon is what
1344
+ // runs the collector, so a credential written for a service that is not
1345
+ // there would be a key on disk doing nothing.
1346
+ //
1347
+ // `connectToCloud` re-verifies each capability and writes only what actually
1348
+ // works, so a key revoked between the earlier probe and here degrades to a
1349
+ // reported partial rather than a connection this machine does not have.
1350
+ let connected = false;
1351
+ if (connect) {
1352
+ try {
1353
+ const outcome = await connectToCloud({
1354
+ url: connect.url,
1355
+ token: connect.token,
1356
+ machineId: connect.machineId,
1357
+ machineLabel: connect.machineLabel,
1358
+ // Both streams, as disclosed at the connect question. This is the one
1359
+ // place that decision becomes a written setting.
1360
+ sessions: true,
1361
+ });
1362
+ connected = outcome.anyConfigured;
1363
+ // Show the human label with the id in parentheses when they differ.
1364
+ const shownAs =
1365
+ connect.machineLabel === connect.machineId
1366
+ ? connect.machineId
1367
+ : `${connect.machineLabel} (${connect.machineId})`;
1368
+ for (const line of describeOutcome(outcome, shownAs, connect.url)) {
1369
+ stdout.write(`${line}\n`);
1370
+ }
1371
+ // Never the key, the URL, or the count — only that it happened and which
1372
+ // capabilities the server actually granted.
1373
+ void emit("configure_connect", {
1374
+ policy_ok: outcome.policy.ok,
1375
+ ingest_ok: outcome.ingest.ok,
1376
+ });
1377
+ } catch (err) {
1378
+ // Non-fatal, unlike the daemon: enforcement does not depend on the
1379
+ // dashboard, and a machine with no connection behaves exactly as every
1380
+ // release before this one did.
1381
+ hookLogWarn(
1382
+ `cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`,
1383
+ );
1384
+ stdout.write("\nCould not connect — everything else applied. Retry with `failproofai config --connect`.\n");
628
1385
  }
629
- void emit("configure_daemon_install", {
630
- installed: daemonResult.installed,
631
- // A bounded classification, never the raw reason: on the failure path
632
- // that string is an errno message from writeFileSync/execFileSync
633
- // against a homedir()-derived path, so it routinely carries the OS
634
- // username and the local filesystem layout ("/home/<user>/.config/
635
- // systemd/user/failproofaid.service"). The full text stays local via
636
- // the hookLogWarn above.
637
- reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
638
- platform: process.platform,
639
- });
640
1386
  }
641
1387
 
642
1388
  await applied;
643
- // Only now — a completed apply — is the launcher considered "seen", so the
644
- // first-run bare invocation stops redirecting here and opens the dashboard.
1389
+ // Only now — a completed apply — is the launcher considered "seen", so
1390
+ // first-run onboarding stops offering itself on every command.
645
1391
  markLauncherSeen();
1392
+ // And any record of an earlier failure is now false: this machine got set up.
1393
+ clearOnboardingAttempt();
646
1394
 
647
1395
  // Keep this inside a standard 80-column terminal. `writeLines` truncates with
648
1396
  // a hard cut and no ellipsis, so an over-long line doesn't just lose its tail
@@ -655,12 +1403,21 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
655
1403
  : customEnabled === false
656
1404
  ? " · custom policies DISABLED"
657
1405
  : "";
658
- const daemonNote = daemonInstalled ? " · background daemon enabled" : "";
1406
+ const daemonNote = daemonInstalled ? " · daemon on" : "";
1407
+ const cloudNote = connected ? " · reporting on" : "";
659
1408
  const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
660
1409
  outro(
661
- `Setup complete — ${policies.length} policies${customNote} · ${assistants}${daemonNote}`,
1410
+ `Setup complete — ${policies.length} policies${customNote} · ${assistants}${daemonNote}${cloudNote}`,
662
1411
  { ok: true },
663
1412
  stdout,
664
1413
  );
665
- return { applied: true, scope, clis, policies };
1414
+ return {
1415
+ applied: true,
1416
+ target,
1417
+ scopes,
1418
+ clis,
1419
+ policies,
1420
+ daemonInstalled,
1421
+ connected,
1422
+ };
666
1423
  }