failproofai 1.0.0 → 1.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +0 -1
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +5 -5
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +0 -6
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +4 -3
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +4 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js +3 -3
  28. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +4 -3
  32. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  36. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -3
  38. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  42. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/index.html +1 -1
  45. package/.next/standalone/.next/server/app/index.rsc +15 -15
  46. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  47. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  48. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js +4 -3
  51. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  54. package/.next/standalone/.next/server/app/policies/page.js +6 -5
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page.js +5 -4
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  67. package/.next/standalone/.next/server/app/projects/page.js +5 -4
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  71. package/.next/standalone/.next/server/app/settings/page.js +6 -4
  72. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  74. package/.next/standalone/.next/server/app-paths-manifest.json +0 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0nne229._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ny9q5d._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1hstmft._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p8poxd._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0jx6cp6._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0f-x2b0._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1f290jp._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0hwvl7e._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1g2ji1t._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1ctjdzl._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03eykvd._.js +5 -0
  95. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07175vb._.js → [root-of-the-server]__07oh8b5._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cxe_2_._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g2rg69._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m98kzy._.js +4 -0
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0sjzdjg._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0v5_91z._.js +4 -0
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-a3135._.js +4 -0
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1hvwiw8._.js → [root-of-the-server]__11z_299._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apc90t._.js → [root-of-the-server]__15c_z97._.js} +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1wmno1q._.js → [root-of-the-server]__17d_ffl._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +4 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e5g_ur._.js +4 -0
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hmqefd._.js +4 -0
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +6 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_04j57fb._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/_05enntk._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/ssr/_0hom1s1._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/ssr/_1nq-35m._.js +23 -0
  118. package/.next/standalone/.next/server/chunks/ssr/_1q7j05x._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  121. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  123. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +3 -0
  124. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  127. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +3 -0
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/standalone/.next/server/pages/404.html +1 -1
  130. package/.next/standalone/.next/server/pages/500.html +1 -1
  131. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  133. package/.next/standalone/.next/static/chunks/00q6cfzuzpc85.js +1 -0
  134. package/.next/standalone/.next/static/chunks/{0zm3_8jpqitxy.js → 054eg7y_cjyf9.js} +22 -22
  135. package/.next/standalone/.next/static/chunks/{2ouq44jv_0x04.js → 0mjufa_0xxz75.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +1 -0
  137. package/.next/standalone/.next/static/chunks/{0zi8zjav5a4rq.js → 1-oz4co31nvc5.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/13--2yvqek_s1.js +1 -0
  139. package/.next/standalone/.next/static/chunks/136ldjmzxhush.js +1 -0
  140. package/.next/standalone/.next/static/chunks/18fqy3doo2ial.js +6 -0
  141. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{31k3yxpy57_fy.js → 1o5sxp_mdaoci.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/29-iqnp9v_axt.css +1 -0
  144. package/.next/standalone/.next/static/chunks/{2676v-lyzgo91.js → 2es4z1ei90vsv.js} +1 -1
  145. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +1 -0
  146. package/.next/standalone/.next/static/chunks/3peix2vxzj7eh.js +1 -0
  147. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +1 -0
  148. package/.next/standalone/.next/static/chunks/{0km-jn9o5xyqs.css → 3wq9x0c-7ld5d.css} +1 -1
  149. package/.next/standalone/app/actions/get-scheduled-audit.ts +65 -6
  150. package/.next/standalone/app/actions/update-scheduled-audit.ts +90 -17
  151. package/.next/standalone/app/api/auth/status/route.ts +7 -17
  152. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +2 -6
  153. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +101 -281
  154. package/.next/standalone/app/audit/_components/empty-state.tsx +2 -2
  155. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  156. package/.next/standalone/app/audit/audit-styles.css +150 -26
  157. package/.next/standalone/app/globals.css +39 -1
  158. package/.next/standalone/app/settings/page.tsx +38 -21
  159. package/.next/standalone/app/settings/settings-client.tsx +583 -392
  160. package/.next/standalone/app/settings/settings.css +354 -0
  161. package/.next/standalone/components/navbar.tsx +33 -4
  162. package/.next/standalone/lib/auth/api-server-client.ts +51 -29
  163. package/.next/standalone/lib/auth/auth-store.ts +44 -59
  164. package/.next/standalone/package.json +12 -10
  165. package/.next/standalone/server.js +1 -1
  166. package/bin/failproofai.mjs +6 -0
  167. package/bin/failproofaid-shim.mjs +0 -0
  168. package/dist/cli.mjs +1889 -426
  169. package/dist/worker.mjs +27 -5
  170. package/lib/auth/api-server-client.ts +51 -29
  171. package/lib/auth/auth-store.ts +44 -59
  172. package/package.json +12 -10
  173. package/scripts/docs-audit.ts +493 -0
  174. package/scripts/translate-docs/cli.ts +16 -2
  175. package/scripts/translate-docs/mdx-translator.ts +6 -1
  176. package/scripts/translate-docs/readme-translator.ts +4 -2
  177. package/src/audit/cli-login.ts +330 -0
  178. package/src/audit/cli.ts +124 -6
  179. package/src/audit/dashboard-cache.ts +44 -8
  180. package/src/audit/harm-report.ts +260 -0
  181. package/src/audit/machine-store.ts +120 -0
  182. package/src/audit/redact-example.ts +312 -0
  183. package/src/audit/report-harm.ts +167 -0
  184. package/src/audit/schedule-cli.ts +352 -0
  185. package/src/hooks/builtin-policies.ts +26 -0
  186. package/src/hooks/daemon-service.ts +76 -0
  187. package/src/hooks/fp-config.ts +96 -5
  188. package/src/hooks/fp-home.ts +118 -8
  189. package/src/hooks/fp-reset.ts +79 -9
  190. package/src/hooks/migrations.ts +241 -4
  191. package/src/hooks/tui.ts +61 -3
  192. package/.next/standalone/.next/server/app/api/auth/reminder/route/app-paths-manifest.json +0 -3
  193. package/.next/standalone/.next/server/app/api/auth/reminder/route/build-manifest.json +0 -12
  194. package/.next/standalone/.next/server/app/api/auth/reminder/route/server-reference-manifest.json +0 -4
  195. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +0 -9
  196. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.map +0 -5
  197. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +0 -1
  198. package/.next/standalone/.next/server/app/api/auth/reminder/route_client-reference-manifest.js +0 -3
  199. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-xggru._.js +0 -3
  200. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00ds05_._.js +0 -3
  201. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +0 -3
  202. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dxagv_._.js +0 -3
  203. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fgqj1_._.js +0 -3
  204. package/.next/standalone/.next/server/chunks/_084toou._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/_0g4t8c9._.js +0 -3
  206. package/.next/standalone/.next/server/chunks/_0km521a._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/_0t8kefc._.js +0 -3
  208. package/.next/standalone/.next/server/chunks/_16dwkfn._.js +0 -3
  209. package/.next/standalone/.next/server/chunks/_1phm9ui._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_auth_reminder_route_actions_1kjgxf8.js +0 -3
  211. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dk8r_l._.js +0 -3
  212. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kviq2m._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ogyhu0._.js +0 -3
  214. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-rfu32._.js +0 -5
  215. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
  216. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1t62w37._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/_032wo7w._.js +0 -3
  218. package/.next/standalone/.next/server/chunks/ssr/_08840em._.js +0 -3
  219. package/.next/standalone/.next/server/chunks/ssr/_0gl2ujp._.js +0 -3
  220. package/.next/standalone/.next/server/chunks/ssr/_0j0ggkc._.js +0 -3
  221. package/.next/standalone/.next/server/chunks/ssr/_0kbv5-q._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/ssr/_0r1jgvp._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/ssr/_0t5xq1b._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/_0w_be0g._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/_15gba46._.js +0 -5
  226. package/.next/standalone/.next/server/chunks/ssr/_1qyhb9r._.js +0 -23
  227. package/.next/standalone/.next/server/chunks/ssr/_1vel2ey._.js +0 -3
  228. package/.next/standalone/.next/static/chunks/0wd503tv801gy.js +0 -1
  229. package/.next/standalone/.next/static/chunks/1hfocb0sdl85c.js +0 -1
  230. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +0 -1
  231. package/.next/standalone/.next/static/chunks/2kopjq_or743g.js +0 -6
  232. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +0 -2
  233. package/.next/standalone/.next/static/chunks/2ynl0-6aqng_b.js +0 -1
  234. package/.next/standalone/.next/static/chunks/3xvtgz6arztp_.js +0 -1
  235. package/.next/standalone/app/api/auth/reminder/route.ts +0 -213
  236. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_buildManifest.js +0 -0
  237. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_clientMiddlewareManifest.js +0 -0
  238. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_ssgManifest.js +0 -0
@@ -56,7 +56,12 @@
56
56
  * policies/ every policy: the user's *.mjs sit directly here
57
57
  * cloud-policies/ the fleet's — flat: active.json, desired-state.json, artifacts/
58
58
  * cursors/<source>/ per-source collector watermarks
59
- * audit/ audit report + per-session cache
59
+ * audit/ MIXED — see the classification note below
60
+ * dashboard.json last result (derived)
61
+ * cache/ per-transcript cache (derived)
62
+ * schedule.json daemon's scan timer (derived)
63
+ * session.json 0600 the signed-in user (user-typed)
64
+ * machine.json this machine's report identity (identity)
60
65
  * hook-activity/ decision log the dashboard reads
61
66
  * custom-agents/ SDK spool (events/ + failed/)
62
67
  * run/ sockets + flock — MUST stay shallow, see below
@@ -88,9 +93,17 @@ import { resolve } from "node:path";
88
93
  * yields "no data" instead of an error.
89
94
  *
90
95
  * 1 — the original flat/`cache`-based layout, through 1.0.0-beta.5.
91
- * 2 — this file.
96
+ * 2 — `config.toml` / `credentials.toml`, policies nested two levels down.
97
+ * 3 — JSON config + credentials, policies flattened back up.
98
+ * 4 — everything the audit owns moved under `audit/`: the signed-in session
99
+ * (from `auth.json`), the daemon's scan timer (from
100
+ * `state/audit-schedule.json`), and the re-audit reminder (from
101
+ * `next-audit.json`, parked at `audit/reminder.json` and retired in the
102
+ * same release — see `legacy.auditReminder`). The point is that one
103
+ * directory now answers "what does the audit know about this machine", the
104
+ * way `policies/` answers it for enforcement.
92
105
  */
93
- export const LAYOUT_VERSION = 3;
106
+ export const LAYOUT_VERSION = 4;
94
107
 
95
108
  /**
96
109
  * `~/.failproofai`, or `FAILPROOFAI_HOME`.
@@ -212,10 +225,54 @@ export const customAgentsFailedDir = (home?: string) => resolve(customAgentsDir(
212
225
 
213
226
  // ── Audit ────────────────────────────────────────────────────────────────────
214
227
 
228
+ /**
229
+ * Everything the audit owns, and a MIXED directory as of layout 4.
230
+ *
231
+ * `auditDir` is deliberately NOT classified in `HOME_CLASSES`, for exactly the
232
+ * reason `stateDir` is not: it now holds a credential and a machine identity
233
+ * alongside two caches, so one class cannot be right for all of it. Before
234
+ * layout 4 the whole directory was `derived` — correct then, and the trap the
235
+ * moment `session.json` moved in, because `resettablePaths()` is a filter over
236
+ * that table and would have deleted the user's tokens on every reset and every
237
+ * future migration. Classify the CHILDREN; never the parent.
238
+ */
215
239
  export const auditDir = (home?: string) => atHome(home, "audit");
216
240
  export const auditDashboardFile = (home?: string) => resolve(auditDir(home), "dashboard.json");
217
241
  export const auditCacheDir = (home?: string) => resolve(auditDir(home), "cache");
218
242
 
243
+ /**
244
+ * The signed-in user's tokens. `0600`, written only by the dashboard's auth
245
+ * routes and the audit child (`lib/auth/auth-store.ts`).
246
+ *
247
+ * Layout 3 kept this at the home root as `auth.json`, where it was invisible to
248
+ * `HOME_CLASSES` altogether — neither classified nor deleted, safe by accident
249
+ * rather than by decision. It is `user-typed`: nothing regenerates a session,
250
+ * and dropping it silently signs the machine out.
251
+ *
252
+ * TS-only, so it is absent from `paths.rs` by design: the daemon never opens
253
+ * it. The audit child does the reporting precisely so the daemon holds no human
254
+ * credential — see `audit_lane.rs`.
255
+ */
256
+ export const auditSessionFile = (home?: string) => resolve(auditDir(home), "session.json");
257
+
258
+ /**
259
+ * This machine's report identity: the id the api-server keys reports on, and
260
+ * the watermark saying how far the last digest reached.
261
+ *
262
+ * SEPARATE from `auditSessionFile` on purpose, and the separation is the whole
263
+ * design. Both fields have to outlive a sign-out: regenerate the id and the
264
+ * server sees a brand-new machine and burns a slot off the account's cap on
265
+ * every logout; reset the watermark and the next digest re-reports months of
266
+ * history as though it just happened. So this is `identity` — never deleted,
267
+ * like `cursors/` and `telemetryIdFile` — while the tokens beside it come and
268
+ * go with the session.
269
+ *
270
+ * Minted fresh rather than reusing `telemetryIdFile`, so opting into emailed
271
+ * reports never links the anonymous telemetry person to a verified address.
272
+ */
273
+ export const auditMachineFile = (home?: string) => resolve(auditDir(home), "machine.json");
274
+
275
+
219
276
  // ── Hook activity ────────────────────────────────────────────────────────────
220
277
 
221
278
  /** The decision log: page-sized JSONL the dashboard's activity tab reads. */
@@ -270,10 +327,17 @@ export const sessionPauseDir = () => resolve(stateDir(), "sessions");
270
327
  * mirrors this path in `paths.rs`) — it owns the schedule, and a second writer
271
328
  * racing it could hand a machine two full scans back to back. Everything on this
272
329
  * side reads it: the interval itself lives in `config.json`'s `audit` object,
273
- * which a human edits, while this file is derived state a human never opens,
274
- * which is why it sits under `state/` rather than beside the audit results.
330
+ * which a human edits, while this file is derived state a human never opens.
331
+ *
332
+ * Layout 4 moved it out of `state/` and in beside the audit results. It stays
333
+ * `derived` — losing it costs one rescheduled scan, nothing more — but it now
334
+ * sits with the rest of what the audit owns rather than in the daemon's scratch
335
+ * drawer, which is what makes `audit/` answerable as one directory.
336
+ *
337
+ * Declared here, below `stateDir`, only because the section order of this file
338
+ * is historical; the path itself is under `auditDir`.
275
339
  */
276
- export const auditScheduleFile = (home?: string) => resolve(stateDir(home), "audit-schedule.json");
340
+ export const auditScheduleFile = (home?: string) => resolve(auditDir(home), "schedule.json");
277
341
  /**
278
342
  * The anonymous instance id this machine reports telemetry under.
279
343
  *
@@ -427,6 +491,13 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da
427
491
  // source destroyed, while `isConfigured()` still read true so the wizard never
428
492
  // re-asked and hooks kept firing against an empty policy set.
429
493
  { path: policiesDir, class: "user-typed" },
494
+ // The signed-in session. `auth.json` at the home root through layout 3, where
495
+ // it was in NEITHER this table nor the delete list — undeleted by oversight
496
+ // rather than by decision, which is the state this table exists to make
497
+ // impossible. Nothing regenerates a session; losing it signs the machine out
498
+ // with no notice, and the machine only finds out the next time it tries to
499
+ // report.
500
+ { path: auditSessionFile, class: "user-typed" },
430
501
 
431
502
  // ── Never deleted: recorded and not yet shipped ──
432
503
  // Batches read out of transcripts and queued for upload. The reason losing
@@ -471,10 +542,23 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da
471
542
  // deleting the backup is deleting the undo for the step that just ran.
472
543
  { path: migrationsDir, class: "identity" },
473
544
 
545
+ // This machine's report identity + digest watermark. `identity` for the same
546
+ // reason `cursorsDir` is: a new id is a new machine to the api-server, which
547
+ // burns a slot off the account's machine cap, and a reset watermark re-reports
548
+ // history the user was already told about. Kept OUT of `auditSessionFile`
549
+ // precisely so both survive a sign-out.
550
+ { path: auditMachineFile, class: "identity" },
551
+
474
552
  // ── May be dropped: rebuilt on demand ──
475
- { path: auditDir, class: "derived" },
476
- { path: collectorHealthFile, class: "derived" },
553
+ // NOTE: `auditDir` itself is deliberately absent. Layout 4 made it MIXED — it
554
+ // holds the session and the machine identity above alongside these three — so
555
+ // it is classified per-file, exactly like `stateDir`. Listing the parent here
556
+ // (which layout 3 did, correctly for what it then held) would put the token on
557
+ // the delete list.
558
+ { path: auditDashboardFile, class: "derived" },
559
+ { path: auditCacheDir, class: "derived" },
477
560
  { path: auditScheduleFile, class: "derived" },
561
+ { path: collectorHealthFile, class: "derived" },
478
562
  { path: codexSessionPathsFile, class: "derived" },
479
563
  { path: shimsDir, class: "derived" },
480
564
  { path: sessionPauseDir, class: "derived" },
@@ -555,6 +639,28 @@ export const legacy = {
555
639
  launcherMarker: () => at(".launcher-configured"),
556
640
  lastVersion: () => at("last-version"),
557
641
  auditDashboard: () => at("audit-dashboard.json"),
642
+ /**
643
+ * Layout 3's audit-owned files, before layout 4 gathered them under `audit/`.
644
+ *
645
+ * The first two were never classified in `HOME_CLASSES`, so unlike every other
646
+ * entry in this map they were not on any delete list — the layout-4 step MOVES
647
+ * them and there is no older copy to prune. They are here so that step can
648
+ * find them, and so `filesToBackUp()` copies them aside first: a bug in the
649
+ * move would otherwise take a live session with it.
650
+ */
651
+ authJson: () => at("auth.json"),
652
+ nextAudit: () => at("next-audit.json"),
653
+ /**
654
+ * Layout 4's `audit/reminder.json`, retired before it was ever written to.
655
+ *
656
+ * The layout-4 step MOVES `next-audit.json` here rather than deleting it,
657
+ * because the scheduled-audit work that replaces reminders had not landed yet
658
+ * and dropping a cadence someone chose would have been unrecoverable if it
659
+ * slipped. It has landed; the reminder concept is gone, and this is the
660
+ * position the file was parked in. Listed so a reset clears it.
661
+ */
662
+ auditReminder: () => at("audit", "reminder.json"),
663
+ auditSchedule: () => at("state", "audit-schedule.json"),
558
664
  cacheDir: () => at("cache"),
559
665
  hookActivityDir: () => at("cache", "hook-activity"),
560
666
  auditCacheDir: () => at("cache", "audit"),
@@ -640,6 +746,10 @@ function retiredLayoutPaths(): string[] {
640
746
  // `migrateHookActivity()`, and everything else in `cache/` still goes —
641
747
  // both remaining entries are re-derived on demand.
642
748
  legacy.auditCacheDir(),
749
+ // The reminder, at the position layout 4 parked it in. It is on this list
750
+ // rather than in `HOME_CLASSES` because the path is RETIRED: nothing writes
751
+ // it any more, so it has no class to carry — only a location to clear.
752
+ legacy.auditReminder(),
643
753
  legacy.codexSessionPaths(),
644
754
  legacy.spoolDir(),
645
755
  legacy.failedDir(),
@@ -910,7 +910,12 @@ export function readCarriedLegacyCredentials(): FpCredentials | null {
910
910
  return Object.keys(creds).length > 0 ? creds : null;
911
911
  }
912
912
 
913
- export function resetHome(from: number): ResetOutcome {
913
+ /**
914
+ * @param to The layout this step LANDS on, which is not always the current one.
915
+ * Defaults to `LAYOUT_VERSION` for a direct call, but the registry passes the
916
+ * step's own `to` — see the stamp at the end of this function.
917
+ */
918
+ export function resetHome(from: number, to: number = LAYOUT_VERSION): ResetOutcome {
914
919
  // BEFORE the deletions, so a file that is mid-move is never one the reset
915
920
  // then walks over.
916
921
  const migrated = migrateConventionPolicies();
@@ -959,7 +964,22 @@ export function resetHome(from: number): ResetOutcome {
959
964
  // straight out of the same file. Both paths end at the same key, and only one
960
965
  // of them represents something a person typed.
961
966
  if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } });
962
- writeVersionFile();
967
+ // The step's OWN target, not LAYOUT_VERSION.
968
+ //
969
+ // Every step used to end stamping the current layout, which was harmless
970
+ // while every chain was one hop. On `1 → 3 → 4` it means the first step
971
+ // marks the home layout 4 with its files still at layout 3, and the window
972
+ // between that stamp and the next step completing is a real one: a SIGKILL,
973
+ // an OOM or a power loss inside it leaves a home that reads as `current`
974
+ // forever. `detectLayout()` short-circuits on the marker, so no later
975
+ // command re-examines the landmarks, and `auth.json` sits at the root while
976
+ // layout 4 reads `audit/session.json` — a machine silently signed out with
977
+ // its session on disk and nothing that would ever move it.
978
+ //
979
+ // `runMigrations` also repairs an over-stamp in its `catch`, but a killed
980
+ // process runs no catch. Stamping the truth in the first place is what makes
981
+ // that repair a second line of defence rather than the only one.
982
+ writeVersionFile({ layout: to });
963
983
  return { removed, migrated, activity, policyConfig, spooled, from };
964
984
  }
965
985
 
@@ -1094,6 +1114,10 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
1094
1114
  // After, not before — see the function's own note for why the intuitive
1095
1115
  // order cannot work.
1096
1116
  const pending = await drainSpoolAfterMigrating();
1117
+ // Read AFTER the migration: on a machine coming from layout 1 or 2 the
1118
+ // config this reads is the one the migration just carried across, so asking
1119
+ // any earlier would read a file that is about to move.
1120
+ const daemonHint = staleDaemonHint();
1097
1121
  return {
1098
1122
  state,
1099
1123
  fatal: false,
@@ -1160,6 +1184,14 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
1160
1184
  // so the machine enforces exactly as it did before this command ran. A
1161
1185
  // home that genuinely never finished setup reaches the wizard through
1162
1186
  // `shouldOfferFirstRun`, which reads `isConfigured()` — see `didReset`.
1187
+ //
1188
+ // The daemon hint belongs HERE above all, and was missing. This branch
1189
+ // is the one that moves the home and stamps the new layout marker — it
1190
+ // is the command that CREATES the incompatibility with an unrefreshed
1191
+ // daemon, and it was the one command saying nothing about it. Every
1192
+ // later command reached the non-stale return below and got the hint;
1193
+ // the one where the user is watching the reorganisation happen did not.
1194
+ ...(daemonHint.length > 0 ? ["", ...daemonHint] : []),
1163
1195
  ],
1164
1196
  };
