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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -1
  3. package/.next/standalone/.next/build-manifest.json +6 -6
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +6 -0
  7. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  8. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js +3 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js +3 -1
  22. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  25. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  27. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  28. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  31. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  33. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
  35. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -1
  39. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -2
  41. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  43. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +3 -2
  45. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -2
  47. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  53. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/index.html +1 -1
  56. package/.next/standalone/.next/server/app/index.rsc +15 -15
  57. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  58. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  59. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  60. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  61. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  63. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/page.js +3 -1
  65. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  68. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  69. package/.next/standalone/.next/server/app/policies/page.js +5 -3
  70. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  73. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  74. package/.next/standalone/.next/server/app/project/[name]/page.js +4 -2
  75. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  76. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  77. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  78. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  79. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  80. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  81. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  83. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  84. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  85. package/.next/standalone/.next/server/app/projects/page.js +4 -2
  86. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/settings/page/app-paths-manifest.json +3 -0
  89. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +18 -0
  90. package/.next/standalone/.next/server/app/settings/page/next-font-manifest.json +6 -0
  91. package/.next/standalone/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
  92. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +53 -0
  93. package/.next/standalone/.next/server/app/settings/page.js +18 -0
  94. package/.next/standalone/.next/server/app/settings/page.js.map +5 -0
  95. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -0
  96. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +3 -0
  97. package/.next/standalone/.next/server/app-paths-manifest.json +2 -1
  98. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  99. package/.next/standalone/.next/server/chunks/{[externals]__1nl3dvw._.js → [externals]__0r-zamh._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__00ni9za._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__0effm9m._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g0vcwt._.js +3 -0
  109. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0ix8gm7._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ji11a-._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__10oa-v9._.js → [root-of-the-server]__0ojwjxl._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__0pbp9fe._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +4 -4
  116. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +3 -0
  122. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
  123. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  124. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  125. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-wfhrp._.js +3 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06bp_fg._.js +5 -0
  129. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__074qmkp._.js +3 -0
  130. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0_1tiya._.js} +2 -2
  131. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  132. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t281d4._.js +3 -0
  133. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +5 -0
  134. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1n2vcv4._.js} +2 -2
  135. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +3 -0
  137. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tlvv9a._.js +3 -0
  138. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
  139. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +3 -0
  140. package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
  141. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
  142. package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +3 -0
  143. package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +3 -0
  144. package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +3 -0
  145. package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +3 -0
  147. package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +3 -0
  148. package/.next/standalone/.next/server/chunks/ssr/_1ctse6u._.js +3 -0
  149. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  150. package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +3 -0
  151. package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +3 -0
  152. package/.next/standalone/.next/server/chunks/ssr/_1sdgprl._.js +5 -0
  153. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  154. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  155. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  156. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dwo94s.js +4 -0
  157. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  158. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +3 -0
  159. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  160. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  161. package/.next/standalone/.next/server/middleware.js +2 -2
  162. package/.next/standalone/.next/server/pages/404.html +1 -1
  163. package/.next/standalone/.next/server/pages/500.html +1 -1
  164. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  165. package/.next/standalone/.next/server/server-reference-manifest.json +74 -20
  166. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  167. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  168. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  169. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 113g8wtal6_c4.js} +22 -22
  170. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  171. package/.next/standalone/.next/static/chunks/18wqn0_3mzylf.js +1 -0
  172. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
  173. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 1xq1m8h14tnlj.js} +1 -1
  174. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 1y3vwogqb88dx.js} +1 -1
  175. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 1zmh-b0ulrqu7.js} +1 -1
  176. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  177. package/.next/standalone/.next/static/chunks/299lhw60e8gr6.js +1 -0
  178. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 2adbpdaxp5ygq.js} +1 -1
  179. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 2kbqcw-6swmav.js} +1 -1
  180. package/.next/standalone/.next/static/chunks/2n9n5m0963pdg.js +6 -0
  181. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
  182. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 30nrrpwymht5m.js} +1 -1
  183. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  184. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  185. package/.next/standalone/SECURITY.md +8 -2
  186. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  187. package/.next/standalone/app/actions/get-hooks-config.ts +2 -2
  188. package/.next/standalone/app/actions/get-scheduled-audit.ts +70 -0
  189. package/.next/standalone/app/actions/update-scheduled-audit.ts +44 -0
  190. package/.next/standalone/app/api/audit/run/route.ts +28 -0
  191. package/.next/standalone/app/api/audit/status/route.ts +13 -1
  192. package/.next/standalone/app/api/auth/status/route.ts +1 -1
  193. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -3
  194. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +1 -1
  195. package/.next/standalone/app/audit/_components/auth-dialog.css +159 -0
  196. package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
  197. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +1 -1
  198. package/.next/standalone/app/audit/_components/empty-state.tsx +0 -1
  199. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +1 -1
  200. package/.next/standalone/app/audit/_components/quirks-section.tsx +0 -1
  201. package/.next/standalone/app/audit/_components/report-footer.tsx +0 -2
  202. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  203. package/.next/standalone/app/audit/_components/strengths-section.tsx +0 -1
  204. package/.next/standalone/app/audit/audit-styles.css +6 -149
  205. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  206. package/.next/standalone/app/components/raw-log-viewer.tsx +1 -1
  207. package/.next/standalone/app/components/toast.tsx +1 -1
  208. package/.next/standalone/app/policies/hooks-client.tsx +84 -8
  209. package/.next/standalone/app/settings/page.tsx +31 -0
  210. package/.next/standalone/app/settings/settings-client.tsx +487 -0
  211. package/.next/standalone/components/navbar.tsx +2 -0
  212. package/.next/standalone/lib/auth/api-server-client.ts +2 -3
  213. package/.next/standalone/lib/auth/auth-store.ts +4 -5
  214. package/.next/standalone/lib/codex-sessions.ts +2 -1
  215. package/.next/standalone/lib/dashboard-host.ts +101 -0
  216. package/.next/standalone/lib/install-check.ts +22 -7
  217. package/.next/standalone/lib/pi-sessions.ts +99 -14
  218. package/.next/standalone/lib/telemetry-enabled.ts +54 -0
  219. package/.next/standalone/lib/telemetry-id.ts +52 -1
  220. package/.next/standalone/lib/telemetry.ts +8 -4
  221. package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
  222. package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
  223. package/.next/standalone/node_modules/react/package.json +3 -3
  224. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
  225. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
  226. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
  227. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
  228. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
  229. package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
  230. package/.next/standalone/node_modules/react-dom/package.json +4 -4
  231. package/.next/standalone/package.json +23 -12
  232. package/.next/standalone/proxy.ts +111 -1
  233. package/.next/standalone/server.js +1 -1
  234. package/bin/failproofai-worker.mjs +1 -1
  235. package/bin/failproofai.mjs +1041 -81
  236. package/bin/failproofaid-shim.mjs +4 -4
  237. package/dist/cli.mjs +18513 -10474
  238. package/dist/worker.mjs +1223 -299
  239. package/lib/auth/api-server-client.ts +2 -3
  240. package/lib/auth/auth-store.ts +4 -5
  241. package/lib/codex-sessions.ts +2 -1
  242. package/lib/dashboard-host.ts +101 -0
  243. package/lib/install-check.ts +22 -7
  244. package/lib/pi-sessions.ts +99 -14
  245. package/lib/telemetry-enabled.ts +54 -0
  246. package/lib/telemetry-id.ts +52 -1
  247. package/lib/telemetry.ts +8 -4
  248. package/package.json +23 -12
  249. package/scripts/build-daemon-packages.mjs +247 -0
  250. package/scripts/daemon-platforms.mjs +42 -0
  251. package/scripts/dev-hook.mjs +46 -5
  252. package/scripts/install-telemetry.mjs +8 -1
  253. package/scripts/launch.ts +25 -3
  254. package/scripts/parse-script-args.ts +27 -1
  255. package/scripts/prune-standalone.mjs +26 -0
  256. package/scripts/publish-aliases.mjs +62 -41
  257. package/scripts/repro-npm-install.sh +155 -0
  258. package/scripts/translate-docs/cache.ts +1 -1
  259. package/scripts/translate-docs/cli.ts +2 -2
  260. package/scripts/translate-docs/mintlify-nav.ts +1 -1
  261. package/scripts/translate-docs/readme-translator.ts +163 -4
  262. package/scripts/validate-mdx.ts +153 -13
  263. package/src/audit/audit-lock.ts +285 -0
  264. package/src/audit/audit-schedule.ts +111 -0
  265. package/src/audit/cache.ts +18 -5
  266. package/src/audit/cli-adapters/goose.ts +2 -1
  267. package/src/audit/cli-adapters/hermes.ts +28 -10
  268. package/src/audit/cli.ts +191 -35
  269. package/src/audit/dashboard-cache.ts +2 -3
  270. package/src/audit/report.ts +0 -1
  271. package/src/hooks/backfill-cli.ts +247 -0
  272. package/src/hooks/builtin-policies.ts +391 -3
  273. package/src/hooks/cloud-connection.ts +354 -0
  274. package/src/hooks/cloud-enrollment-cli.ts +439 -0
  275. package/src/hooks/cloud-enrollment.ts +284 -0
  276. package/src/hooks/cloud-introspect.ts +147 -0
  277. package/src/hooks/cloud-managed-policies.ts +206 -0
  278. package/src/hooks/collector-config.ts +218 -0
  279. package/src/hooks/configure-wizard.ts +1211 -233
  280. package/src/hooks/custom-hooks-loader.ts +243 -37
  281. package/src/hooks/daemon-client.ts +136 -33
  282. package/src/hooks/daemon-download.ts +264 -31
  283. package/src/hooks/daemon-service.ts +1124 -76
  284. package/src/hooks/first-run-gate.ts +50 -0
  285. package/src/hooks/flush-cli.ts +180 -0
  286. package/src/hooks/fp-config.ts +835 -0
  287. package/src/hooks/fp-home.ts +714 -0
  288. package/src/hooks/fp-reset.ts +1363 -0
  289. package/src/hooks/handler.ts +185 -6
  290. package/src/hooks/harness-cli.ts +386 -0
  291. package/src/hooks/hook-activity-store.ts +171 -24
  292. package/src/hooks/hook-logger.ts +2 -2
  293. package/src/hooks/hook-telemetry.ts +5 -1
  294. package/src/hooks/hooks-config.ts +4 -3
  295. package/src/hooks/integrations.ts +0 -1
  296. package/src/hooks/loader-utils.ts +172 -9
  297. package/src/hooks/manager.ts +41 -5
  298. package/src/hooks/migrations.ts +450 -0
  299. package/src/hooks/onboarding-attempt.ts +195 -0
  300. package/src/hooks/onboarding-lock.ts +118 -0
  301. package/src/hooks/policy-types.ts +28 -0
  302. package/src/hooks/session-pause-cli.ts +159 -0
  303. package/src/hooks/session-pause.ts +267 -0
  304. package/src/hooks/setup-state.ts +256 -0
  305. package/src/hooks/tui.ts +209 -9
  306. package/src/hooks/uninstall-cli.ts +418 -0
  307. package/src/hooks/worker-server.ts +134 -15
  308. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  309. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  310. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  311. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0longh-._.js +0 -3
  312. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +0 -3
  313. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +0 -3
  314. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +0 -3
  315. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +0 -3
  316. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +0 -3
  317. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  318. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +0 -4
  319. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
  320. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
  321. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
  322. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0nb0syg._.js +0 -3
  323. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
  324. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
  325. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
  326. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
  327. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
  328. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1psjybo._.js +0 -3
  329. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +0 -4
  330. package/.next/standalone/.next/server/chunks/ssr/_1m_p7pi._.js +0 -3
  331. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_0oy_-36.js +0 -3
  332. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +0 -5
  333. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +0 -5
  334. package/.next/standalone/.next/static/chunks/0h7auy7hzjyhw.js +0 -1
  335. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  336. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  337. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  338. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  339. package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
  340. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  341. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  342. package/.next/standalone/Cargo.lock +0 -428
  343. package/.next/standalone/Cargo.toml +0 -9
  344. package/.next/standalone/crates/PROTOCOL.md +0 -122
  345. package/.next/standalone/crates/failproofaid/Cargo.toml +0 -18
  346. package/.next/standalone/crates/failproofaid/src/lock.rs +0 -81
  347. package/.next/standalone/crates/failproofaid/src/main.rs +0 -83
  348. package/.next/standalone/crates/failproofaid/src/paths.rs +0 -172
  349. package/.next/standalone/crates/failproofaid/src/server.rs +0 -502
  350. package/.next/standalone/crates/failproofaid/src/worker.rs +0 -400
  351. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +0 -241
  352. package/.next/standalone/crates/fpai-ipc/Cargo.toml +0 -16
  353. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +0 -175
  354. package/.next/standalone/crates/fpai-ipc/src/framing.rs +0 -177
  355. package/.next/standalone/crates/fpai-ipc/src/lib.rs +0 -10
  356. package/.next/standalone/crates/fpai-ipc/src/peer.rs +0 -80
  357. package/.next/standalone/docker-hook-sync/Dockerfile +0 -79
  358. package/.next/standalone/docker-hook-sync/entrypoint.sh +0 -109
  359. package/.next/standalone/integration-suite/Dockerfile +0 -47
  360. package/.next/standalone/integration-suite/README.md +0 -129
  361. package/.next/standalone/integration-suite/canary-policies.mjs +0 -45
  362. package/.next/standalone/integration-suite/capture-tokens.sh +0 -48
  363. package/.next/standalone/integration-suite/ci-entrypoint.sh +0 -179
  364. package/.next/standalone/integration-suite/inject-tokens.sh +0 -44
  365. package/.next/standalone/integration-suite/install-clis.sh +0 -136
  366. package/.next/standalone/integration-suite/probe-cli.sh +0 -248
  367. package/.next/standalone/integration-suite/report.js +0 -120
  368. package/.next/standalone/integration-suite/run.sh +0 -159
  369. package/.next/standalone/openclaw-plugin/index.js +0 -223
  370. package/.next/standalone/openclaw-plugin/openclaw.plugin.json +0 -6
  371. package/.next/standalone/openclaw-plugin/package.json +0 -12
  372. package/.next/standalone/osv-scanner.toml +0 -17
  373. package/.next/standalone/pi-extension/index.ts +0 -524
  374. package/.next/standalone/pi-extension/package.json +0 -12
  375. package/.next/standalone/postcss.config.mjs +0 -8
  376. package/.next/standalone/readme-arch-hq.gif +0 -0
  377. package/.next/standalone/rust-toolchain.toml +0 -4
  378. package/src/auth/cli.ts +0 -371
  379. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_buildManifest.js +0 -0
  380. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_clientMiddlewareManifest.js +0 -0
  381. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_ssgManifest.js +0 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * `VERSION`, `config.json` and `credentials.json` — the three files a human
3
+ * might actually open in `~/.failproofai`.
4
+ *
5
+ * ## Why the split
6
+ *
7
+ * `config.json` inherits the umask and lands at 0664 on a normal machine.
8
+ * `credentials.json` is written 0600 and holds every token. That boundary is
9
+ * not tidiness: it is why `ingest.json` and `cloud.json` were separate files
10
+ * before this, and it means CI, tooling and a screen-shared `cat config.json`
11
+ * can read a machine's configuration without ever seeing a live credential.
12
+ *
13
+ * ## One format, not two
14
+ *
15
+ * These were TOML through layout 2, for the comments — the file explained what
16
+ * `oss` meant and why `daemon.configured` must never be hand-set. Layout 3
17
+ * moved them to JSON so the home speaks one format end to end: one parser, one
18
+ * escaping rule, and no `toml` dependency in either the CLI or the daemon for
19
+ * files that were always flat key/value.
20
+ *
21
+ * The comments are the cost, and they were not decoration — this is the file
22
+ * someone opens when something is wrong. That guidance now lives in
23
+ * `failproofai config --status` and the docs, which is a worse place for it.
24
+ */
25
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
26
+ import { dirname } from "node:path";
27
+ import { version as cliVersion } from "../../package.json";
28
+ import {
29
+ LAYOUT_VERSION,
30
+ configFile,
31
+ credentialsFile,
32
+ failproofaiHome,
33
+ legacy,
34
+ versionFile,
35
+ } from "./fp-home";
36
+
37
+ // ── VERSION ──────────────────────────────────────────────────────────────────
38
+
39
+ export interface VersionFile {
40
+ /** The on-disk layout this home was written by. */
41
+ layout: number;
42
+ /** The CLI version that last wrote it — replaces the old `last-version`. */
43
+ cli: string;
44
+ /** The daemon binary version installed, if any. */
45
+ daemon?: string;
46
+ }
47
+
48
+ export type LayoutState =
49
+ /** No home yet, or an empty one. A fresh install. */
50
+ | { kind: "absent" }
51
+ /** A home written by this layout. `inferred` means the layout was recovered
52
+ * from a landmark because `VERSION` was missing or unreadable — the caller
53
+ * should write the marker back. */
54
+ | { kind: "current"; version: VersionFile; inferred?: boolean }
55
+ /** A home from an older layout — must be reset, never half-read. */
56
+ | { kind: "stale"; found: number }
57
+ /** A home from a NEWER layout: a downgrade. Refuse rather than corrupt it. */
58
+ | { kind: "future"; found: number };
59
+
60
+ /**
61
+ * What layout the home on disk speaks.
62
+ *
63
+ * The `VERSION` file is the primary signal; its absence is only treated as
64
+ * "layout 1" when the home actually contains something, because an empty or
65
+ * missing directory is a fresh install and must not be reported as stale.
66
+ *
67
+ * A NEWER layout is called out separately from an older one. Both are
68
+ * unreadable, but only one of them is fixable by re-running setup — telling a
69
+ * user to reset a home written by a newer CLI would delete data that a simple
70
+ * upgrade would have read fine.
71
+ */
72
+ export function detectLayout(): LayoutState {
73
+ const raw = readVersionFile();
74
+ if (raw) {
75
+ if (raw.layout === LAYOUT_VERSION) return { kind: "current", version: raw };
76
+ return raw.layout > LAYOUT_VERSION
77
+ ? { kind: "future", found: raw.layout }
78
+ : { kind: "stale", found: raw.layout };
79
+ }
80
+
81
+ // No VERSION file — fall back to landmarks.
82
+ //
83
+ // ORDER MATTERS, because layout 3 collides with layout 1 on one of them.
84
+ // Layout 3 put `policies-config.json` back at the home root, which is exactly
85
+ // where layout 1 kept it. A layout-3 home that lost its VERSION would
86
+ // therefore match a layout-1 landmark and be reported as layout 1 — and
87
+ // `resetHome(1)` runs layout-1 MIGRATIONS, which move `policies/*.mjs` on the
88
+ // assumption they are in layout 1's positions. In layout 3 those are the
89
+ // user's own convention policies, sitting where they belong.
90
+ //
91
+ // So check for a marker only the newer layouts have first — and DISTINGUISH
92
+ // them, because the two answers are not equally safe.
93
+ //
94
+ // `config.json` is layout 3's OWN file. Layout 2 wrote `config.toml`, layout 1
95
+ // had neither, and nothing but setup writes either one. So its presence does
96
+ // not merely prove "newer than layout 1" — it identifies THIS layout, and the
97
+ // only thing actually missing is the marker. Reporting it as stale instead ran
98
+ // a destructive reset over a healthy machine: `resettablePaths()` lists
99
+ // `configFile()` and `credentialsFile()`, so the home lost its live cloud
100
+ // token AND `daemon.configured` — which is what makes a machine fail closed.
101
+ // A machine that dropped out of fail-closed enforcement because a marker file
102
+ // went missing is the exact failure this module exists to prevent, and it
103
+ // announced itself as a routine "reorganised your home" message.
104
+ if (existsSync(configFile())) {
105
+ // `inferred`: the layout is right but the MARKER is missing, and nothing
106
+ // else rewrites it — so every later command re-derives it from a landmark,
107
+ // and the daemon version recorded in that file is gone for good
108
+ // (`daemonVersionSkew()` reads it, and stops nudging about a stale daemon).
109
+ // The caller re-stamps, exactly as it already does for a fresh home.
110
+ return { kind: "current", version: { layout: LAYOUT_VERSION, cli: "" }, inferred: true };
111
+ }
112
+
113
+ // `config.toml` and no `config.json` is genuinely layout 2, and a reset is
114
+ // right: its files are the ones being replaced.
115
+ if (existsSync(legacy.configToml())) return { kind: "stale", found: LAYOUT_VERSION - 1 };
116
+
117
+ // Layout 1 if any of its landmarks are present, otherwise this is simply a
118
+ // home that has not been set up yet.
119
+ const layoutOneMarkers = [
120
+ legacy.policyConfig(),
121
+ legacy.cacheDir(),
122
+ legacy.launcherMarker(),
123
+ legacy.lastVersion(),
124
+ legacy.cloudCredentials(),
125
+ legacy.ingestCredentials(),
126
+ legacy.auditDashboard(),
127
+ ];
128
+ return layoutOneMarkers.some((p) => existsSync(p)) ? { kind: "stale", found: 1 } : { kind: "absent" };
129
+ }
130
+
131
+ /**
132
+ * Read a layout-2 `VERSION`, which is TOML.
133
+ *
134
+ * Layout 3 made this file JSON, so `JSON.parse` throws on every layout-2 home —
135
+ * and "unreadable" is NOT a harmless answer here. It sends `detectLayout()` to
136
+ * the landmarks, and a layout-2 home whose owner never completed setup has no
137
+ * `config.toml` to match: it falls through every layout-1 marker too (layout 2
138
+ * nested `policies-config.json`, so the root file layout 1 is recognised by is
139
+ * absent) and reads as `absent` — a FRESH INSTALL. The CLI then stamps it
140
+ * layout 3 with no reset and no migration, and
141
+ * `policies/local-policies/policies-config.json` plus
142
+ * `policies/custom-policies/*.mjs` are orphaned permanently: the user's enabled
143
+ * policies revert to the defaults, their own policy files stop loading, and
144
+ * nothing says so. Reproduced exactly that way on a seeded home.
145
+ *
146
+ * A regex rather than a TOML parser because removing that dependency is half
147
+ * the point of layout 3, and this file is three flat `key = value` lines that
148
+ * this codebase itself wrote.
149
+ *
150
+ * ALL THREE fields are read, including `daemon`. It is tempting to take only the
151
+ * layout number on the grounds that the home is about to be reset — but
152
+ * `writeVersionFile()` carries the daemon version forward from `existing?.daemon`,
153
+ * so a field missing here is not re-derived, it is ERASED. The machine then
154
+ * reports "daemon not installed" with the binary sitting in `bin/`, and
155
+ * `daemonVersionSkew()` stops nudging about a stale daemon precisely when an
156
+ * upgrade has just happened.
157
+ */
158
+ function readLegacyTomlVersion(text: string): VersionFile | null {
159
+ const layout = Number(/^\s*layout\s*=\s*(\d+)\s*$/m.exec(text)?.[1]);
160
+ if (!Number.isFinite(layout)) return null;
161
+ const str = (key: string) => new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m").exec(text)?.[1];
162
+ return { layout, cli: str("cli") ?? "", daemon: str("daemon") };
163
+ }
164
+
165
+ export function readVersionFile(): VersionFile | null {
166
+ let text: string;
167
+ try {
168
+ text = readFileSync(versionFile(), "utf8");
169
+ } catch {
170
+ // Absent or unreadable. The landmark check decides what that implies.
171
+ return null;
172
+ }
173
+ try {
174
+ const parsed = JSON.parse(text) as Record<string, unknown>;
175
+ const layout = Number(parsed.layout);
176
+ if (!Number.isFinite(layout)) return null;
177
+ return {
178
+ layout,
179
+ cli: typeof parsed.cli === "string" ? parsed.cli : "",
180
+ daemon: typeof parsed.daemon === "string" ? parsed.daemon : undefined,
181
+ };
182
+ } catch {
183
+ // Not JSON. Before concluding "no layout", try the one other format this
184
+ // file has ever had — see `readLegacyTomlVersion` for what guessing wrong
185
+ // costs. Genuinely corrupt content still yields null from there.
186
+ return readLegacyTomlVersion(text);
187
+ }
188
+ }
189
+
190
+ export function writeVersionFile(
191
+ v: Partial<VersionFile> & { /** Erase the daemon version rather than keeping it. */ clearDaemon?: boolean } = {},
192
+ ): void {
193
+ const existing = readVersionFile();
194
+ const next: VersionFile = {
195
+ layout: LAYOUT_VERSION,
196
+ cli: v.cli ?? cliVersion,
197
+ // `undefined` means "leave whatever is there" — a CLI-only rewrite must not
198
+ // drop a daemon version it never touched. Erasing it is therefore an
199
+ // explicit act, used on uninstall.
200
+ daemon: v.clearDaemon ? undefined : (v.daemon ?? existing?.daemon),
201
+ };
202
+ writeFileAt(
203
+ versionFile(),
204
+ `${JSON.stringify(
205
+ { layout: next.layout, cli: next.cli, ...(next.daemon ? { daemon: next.daemon } : {}) },
206
+ null,
207
+ 2,
208
+ )}\n`,
209
+ );
210
+ }
211
+
212
+ // ── config.json ──────────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * How this machine relates to Failproof Cloud.
216
+ *
217
+ * `oss` is not merely "no credentials configured" — it is a hard gate. In OSS
218
+ * mode nothing constructs a cloud URL, polls for policy, or spools an event
219
+ * for upload. A machine that never opted in must be provably silent, not
220
+ * silent-by-happenstance because a token lookup returned undefined.
221
+ */
222
+ export type Mode = "oss" | "cloud";
223
+
224
+ export interface FpConfig {
225
+ mode: Mode;
226
+ daemon: {
227
+ /**
228
+ * Route hooks through the daemon — and FAIL CLOSED when it is unreachable.
229
+ *
230
+ * The installed binary VERSION deliberately does not live here; it is in
231
+ * `VERSION`, which is the file about versions. Two copies could disagree,
232
+ * and a stale one would misreport which daemon a machine is running.
233
+ */
234
+ configured: boolean;
235
+ };
236
+ collector: {
237
+ sessions: boolean;
238
+ hooks: boolean;
239
+ hooksVerbosity: "all" | "decisions" | "off";
240
+ redact: "minimal" | "off";
241
+ environment: string;
242
+ machineId?: string;
243
+ /**
244
+ * Extra locations to capture per harness, beyond the one each source ships
245
+ * with — `collector.sources.<harness>.extra_paths` in `config.json`.
246
+ *
247
+ * Each entry is `label=path` or a bare `path`; the label becomes an agent-id
248
+ * namespace (`<label>-<agentId>`), which is what keeps two copies of one
249
+ * project from merging into a single agent. The daemon is the authority on
250
+ * the grammar and on which harness names are real — see
251
+ * `crates/fpai-collect/src/extra_paths.rs` and `HARNESS_KEYS` in
252
+ * `crates/failproofaid/src/main.rs`. This side stores and edits the strings;
253
+ * it deliberately does not re-implement the parser, because two parsers is
254
+ * how the CLI comes to accept a path the daemon then silently drops.
255
+ *
256
+ * Absent for every machine that has not configured one, which is almost all
257
+ * of them — so `writeConfig` emits nothing at all in that case and a config
258
+ * file is byte-identical to what this version wrote before the field
259
+ * existed.
260
+ */
261
+ sources?: Record<string, { extraPaths: string[] }>;
262
+ };
263
+ telemetry: {
264
+ /**
265
+ * Anonymous product telemetry. On unless switched off.
266
+ *
267
+ * This is the ONLY off-switch that can reach the daemon.
268
+ * `FAILPROOFAI_TELEMETRY_DISABLED=1` is read from `process.env`, and
269
+ * failproofaid runs as a system-scope service whose environment carries
270
+ * essentially nothing — so a shell export is structurally incapable of
271
+ * turning it off there. A file both sides read is the only thing that works.
272
+ */
273
+ enabled: boolean;
274
+ };
275
+ audit: {
276
+ /**
277
+ * Scan this machine's agent history on a schedule.
278
+ *
279
+ * OFF by default, and the asymmetry with `telemetry.enabled` above is
280
+ * deliberate: the audit reads the CONTENTS of every session transcript on
281
+ * disk — prompts, file contents, command output — so nothing scans on a
282
+ * timer until somebody asks for it.
283
+ */
284
+ auto: boolean;
285
+ /** Days between scheduled runs. Wall clock, so it survives suspend. */
286
+ intervalDays: number;
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Days between scheduled audits when nothing says otherwise, and the bounds a
292
+ * hand-written value is held to.
293
+ *
294
+ * Both ends fail quietly, which is why they are clamped at all. A measured full
295
+ * scan is ~104 seconds over 3,277 transcripts and grows with history, so an
296
+ * interval under a day spends minutes of disk every day re-reading transcripts
297
+ * that have barely changed. At the other end a typo'd `interval_days = 3650`
298
+ * reads as "never", which on a status line is indistinguishable from the
299
+ * feature being broken — and "never" already has its own switch, `auto = false`.
300
+ */
301
+ export const DEFAULT_AUDIT_INTERVAL_DAYS = 7;
302
+ const MIN_AUDIT_INTERVAL_DAYS = 1;
303
+ const MAX_AUDIT_INTERVAL_DAYS = 90;
304
+
305
+ /**
306
+ * Resolve `interval_days`, refusing anything that is not a whole number of days.
307
+ *
308
+ * 0, a negative, a fraction below a day, and any non-number all resolve to the
309
+ * DEFAULT rather than clamping up to the 1-day floor. A `0` almost certainly
310
+ * means "off", and reading it as a DAILY 104-second scan of every transcript on
311
+ * the machine is the loudest possible way to get that wrong. A too-large value
312
+ * is clamped DOWN to 90 instead, because that is the conservative direction of
313
+ * the two available: falling back to 7 there would scan an order of magnitude
314
+ * more often than was asked for.
315
+ */
316
+ function readIntervalDays(raw: unknown): number {
317
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_AUDIT_INTERVAL_DAYS;
318
+ const days = Math.floor(raw);
319
+ if (days < MIN_AUDIT_INTERVAL_DAYS) return DEFAULT_AUDIT_INTERVAL_DAYS;
320
+ return Math.min(days, MAX_AUDIT_INTERVAL_DAYS);
321
+ }
322
+
323
+ export const DEFAULT_CONFIG: FpConfig = {
324
+ mode: "oss",
325
+ daemon: { configured: false },
326
+ collector: {
327
+ sessions: false,
328
+ hooks: true,
329
+ hooksVerbosity: "decisions",
330
+ redact: "minimal",
331
+ environment: "local",
332
+ },
333
+ telemetry: { enabled: true },
334
+ audit: { auto: false, intervalDays: DEFAULT_AUDIT_INTERVAL_DAYS },
335
+ };
336
+
337
+ /**
338
+ * `[collector.sources.*]` → the in-memory shape, dropping anything unusable.
339
+ *
340
+ * Entries are kept as written rather than normalised. The daemon parses them
341
+ * (label derivation, overlap rejection, `~` expansion) and reports what it
342
+ * rejected at startup; normalising here would give the two sides two answers
343
+ * and hide the daemon's rejection behind a value the CLI had already rewritten.
344
+ * The only things dropped are shapes that could not survive a round trip at
345
+ * all — a non-array, or a non-string element.
346
+ *
347
+ * `undefined` rather than `{}` when nothing is configured, so `writeConfig`
348
+ * emits no table and an untouched config file keeps its exact previous bytes.
349
+ */
350
+ function readSources(raw: unknown): FpConfig["collector"]["sources"] {
351
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
352
+ const out: Record<string, { extraPaths: string[] }> = {};
353
+ for (const [name, table] of Object.entries(raw as Record<string, unknown>)) {
354
+ if (!table || typeof table !== "object" || Array.isArray(table)) continue;
355
+ const entries = (table as Record<string, unknown>).extra_paths;
356
+ if (!Array.isArray(entries)) continue;
357
+ const paths = entries.filter((e): e is string => typeof e === "string" && e.trim() !== "");
358
+ if (paths.length > 0) out[name] = { extraPaths: paths };
359
+ }
360
+ return Object.keys(out).length > 0 ? out : undefined;
361
+ }
362
+
363
+ /**
364
+ * {@link readConfig}, plus the raw object it was projected from.
365
+ *
366
+ * `readConfig` is a WHITELIST PROJECTION, not a merge: it names every key it
367
+ * understands and builds a fresh `FpConfig`. Paired with a `writeConfig` that
368
+ * regenerates the file wholesale, that means **any key this build does not know
369
+ * is erased on the next write** — and that is a data-loss bug with no layout
370
+ * change involved at all. Two CLI versions on the same layout round-trip the
371
+ * file and silently delete each other's keys, and most releases do not bump the
372
+ * layout, so `detectLayout()`'s `future` refusal never fires to protect it.
373
+ *
374
+ * `collector.sources` is the live example: added inside layout 3, so a CLI that
375
+ * predates it drops every extra path a user typed on the first `updateConfig()`
376
+ * call — from `harness add-path`, which is the one command whose entire output is
377
+ * that key.
378
+ *
379
+ * So every mutation path reads the raw object and hands it back to `writeConfig`,
380
+ * which preserves what it does not own. `readConfig` stays as it was for the many
381
+ * read-only callers that genuinely only want the projection.
382
+ */
383
+ export function readConfigRaw(): { raw: Record<string, unknown>; config: FpConfig } {
384
+ let raw: Record<string, unknown> = {};
385
+ try {
386
+ const parsed = JSON.parse(readFileSync(configFile(), "utf8")) as unknown;
387
+ // An array or a scalar is not a config object, and spreading one into the
388
+ // merge below would produce index keys. Anything unusable reads as empty,
389
+ // which is what the projection already falls back to.
390
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
391
+ raw = parsed as Record<string, unknown>;
392
+ }
393
+ } catch {
394
+ // Absent or unparseable. `readConfig` returns defaults for the same input;
395
+ // an empty raw means the write below carries nothing forward, which is the
396
+ // only honest answer when the previous bytes could not be read.
397
+ }
398
+ return { raw, config: readConfig() };
399
+ }
400
+
401
+ export function readConfig(): FpConfig {
402
+ try {
403
+ const parsed = JSON.parse(readFileSync(configFile(), "utf8")) as Record<string, unknown>;
404
+ return projectConfig(parsed);
405
+ } catch {
406
+ return structuredClone(DEFAULT_CONFIG);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Project a parsed config object onto {@link FpConfig}.
412
+ *
413
+ * Split out of `readConfig` so the layout-2 → 3 carry can run the SAME
414
+ * projection over a `config.toml` it parsed itself, rather than a second reader
415
+ * that has to be kept in step with this one. The two formats use identical key
416
+ * names (`hooks_verbosity`, `machine_id`, `interval_days`,
417
+ * `sources.<h>.extra_paths`), so the only difference between them is how the
418
+ * bytes become an object — and once that is a parameter there is nothing left to
419
+ * drift. Every failure-direction decision below is therefore made once, for both.
420
+ */
421
+ export function projectConfig(parsed: Record<string, unknown>): FpConfig {
422
+ {
423
+ const mode = (parsed.mode as Record<string, unknown> | undefined)?.kind;
424
+ const daemon = (parsed.daemon ?? {}) as Record<string, unknown>;
425
+ const collector = (parsed.collector ?? {}) as Record<string, unknown>;
426
+ const telemetry = (parsed.telemetry ?? {}) as Record<string, unknown>;
427
+ const audit = (parsed.audit ?? {}) as Record<string, unknown>;
428
+ return {
429
+ // Anything unrecognised reads as `oss`. The failure direction matters: a
430
+ // corrupt config must not be able to turn cloud reporting ON.
431
+ //
432
+ // This is a real check rather than `?? "oss"`, which only caught a MISSING
433
+ // mode and passed every present-but-unknown value straight through — so
434
+ // `"enterprise"`, or a typo like `"clod"`, became the machine's mode. The
435
+ // test named for this branch was writing a TOML body into a `.json` file,
436
+ // so the parse failed, the whole function fell to its defaults, and the
437
+ // assertion passed without the branch ever running.
438
+ mode: mode === "cloud" ? "cloud" : "oss",
439
+ daemon: { configured: daemon.configured === true },
440
+ collector: {
441
+ sessions: collector.sessions === true,
442
+ hooks: collector.hooks !== false,
443
+ hooksVerbosity:
444
+ collector.hooks_verbosity === "all" || collector.hooks_verbosity === "off"
445
+ ? collector.hooks_verbosity
446
+ : "decisions",
447
+ redact: collector.redact === "off" ? "off" : "minimal",
448
+ environment:
449
+ typeof collector.environment === "string" ? collector.environment : "local",
450
+ machineId: typeof collector.machine_id === "string" ? collector.machine_id : undefined,
451
+ sources: readSources(collector.sources),
452
+ },
453
+ // Only an explicit `false` switches it off. Absent, or any other value,
454
+ // reads as on — the shipped default, and what a config with no
455
+ // [telemetry] block at all means.
456
+ telemetry: { enabled: telemetry.enabled !== false },
457
+ // Mirror image of telemetry above: only an explicit `true` switches the
458
+ // scheduled scan on. Absent, misspelled, or `"yes"` all read as off,
459
+ // because the failure direction here is a machine that starts reading
460
+ // every transcript it can find on a timer nobody set.
461
+ audit: { auto: audit.auto === true, intervalDays: readIntervalDays(audit.interval_days) },
462
+ // Same shape as `audit.auto` above and for the same reason: only an
463
+ // explicit `true` opts in. Anything else — absent, misspelled, `"yes"` —
464
+ // reads as off, because the failure direction is a machine that starts
465
+ // mailing a report nobody asked for to an org address it never sees.
466
+ };
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Every key `writeConfig` owns, as a path into the emitted JSON.
472
+ *
473
+ * Ownership is what makes preserving unknown keys safe in BOTH directions, and
474
+ * getting the direction wrong is a bug either way:
475
+ *
476
+ * - An **owned** key absent from the projection is a DELIBERATE OMISSION and
477
+ * must be deleted. `writeConfig` uses absence to express state — `telemetry`
478
+ * is emitted only when switched off, `machine_id` only when set, `sources`
479
+ * only when non-empty. Merging blindly over the old bytes would resurrect
480
+ * them, so switching telemetry back on would leave `enabled: false` in the
481
+ * file and the opt-out would become un-revokable.
482
+ * - An **unowned** key belongs to a build that knows more than this one and
483
+ * must survive. That is the bug this list exists to fix; see
484
+ * {@link readConfigRaw}.
485
+ *
486
+ * A `"*"` segment matches every key at that level. `collector.sources` is keyed
487
+ * by harness name, so its paths cannot be enumerated — and owning the subtree
488
+ * wholesale would delete a future per-harness sibling of `extra_paths`, which is
489
+ * the same class of loss one level down.
490
+ *
491
+ * Held honest by `config-round-trip.test.ts`, which populates every optional
492
+ * field, runs `writeConfig`, and asserts the emitted key set is exactly this.
493
+ */
494
+ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [
495
+ ["mode", "kind"],
496
+ ["daemon", "configured"],
497
+ ["collector", "sessions"],
498
+ ["collector", "hooks"],
499
+ ["collector", "hooks_verbosity"],
500
+ ["collector", "redact"],
501
+ ["collector", "environment"],
502
+ ["collector", "machine_id"],
503
+ ["collector", "sources", "*", "extra_paths"],
504
+ ["telemetry", "enabled"],
505
+ ["audit", "auto"],
506
+ ["audit", "interval_days"],
507
+ ];
508
+
509
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
510
+ typeof v === "object" && v !== null && !Array.isArray(v);
511
+
512
+ /**
513
+ * Remove one owned path, pruning any container the removal emptied.
514
+ *
515
+ * The pruning is not tidiness. Without it, deleting `telemetry.enabled` from a
516
+ * file that held nothing else under `[telemetry]` leaves `telemetry: {}` — so a
517
+ * default install would stop producing the byte-identical file that
518
+ * `readSources` and `writeConfig` both document, and every fixture asserting
519
+ * exact bytes would drift. A container that still holds an UNKNOWN key is never
520
+ * pruned, which is the whole point.
521
+ */
522
+ function deleteOwnedPath(node: Record<string, unknown>, path: readonly string[]): void {
523
+ const [head, ...rest] = path;
524
+ if (head === undefined) return;
525
+ const keys = head === "*" ? Object.keys(node) : [head];
526
+ for (const key of keys) {
527
+ if (rest.length === 0) {
528
+ delete node[key];
529
+ continue;
530
+ }
531
+ const child = node[key];
532
+ if (!isPlainObject(child)) continue;
533
+ deleteOwnedPath(child, rest);
534
+ if (Object.keys(child).length === 0) delete node[key];
535
+ }
536
+ }
537
+
538
+ /** Deep-merge `patch` onto `base`, recursing only where both sides are objects. */
539
+ function mergeOnto(base: Record<string, unknown>, patch: Record<string, unknown>): void {
540
+ for (const [key, value] of Object.entries(patch)) {
541
+ const existing = base[key];
542
+ if (isPlainObject(existing) && isPlainObject(value)) {
543
+ mergeOnto(existing, value);
544
+ } else {
545
+ base[key] = value;
546
+ }
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Regenerates the keys `config.json` owns, preserving every key it does not.
552
+ *
553
+ * The layout-2 file was hand-written TOML carrying its own documentation — what
554
+ * `oss` meant, why `daemon.configured` must never be hand-set. JSON cannot hold
555
+ * a comment, so that guidance now lives in `failproofai config --status` and the
556
+ * docs. It is a real loss on the one file a user opens when something is wrong;
557
+ * the trade is one serialisation format across the whole home instead of two.
558
+ *
559
+ * `raw` is the object the projection came from — pass it whenever you have one
560
+ * ({@link readConfigRaw} returns both together). Omitting it re-reads the file,
561
+ * which is the safe default: a caller who forgets still preserves, where the
562
+ * alternative silently erases. Pass `{}` deliberately to write a clean file.
563
+ */
564
+ export function writeConfig(config: FpConfig, raw?: Record<string, unknown>): void {
565
+ const c = config.collector;
566
+ const out: Record<string, unknown> = {
567
+ mode: { kind: config.mode },
568
+ daemon: { configured: config.daemon.configured },
569
+ collector: {
570
+ sessions: c.sessions,
571
+ hooks: c.hooks,
572
+ hooks_verbosity: c.hooksVerbosity,
573
+ redact: c.redact,
574
+ environment: c.environment,
575
+ // Omitted when unset rather than written null: the Rust side treats an
576
+ // absent machine_id as "derive one", and an explicit null is not that.
577
+ ...(c.machineId ? { machine_id: c.machineId } : {}),
578
+ // Extra capture paths per harness. Emitted ONLY when non-empty, so a
579
+ // machine that never configured one gets a file byte-identical to what
580
+ // this function produced before the field existed — the same reasoning as
581
+ // `telemetry` below. (In TOML this also had to come after every scalar of
582
+ // `[collector]`, or the scalars after it read as belonging to the
583
+ // sub-table. JSON has no such ordering hazard, which is one of the things
584
+ // the format change buys.)
585
+ ...(Object.entries(c.sources ?? {}).some(([, s]) => s.extraPaths?.length)
586
+ ? {
587
+ sources: Object.fromEntries(
588
+ Object.entries(c.sources ?? {})
589
+ .filter(([, s]) => s.extraPaths?.length)
590
+ .map(([name, s]) => [name, { extra_paths: s.extraPaths }]),
591
+ ),
592
+ }
593
+ : {}),
594
+ },
595
+ // Written ONLY when switched off. A default install therefore carries no
596
+ // telemetry block at all, but an operator who switched it off keeps it:
597
+ // this function regenerates the file wholesale, so emitting the key only
598
+ // when it is set is what stops a later rewrite from silently switching
599
+ // telemetry back on underneath them.
600
+ ...(config.telemetry.enabled ? {} : { telemetry: { enabled: false } }),
601
+ // Written ALWAYS, unlike telemetry directly above — the two are opposites
602
+ // on purpose. Telemetry ships on and is deliberately not advertised;
603
+ // the scheduled audit ships off and is meant to be FOUND, and a switch
604
+ // nobody can see is the same as a switch that does not exist. Emitting both
605
+ // keys unconditionally also makes "a user's setting survives a rewrite"
606
+ // total rather than conditional.
607
+ audit: { auto: config.audit.auto, interval_days: config.audit.intervalDays },
608
+ };
609
+ // Start from the previous bytes, strip the keys this build owns — so an
610
+ // omission above really removes — then lay the projection on top. What is left
611
+ // untouched in between is exactly the set of keys a newer build wrote and this
612
+ // one has never heard of.
613
+ const merged = structuredClone(raw ?? readConfigRaw().raw);
614
+ for (const path of OWNED_CONFIG_KEYS) deleteOwnedPath(merged, path);
615
+ mergeOnto(merged, out);
616
+ writeFileAt(configFile(), `${JSON.stringify(merged, null, 2)}\n`);
617
+ }
618
+
619
+ /** Merge a partial update into the config on disk. */
620
+ export function updateConfig(patch: {
621
+ mode?: Mode;
622
+ daemon?: Partial<FpConfig["daemon"]>;
623
+ collector?: Partial<FpConfig["collector"]>;
624
+ telemetry?: Partial<FpConfig["telemetry"]>;
625
+ audit?: Partial<FpConfig["audit"]>;
626
+ }): FpConfig {
627
+ // `readConfigRaw`, not `readConfig` — this is the function EVERY mutation path
628
+ // goes through (the wizard, `harness add-path`, `healDaemonFlag`, the telemetry
629
+ // opt-out), so carrying the raw object here is what makes unknown-key
630
+ // preservation total rather than per-caller.
631
+ const { raw, config: current } = readConfigRaw();
632
+ const next: FpConfig = {
633
+ mode: patch.mode ?? current.mode,
634
+ daemon: { ...current.daemon, ...patch.daemon },
635
+ collector: { ...current.collector, ...patch.collector },
636
+ telemetry: { ...current.telemetry, ...patch.telemetry },
637
+ audit: { ...current.audit, ...patch.audit },
638
+ };
639
+ writeConfig(next, raw);
640
+ return next;
641
+ }
642
+
643
+ // ── credentials.json ─────────────────────────────────────────────────────────
644
+
645
+ export interface FpCredentials {
646
+ cloud?: { url: string; machineId: string; token: string; machineLabel?: string };
647
+ /**
648
+ * Which organisation this machine's credentials report into, as the server
649
+ * named it at connect time (`/v1/auth/introspect`).
650
+ *
651
+ * ONE table, not a field on `[cloud]` and `[ingest]` both: it describes the
652
+ * TOKEN, and connect uses the same token for both capabilities, so two copies
653
+ * could only ever drift or disagree. It also has to survive the case that
654
+ * motivated recording it at all — an `events:add`-only key, which configures
655
+ * ingest and never writes `[cloud]`.
656
+ *
657
+ * Recorded so `--status` can answer "where does this machine's data go?"
658
+ * with no network call: the question asked when a dashboard looks empty, and
659
+ * the one a key pasted from the wrong org gets silently wrong. Absent against
660
+ * a server predating introspect, and in files written by an older CLI.
661
+ */
662
+ org?: { id?: string; slug?: string; name?: string };
663
+ ingest?: { url: string; key: string };
664
+ auth?: { baseUrl?: string; sessionToken?: string; expiresAt?: number; email?: string };
665
+ }
666
+
667
+ /**
668
+ * Every key `writeCredentials` owns. Same contract as {@link OWNED_CONFIG_KEYS},
669
+ * same reason — `readCredentials` is a whitelist projection too, and
670
+ * `writeCredentials` regenerates the file wholesale.
671
+ *
672
+ * `org` is the live example on this side: its own doc records that it is "absent
673
+ * against a server predating introspect, and in files written by an older CLI" —
674
+ * which is precisely a key an older CLI would delete on write, taking with it the
675
+ * only local answer to "where does this machine's data go?".
676
+ */
677
+ const OWNED_CREDENTIAL_KEYS: readonly (readonly string[])[] = [
678
+ ["cloud", "url"],
679
+ ["cloud", "machine_id"],
680
+ ["cloud", "machine_label"],
681
+ ["cloud", "token"],
682
+ ["org", "id"],
683
+ ["org", "slug"],
684
+ ["org", "name"],
685
+ ["ingest", "url"],
686
+ ["ingest", "key"],
687
+ ["auth", "base_url"],
688
+ ["auth", "session_token"],
689
+ ["auth", "expires_at"],
690
+ ["auth", "email"],
691
+ ];
692
+
693
+ /** {@link readCredentials}, plus the raw object it was projected from. */
694
+ export function readCredentialsRaw(): { raw: Record<string, unknown>; credentials: FpCredentials } {
695
+ let raw: Record<string, unknown> = {};
696
+ try {
697
+ const parsed = JSON.parse(readFileSync(credentialsFile(), "utf8")) as unknown;
698
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
699
+ raw = parsed as Record<string, unknown>;
700
+ }
701
+ } catch {
702
+ // Absent or unparseable — nothing to carry forward.
703
+ }
704
+ return { raw, credentials: readCredentials() };
705
+ }
706
+
707
+ export function readCredentials(): FpCredentials {
708
+ try {
709
+ return projectCredentials(
710
+ JSON.parse(readFileSync(credentialsFile(), "utf8")) as Record<string, unknown>,
711
+ );
712
+ } catch {
713
+ return {};
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Project a parsed credentials object onto {@link FpCredentials}.
719
+ *
720
+ * Split out for the reason {@link projectConfig} is: the layout-2 → 3 carry
721
+ * parses `credentials.toml` itself and needs the SAME validation, not a copy of
722
+ * it. The validation is the part worth not duplicating — "a cloud block without
723
+ * a token is not a cloud block" is a security property, and two readers
724
+ * disagreeing about it is how a half-written credential gets treated as live.
725
+ */
726
+ export function projectCredentials(parsed: Record<string, unknown>): FpCredentials {
727
+ {
728
+ const cloud = parsed.cloud as Record<string, unknown> | undefined;
729
+ const ingest = parsed.ingest as Record<string, unknown> | undefined;
730
+ const auth = parsed.auth as Record<string, unknown> | undefined;
731
+ const out: FpCredentials = {};
732
+ if (
733
+ cloud &&
734
+ typeof cloud.url === "string" &&
735
+ typeof cloud.machine_id === "string" &&
736
+ typeof cloud.token === "string" &&
737
+ cloud.url &&
738
+ cloud.token
739
+ ) {
740
+ const machineLabel =
741
+ typeof cloud.machine_label === "string" && cloud.machine_label ? cloud.machine_label : undefined;
742
+ out.cloud = { url: cloud.url, machineId: cloud.machine_id, token: cloud.token, machineLabel };
743
+ }
744
+ const org = parsed.org as Record<string, unknown> | undefined;
745
+ if (org) {
746
+ // Every field independently optional: a server may send fewer than three,
747
+ // and a partial org record is still better than none for `--status`.
748
+ const pick = (v: unknown) => (typeof v === "string" && v ? v : undefined);
749
+ const built = { id: pick(org.id), slug: pick(org.slug), name: pick(org.name) };
750
+ if (built.id || built.slug || built.name) out.org = built;
751
+ }
752
+ if (ingest && typeof ingest.url === "string" && typeof ingest.key === "string" && ingest.url && ingest.key) {
753
+ out.ingest = { url: ingest.url, key: ingest.key };
754
+ }
755
+ if (auth) {
756
+ out.auth = {
757
+ baseUrl: typeof auth.base_url === "string" ? auth.base_url : undefined,
758
+ sessionToken: typeof auth.session_token === "string" ? auth.session_token : undefined,
759
+ expiresAt: typeof auth.expires_at === "number" ? auth.expires_at : undefined,
760
+ email: typeof auth.email === "string" ? auth.email : undefined,
761
+ };
762
+ }
763
+ return out;
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Write credentials at 0600, tightening the home that holds them.
769
+ *
770
+ * A 0600 file inside a 0775 directory is still reachable by every local user,
771
+ * so the directory is tightened too. `mode` on `writeFileSync` applies only
772
+ * when the file is CREATED, so an existing over-permissive file keeps its mode
773
+ * without the explicit chmod.
774
+ */
775
+ export function writeCredentials(creds: FpCredentials, raw?: Record<string, unknown>): void {
776
+ const out: Record<string, unknown> = {};
777
+ if (creds.cloud) {
778
+ out.cloud = {
779
+ url: creds.cloud.url,
780
+ machine_id: creds.cloud.machineId,
781
+ ...(creds.cloud.machineLabel ? { machine_label: creds.cloud.machineLabel } : {}),
782
+ token: creds.cloud.token,
783
+ };
784
+ }
785
+ if (creds.ingest) {
786
+ out.ingest = { url: creds.ingest.url, key: creds.ingest.key };
787
+ }
788
+ if (creds.org && (creds.org.id || creds.org.slug || creds.org.name)) {
789
+ out.org = {
790
+ ...(creds.org.id ? { id: creds.org.id } : {}),
791
+ ...(creds.org.slug ? { slug: creds.org.slug } : {}),
792
+ ...(creds.org.name ? { name: creds.org.name } : {}),
793
+ };
794
+ }
795
+ if (creds.auth && (creds.auth.sessionToken || creds.auth.email)) {
796
+ out.auth = {
797
+ ...(creds.auth.baseUrl ? { base_url: creds.auth.baseUrl } : {}),
798
+ ...(creds.auth.sessionToken ? { session_token: creds.auth.sessionToken } : {}),
799
+ ...(creds.auth.expiresAt ? { expires_at: creds.auth.expiresAt } : {}),
800
+ ...(creds.auth.email ? { email: creds.auth.email } : {}),
801
+ };
802
+ }
803
+
804
+ // Same three-step as `writeConfig`: previous bytes, minus the keys this build
805
+ // owns, plus the projection. An unowned key here is a credential a newer build
806
+ // stored, and deleting one costs the machine an enrolment it cannot re-derive.
807
+ const merged = structuredClone(raw ?? readCredentialsRaw().raw);
808
+ for (const path of OWNED_CREDENTIAL_KEYS) deleteOwnedPath(merged, path);
809
+ mergeOnto(merged, out);
810
+
811
+ const home = failproofaiHome();
812
+ mkdirSync(home, { recursive: true });
813
+ try {
814
+ if (statSync(home).mode & 0o077) chmodSync(home, 0o700);
815
+ } catch {
816
+ // Not fatal: the file's own 0600 is the primary protection.
817
+ }
818
+ writeFileSync(credentialsFile(), `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
819
+ try {
820
+ chmodSync(credentialsFile(), 0o600);
821
+ } catch {
822
+ /* best effort on filesystems without POSIX modes */
823
+ }
824
+ }
825
+
826
+ /** True when this machine has any cloud credential at all. */
827
+ export function hasCloudCredentials(): boolean {
828
+ const c = readCredentials();
829
+ return Boolean(c.cloud || c.ingest);
830
+ }
831
+
832
+ function writeFileAt(path: string, contents: string): void {
833
+ mkdirSync(dirname(path), { recursive: true });
834
+ writeFileSync(path, contents, "utf8");
835
+ }