1165
1197
  }
@@ -1269,20 +1301,58 @@ async function healDaemonFlag(): Promise<string[]> {
1269
1301
  }
1270
1302
 
1271
1303
  /**
1272
- * One line when the daemon is older than the CLI.
1273
- *
1274
- * Deliberately NOT on the hook path. A stale daemon still enforces every policy
1275
- * correctly — it is slower to notice an upgrade, not broken — so a warning once
1276
- * per tool call would be noise about something that is working. CLI commands
1277
- * are where a person is present to act on it.
1304
+ * What to say when the daemon's version does not match the CLI's.
1305
+ *
1306
+ * Deliberately NOT on the hook path. CLI commands are where a person is present
1307
+ * to act on it, and once per tool call would be noise.
1308
+ *
1309
+ * Two messages, because the stakes are not the same on both kinds of machine.
1310
+ *
1311
+ * On a machine that does NOT require the daemon, a stale one is what it looks
1312
+ * like: slower to notice an upgrade, still enforcing correctly.
1313
+ *
1314
+ * On a machine that DOES — `daemon.configured` — it is a scheduled outage.
1315
+ * failproofaid calls `refuse_foreign_layout()` before it binds its socket and
1316
+ * exits when the home's layout marker is not the one its binary was built
1317
+ * against, and a release that moves `~/.failproofai` therefore strands every
1318
+ * daemon that has not been refreshed. Nothing looks wrong in the meantime: the
1319
+ * running process read the marker once at startup and keeps serving from
1320
+ * memory. The failure lands at the next restart — a reboot, a crash,
1321
+ * `systemctl restart` — where the unit exits nonzero, `Restart=on-failure`
1322
+ * trips the start limit, and the service latches `failed`. From there the
1323
+ * machine fails closed and denies every tool call across all 11 CLIs, and
1324
+ * `healDaemonFlag()` will not rescue it because a layout-refusing unit reads as
1325
+ * `stopped`, which it deliberately excludes.
1326
+ *
1327
+ * This used to say a stale daemon "is slower to notice an upgrade, not broken"
1328
+ * to everybody, and pointed at `failproofai config`. Across a layout bump that
1329
+ * is the wrong sentence and the wrong command.
1278
1330
  */
1279
1331
  function staleDaemonHint(): string[] {
1280
1332
  try {
1281
1333
  const skew = daemonVersionSkew();
1282
1334
  if (!skew) return [];
1335
+ let requiresDaemon = false;
1336
+ try {
1337
+ requiresDaemon = readConfig().daemon.configured;
1338
+ } catch {
1339
+ // Unreadable config: fall through to the mild message rather than
1340
+ // frightening somebody whose machine may not require the daemon at all.
1341
+ }
1342
+ if (requiresDaemon) {
1343
+ return [
1344
+ `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected}.`,
1345
+ `This machine is configured to REQUIRE the daemon. A daemon built against a`,
1346
+ `different on-disk layout refuses to start, and this version moved it — so the`,
1347
+ `next reboot or restart can leave the service down, which denies every tool`,
1348
+ `call until it is fixed.`,
1349
+ `Run \`failproofai update\` now to bring the daemon in line.`,
1350
+ ``,
1351
+ ];
1352
+ }
1283
1353
  return [
1284
1354
  `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected} — ` +
1285
- `run \`failproofai config\` to update it.`,
1355
+ `run \`failproofai update\` to update it.`,
1286
1356
  ``,
1287
1357
  ];
1288
1358
  } catch {
@@ -37,11 +37,22 @@
37
37
  * than counting. A chain from 1 today is one step; when layout 4 lands it becomes
38
38
  * `1 → 3` then `3 → 4`, and only the second has to be written.
39
39
  */
40
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
41
- import { basename, dirname, resolve } from "node:path";
40
+ import {
41
+ chmodSync,
42
+ copyFileSync,
43
+ existsSync,
44
+ mkdirSync,
45
+ readFileSync,
46
+ renameSync,
47
+ rmSync,
48
+ writeFileSync,
49
+ } from "node:fs";
50
+ import { basename, dirname, join, resolve } from "node:path";
42
51
  import { version as cliVersion } from "../../package.json";
43
52
  import {
44
53
  LAYOUT_VERSION,
54
+ auditScheduleFile,
55
+ auditSessionFile,
45
56
  configFile,
46
57
  credentialsFile,
47
58
  failproofaiHome,
@@ -52,6 +63,7 @@ import {
52
63
  migrationsDir,
53
64
  versionFile,
54
65
  } from "./fp-home";
66
+ import { readVersionFile, writeVersionFile } from "./fp-config";
55
67
  import { resetHome, type ResetOutcome } from "./fp-reset";
56
68
 
57
69
  export interface Migration {
@@ -78,17 +90,154 @@ export const MIGRATIONS: readonly Migration[] = [
78
90
  to: 3,
79
91
  describe:
80
92
  "layout 1 → 3: carry the decision log out of cache/, keep the policy config in place, drop the layout-1 credential files",
81
- run: () => resetHome(1),
93
+ run: () => resetHome(1, 3),
82
94
  },
83
95
  {
84
96
  from: 2,
85
97
  to: 3,
86
98
  describe:
87
99
  "layout 2 → 3: carry config.toml and credentials.toml into JSON, move custom-policies/ back up into policies/, nest the policy config at the root",
88
- run: () => resetHome(2),
100
+ run: () => resetHome(2, 3),
101
+ },
102
+ {
103
+ from: 3,
104
+ to: 4,
105
+ describe:
106
+ "layout 3 → 4: gather the audit's files under audit/ — auth.json becomes audit/session.json, next-audit.json becomes audit/reminder.json, state/audit-schedule.json becomes audit/schedule.json",
107
+ run: migrateToLayout4,
89
108
  },
90
109
  ];
91
110
 
111
+ /**
112
+ * Layout 3 → 4. The first step written against this registry rather than
113
+ * delegating to `resetHome`, which is what the header promised: additive.
114
+ *
115
+ * Three moves, no deletions. Each is a rename with a copy fallback, because
116
+ * `audit/` and the home root can sit on different filesystems once `$HOME` is a
117
+ * network mount or the home has been assembled by a container bind — `rename(2)`
118
+ * returns `EXDEV` there, and a step that threw on it would strand the machine at
119
+ * layout 3 forever.
120
+ *
121
+ * The reminder's destination is `legacy.auditReminder()`, a RETIRED path. The
122
+ * feature it belonged to is deleted in this same release, so nothing will ever
123
+ * read the file again — but a migration that DESTROYS something a person chose
124
+ * is a different act from one that moves it, and the difference matters even
125
+ * when the thing is obsolete. It is moved here and cleared by the next reset,
126
+ * via `retiredLayoutPaths()`.
127
+ *
128
+ * **A missing source is success, not failure.** Most homes have never signed in,
129
+ * so `auth.json` and `next-audit.json` are absent on the majority of machines,
130
+ * and a scheduled scan that has never run leaves no `audit-schedule.json`. Only
131
+ * a source that EXISTS and could not be moved is an error worth stopping for.
132
+ *
133
+ * **A destination that already exists wins.** Re-running the step — which is
134
+ * exactly what happens when a later step in the same chain throws and the user
135
+ * retries — must not copy a stale layout-3 file back over the layout-4 one that
136
+ * has since been written to.
137
+ */
138
+ function migrateToLayout4(): ResetOutcome {
139
+ const moves: { from: string; to: string }[] = [
140
+ { from: legacy.authJson(), to: auditSessionFile() },
141
+ { from: legacy.nextAudit(), to: legacy.auditReminder() },
142
+ { from: legacy.auditSchedule(), to: auditScheduleFile() },
143
+ ];
144
+
145
+ // `FAILPROOFAI_AUTH_DIR` names a directory OUTSIDE the managed home — a
146
+ // documented env var, not a test hook — and `auth-store` resolves the session
147
+ // relative to it. Every path above comes from `FAILPROOFAI_HOME`, so without
148
+ // this the override directory is never visited: the file stays `auth.json`,
149
+ // layout 4 reads `session.json`, and the upgrade signs the user out silently.
150
+ // Their scans keep running and their digests stop, which is the failure this
151
+ // whole area is built to avoid.
152
+ //
153
+ // The same two moves, in their directory, so one naming scheme holds
154
+ // everywhere rather than the file having a different name depending on how
155
+ // the process was configured.
156
+ const authDirOverride = process.env.FAILPROOFAI_AUTH_DIR;
157
+ if (authDirOverride) {
158
+ moves.push(
159
+ { from: join(authDirOverride, "auth.json"), to: join(authDirOverride, "session.json") },
160
+ {
161
+ from: join(authDirOverride, "next-audit.json"),
162
+ to: join(authDirOverride, "reminder.json"),
163
+ },
164
+ );
165
+ }
166
+
167
+ const migrated: string[] = [];
168
+ for (const { from, to } of moves) {
169
+ if (!existsSync(from)) continue;
170
+ if (existsSync(to)) {
171
+ // The layout-4 file is already authoritative. Drop the stale original
172
+ // rather than leaving a second copy of a credential lying at the root.
173
+ //
174
+ // A failure here PROPAGATES. Swallowing it continued to `writeVersionFile`
175
+ // and stamped the home as layout 4 with `auth.json` — a live bearer token
176
+ // — still sitting at the root, where nothing would ever look at it again
177
+ // and nothing would ever clean it up. Throwing leaves the home at layout 3
178
+ // and the next command retries, which is exactly what `runMigrations`
179
+ // documents a failed step to mean; the destination is already
180
+ // authoritative, so the retry is a no-op plus one more delete attempt.
181
+ rmSync(from, { force: true });
182
+ continue;
183
+ }
184
+ mkdirSync(dirname(to), { recursive: true });
185
+ try {
186
+ renameSync(from, to);
187
+ } catch {
188
+ // EXDEV, or a rename racing something holding the file open on Windows.
189
+ try {
190
+ copyFileSync(from, to);
191
+ } catch (err) {
192
+ // Remove the PARTIAL destination before giving up.
193
+ //
194
+ // `copyFileSync` is not atomic: ENOSPC or a kill part-way through
195
+ // leaves a truncated `to` on disk. The source is still intact at this
196
+ // point, so nothing is lost yet — but the next attempt takes the
197
+ // `existsSync(to)` branch above, reads that fragment as the
198
+ // authoritative layout-4 file, and deletes the good original. A
199
+ // half-written session file is not a session, so the retry would have
200
+ // signed the machine out using the very branch that exists to protect
201
+ // the credential.
202
+ try {
203
+ rmSync(to, { force: true });
204
+ } catch {
205
+ // Nothing better to do. The throw below still leaves the home at
206
+ // layout 3, so `deleteAuth`'s sweep and the backup both still apply.
207
+ }
208
+ throw err;
209
+ }
210
+ rmSync(from, { force: true });
211
+ }
212
+ migrated.push(`${basename(from)} → audit/${basename(to)}`);
213
+ }
214
+
215
+ // `session.json` carries tokens and `auth.json` was written 0600 by
216
+ // `writeJsonAtomically`. A rename preserves the mode, but a copy fallback
217
+ // inherits the process umask — so reassert it rather than assume which branch
218
+ // ran. Belt and braces on a file whose whole content is a bearer credential.
219
+ for (const secret of [auditSessionFile()]) {
220
+ if (!existsSync(secret)) continue;
221
+ try {
222
+ chmodSync(secret, 0o600);
223
+ } catch {
224
+ // Best effort, exactly as `writeJsonAtomically` treats it.
225
+ }
226
+ }
227
+
228
+ // The same stamper every other write of this file goes through. Hand-rolling
229
+ // the JSON here would drop `daemon`, which nothing on this path touches and
230
+ // which `daemonVersionSkew()` reads on every CLI command.
231
+ //
232
+ // This step's own `to`, spelled out for the same reason `resetHome` takes
233
+ // one: a step must never mark the home as a layout it did not reach. Here
234
+ // they happen to be equal, and writing the literal is what keeps them equal
235
+ // by intent rather than by coincidence when layout 5 arrives.
236
+ writeVersionFile({ layout: 4 });
237
+
238
+ return { removed: [], migrated, activity: [], policyConfig: [], spooled: [], from: 3 };
239
+ }
240
+
92
241
  /**
93
242
  * The steps that take `from` to {@link LAYOUT_VERSION}.
94
243
  *
@@ -256,6 +405,14 @@ const BACKED_UP_LEGACY: BackedUpFile[] = [
256
405
  // most incomplete exactly where it mattered most.
257
406
  { at: legacy.cloudCredentials },
258
407
  { at: legacy.ingestCredentials },
408
+ // The three files the layout-4 step MOVES. `auth.json` is the one that
409
+ // matters: it is a live bearer credential, and unlike every other entry here
410
+ // it was never on a delete list — so it has never had a copy taken before a
411
+ // migration touched it. A move is not a deletion, but a move with a bug in it
412
+ // is, and this is the only insurance against that.
413
+ { at: legacy.authJson },
414
+ { at: legacy.nextAudit },
415
+ { at: legacy.auditSchedule },
259
416
  ];
260
417
 
261
418
  /** The name a file is saved under inside `backup-layout<n>/`. */
@@ -263,6 +420,57 @@ function backupNameOf(f: BackedUpFile): string {
263
420
  return f.as ?? basename(f.at());
264
421
  }
265
422
 
423
+ /**
424
+ * Backup copies deleted once the whole chain has succeeded.
425
+ *
426
+ * The backup above is insurance against a migration that goes wrong. Insurance
427
+ * you keep forever on a live credential is not insurance, it is a second copy
428
+ * of the credential — and this one is worse than the original, because
429
+ * `migrationsDir` is classed `identity` in `HOME_CLASSES`, so no reset class
430
+ * ever removes it, and `deleteAuth()` only ever knew about the live path.
431
+ * Signing out of the dashboard, a 401 auto-delete and `failproofai reset` all
432
+ * left a working bearer and refresh token sitting at
433
+ * `migrations/backup-layout3/auth.json`, where every dotfile backup, container
434
+ * image, snapshot and handed-over machine would carry it. There is no CLI
435
+ * sign-out at all, so the headless boxes this feature targets had no supported
436
+ * way to remove it. A stale refresh token is also exactly the input
437
+ * `auth-store.ts` documents as triggering server-side replay revocation.
438
+ *
439
+ * Only entries whose source was MOVED are prunable, never ones that were
440
+ * DELETED: for `credentials.json` the backup is the only remaining copy, so
441
+ * removing it would be the data loss the backup exists to prevent. `landsAt` is
442
+ * checked rather than assumed, so a copy is dropped only once the file is
443
+ * provably readable at its new home.
444
+ */
445
+ const PRUNED_AFTER_SUCCESS: ReadonlyArray<{ as: string; landsAt: () => string }> = [
446
+ { as: basename(legacy.authJson()), landsAt: auditSessionFile },
447
+ ];
448
+
449
+ /**
450
+ * Remove the credential copies a completed chain no longer needs.
451
+ *
452
+ * Never throws: a chain that migrated correctly must not be reported as failed
453
+ * because a cleanup could not delete a file.
454
+ */
455
+ export function pruneMigratedCredentials(from: number): string[] {
456
+ const pruned: string[] = [];
457
+ for (let layout = from; layout < LAYOUT_VERSION; layout++) {
458
+ for (const { as, landsAt } of PRUNED_AFTER_SUCCESS) {
459
+ try {
460
+ if (!existsSync(landsAt())) continue;
461
+ const copy = resolve(migrationBackupDir(layout), as);
462
+ if (!existsSync(copy)) continue;
463
+ rmSync(copy, { force: true });
464
+ pruned.push(as);
465
+ } catch {
466
+ // Best effort. `deleteAuth()` sweeps these too, so a copy that survives
467
+ // here is still removed the next time somebody signs out.
468
+ }
469
+ }
470
+ }
471
+ return pruned;
472
+ }
473
+
266
474
  /**
267
475
  * The files that exist right now and would be backed up, each exactly once.
268
476
  *
@@ -385,6 +593,31 @@ export function runMigrations(
385
593
  });
386
594
  } catch (err) {
387
595
  steps.push({ from: step.from, to: step.to, ok: false });
596
+ // Undo an EARLIER step's over-stamp, if there was one.
597
+ //
598
+ // A step ends by stamping `VERSION`, and `writeVersionFile()` writes
599
+ // {@link LAYOUT_VERSION} rather than the step's own `to`. That was harmless
600
+ // while every chain was one hop and became a trap the moment one was two:
601
+ // on `2 → 3 → 4` the FIRST step stamps 4, so a `3 → 4` that then throws
602
+ // leaves a home marked CURRENT that was never migrated. `detectLayout()`
603
+ // reports `current`, no later command ever retries, and `auth.json` stays
604
+ // at the root while layout 4 reads `audit/session.json` — a machine
605
+ // silently signed out, with its session sitting on disk and nothing left
606
+ // that would ever move it.
607
+ //
608
+ // Only touched when the marker already claims the home is current, so a
609
+ // chain whose steps never got that far keeps whatever they left. `step.from`
610
+ // is where this one actually got to: every earlier step succeeded, and a
611
+ // failing step is documented not to roll back — which is exactly the state
612
+ // the next command should try to migrate again.
613
+ try {
614
+ const marker = readVersionFile();
615
+ if (marker && marker.layout >= LAYOUT_VERSION) writeVersionFile({ layout: step.from });
616
+ } catch {
617
+ // A marker we cannot rewrite leaves the home reading as whatever the last
618
+ // successful step claimed. Nothing further can be done about it here, and
619
+ // failing the run a second way would only hide the real error below.
620
+ }
388
621
  failed = {
389
622
  from: step.from,
390
623
  to: step.to,
@@ -402,6 +635,10 @@ export function runMigrations(
402
635
  }
403
636
  }
404
637
 
638
+ // Only on a clean chain. A run that failed still needs its copies: the whole
639
+ // point of the backup is the state this branch is in.
640
+ if (!failed) pruneMigratedCredentials(from);
641
+
405
642
  return { from, steps, backedUp, outcome, failed };
406
643
  }
407
644