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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +2 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +3 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +3 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -2
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js +3 -2
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -2
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +5 -3
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +3 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +5 -3
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +4 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +4 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +18 -0
- package/.next/standalone/.next/server/app/settings/page/next-font-manifest.json +6 -0
- package/.next/standalone/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +53 -0
- package/.next/standalone/.next/server/app/settings/page.js +18 -0
- package/.next/standalone/.next/server/app/settings/page.js.map +5 -0
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app-paths-manifest.json +2 -1
- package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[externals]__1nl3dvw._.js → [externals]__0r-zamh._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__00ni9za._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__0effm9m._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g0vcwt._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0ix8gm7._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ji11a-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__10oa-v9._.js → [root-of-the-server]__0ojwjxl._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__0pbp9fe._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +4 -4
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-wfhrp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06bp_fg._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__074qmkp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0_1tiya._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t281d4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1n2vcv4._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tlvv9a._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1ctse6u._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1sdgprl._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dwo94s.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/middleware.js +2 -2
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +74 -20
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 113g8wtal6_c4.js} +22 -22
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/18wqn0_3mzylf.js +1 -0
- package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 1xq1m8h14tnlj.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 1y3vwogqb88dx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 1zmh-b0ulrqu7.js} +1 -1
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/299lhw60e8gr6.js +1 -0
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 2adbpdaxp5ygq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 2kbqcw-6swmav.js} +1 -1
- package/.next/standalone/.next/static/chunks/2n9n5m0963pdg.js +6 -0
- package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 30nrrpwymht5m.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/SECURITY.md +8 -2
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/actions/get-hooks-config.ts +2 -2
- package/.next/standalone/app/actions/get-scheduled-audit.ts +70 -0
- package/.next/standalone/app/actions/update-scheduled-audit.ts +44 -0
- package/.next/standalone/app/api/audit/run/route.ts +28 -0
- package/.next/standalone/app/api/audit/status/route.ts +13 -1
- package/.next/standalone/app/api/auth/status/route.ts +1 -1
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -3
- package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +1 -1
- package/.next/standalone/app/audit/_components/auth-dialog.css +159 -0
- package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
- package/.next/standalone/app/audit/_components/come-back-better-section.tsx +1 -1
- package/.next/standalone/app/audit/_components/empty-state.tsx +0 -1
- package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +1 -1
- package/.next/standalone/app/audit/_components/quirks-section.tsx +0 -1
- package/.next/standalone/app/audit/_components/report-footer.tsx +0 -2
- package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
- package/.next/standalone/app/audit/_components/strengths-section.tsx +0 -1
- package/.next/standalone/app/audit/audit-styles.css +6 -149
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/components/raw-log-viewer.tsx +1 -1
- package/.next/standalone/app/components/toast.tsx +1 -1
- package/.next/standalone/app/policies/hooks-client.tsx +84 -8
- package/.next/standalone/app/settings/page.tsx +31 -0
- package/.next/standalone/app/settings/settings-client.tsx +487 -0
- package/.next/standalone/components/navbar.tsx +2 -0
- package/.next/standalone/lib/auth/api-server-client.ts +2 -3
- package/.next/standalone/lib/auth/auth-store.ts +4 -5
- package/.next/standalone/lib/codex-sessions.ts +2 -1
- package/.next/standalone/lib/dashboard-host.ts +101 -0
- package/.next/standalone/lib/install-check.ts +22 -7
- package/.next/standalone/lib/pi-sessions.ts +99 -14
- package/.next/standalone/lib/telemetry-enabled.ts +54 -0
- package/.next/standalone/lib/telemetry-id.ts +52 -1
- package/.next/standalone/lib/telemetry.ts +8 -4
- package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
- package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
- package/.next/standalone/node_modules/react/package.json +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
- package/.next/standalone/node_modules/react-dom/package.json +4 -4
- package/.next/standalone/package.json +23 -12
- package/.next/standalone/proxy.ts +111 -1
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai-worker.mjs +1 -1
- package/bin/failproofai.mjs +1041 -81
- package/bin/failproofaid-shim.mjs +4 -4
- package/dist/cli.mjs +18513 -10474
- package/dist/worker.mjs +1223 -299
- package/lib/auth/api-server-client.ts +2 -3
- package/lib/auth/auth-store.ts +4 -5
- package/lib/codex-sessions.ts +2 -1
- package/lib/dashboard-host.ts +101 -0
- package/lib/install-check.ts +22 -7
- package/lib/pi-sessions.ts +99 -14
- package/lib/telemetry-enabled.ts +54 -0
- package/lib/telemetry-id.ts +52 -1
- package/lib/telemetry.ts +8 -4
- package/package.json +23 -12
- package/scripts/build-daemon-packages.mjs +247 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/dev-hook.mjs +46 -5
- package/scripts/install-telemetry.mjs +8 -1
- package/scripts/launch.ts +25 -3
- package/scripts/parse-script-args.ts +27 -1
- package/scripts/prune-standalone.mjs +26 -0
- package/scripts/publish-aliases.mjs +62 -41
- package/scripts/repro-npm-install.sh +155 -0
- package/scripts/translate-docs/cache.ts +1 -1
- package/scripts/translate-docs/cli.ts +2 -2
- package/scripts/translate-docs/mintlify-nav.ts +1 -1
- package/scripts/translate-docs/readme-translator.ts +163 -4
- package/scripts/validate-mdx.ts +153 -13
- package/src/audit/audit-lock.ts +285 -0
- package/src/audit/audit-schedule.ts +111 -0
- package/src/audit/cache.ts +18 -5
- package/src/audit/cli-adapters/goose.ts +2 -1
- package/src/audit/cli-adapters/hermes.ts +28 -10
- package/src/audit/cli.ts +191 -35
- package/src/audit/dashboard-cache.ts +2 -3
- package/src/audit/report.ts +0 -1
- package/src/hooks/backfill-cli.ts +247 -0
- package/src/hooks/builtin-policies.ts +391 -3
- package/src/hooks/cloud-connection.ts +354 -0
- package/src/hooks/cloud-enrollment-cli.ts +439 -0
- package/src/hooks/cloud-enrollment.ts +284 -0
- package/src/hooks/cloud-introspect.ts +147 -0
- package/src/hooks/cloud-managed-policies.ts +206 -0
- package/src/hooks/collector-config.ts +218 -0
- package/src/hooks/configure-wizard.ts +1211 -233
- package/src/hooks/custom-hooks-loader.ts +243 -37
- package/src/hooks/daemon-client.ts +136 -33
- package/src/hooks/daemon-download.ts +264 -31
- package/src/hooks/daemon-service.ts +1124 -76
- package/src/hooks/first-run-gate.ts +50 -0
- package/src/hooks/flush-cli.ts +180 -0
- package/src/hooks/fp-config.ts +835 -0
- package/src/hooks/fp-home.ts +714 -0
- package/src/hooks/fp-reset.ts +1363 -0
- package/src/hooks/handler.ts +185 -6
- package/src/hooks/harness-cli.ts +386 -0
- package/src/hooks/hook-activity-store.ts +171 -24
- package/src/hooks/hook-logger.ts +2 -2
- package/src/hooks/hook-telemetry.ts +5 -1
- package/src/hooks/hooks-config.ts +4 -3
- package/src/hooks/integrations.ts +0 -1
- package/src/hooks/loader-utils.ts +172 -9
- package/src/hooks/manager.ts +41 -5
- package/src/hooks/migrations.ts +450 -0
- package/src/hooks/onboarding-attempt.ts +195 -0
- package/src/hooks/onboarding-lock.ts +118 -0
- package/src/hooks/policy-types.ts +28 -0
- package/src/hooks/session-pause-cli.ts +159 -0
- package/src/hooks/session-pause.ts +267 -0
- package/src/hooks/setup-state.ts +256 -0
- package/src/hooks/tui.ts +209 -9
- package/src/hooks/uninstall-cli.ts +418 -0
- package/src/hooks/worker-server.ts +134 -15
- package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0longh-._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0nb0syg._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1psjybo._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_1m_p7pi._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_0oy_-36.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +0 -5
- package/.next/standalone/.next/static/chunks/0h7auy7hzjyhw.js +0 -1
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
- package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- package/.next/standalone/Cargo.lock +0 -428
- package/.next/standalone/Cargo.toml +0 -9
- package/.next/standalone/crates/PROTOCOL.md +0 -122
- package/.next/standalone/crates/failproofaid/Cargo.toml +0 -18
- package/.next/standalone/crates/failproofaid/src/lock.rs +0 -81
- package/.next/standalone/crates/failproofaid/src/main.rs +0 -83
- package/.next/standalone/crates/failproofaid/src/paths.rs +0 -172
- package/.next/standalone/crates/failproofaid/src/server.rs +0 -502
- package/.next/standalone/crates/failproofaid/src/worker.rs +0 -400
- package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +0 -241
- package/.next/standalone/crates/fpai-ipc/Cargo.toml +0 -16
- package/.next/standalone/crates/fpai-ipc/src/envelope.rs +0 -175
- package/.next/standalone/crates/fpai-ipc/src/framing.rs +0 -177
- package/.next/standalone/crates/fpai-ipc/src/lib.rs +0 -10
- package/.next/standalone/crates/fpai-ipc/src/peer.rs +0 -80
- package/.next/standalone/docker-hook-sync/Dockerfile +0 -79
- package/.next/standalone/docker-hook-sync/entrypoint.sh +0 -109
- package/.next/standalone/integration-suite/Dockerfile +0 -47
- package/.next/standalone/integration-suite/README.md +0 -129
- package/.next/standalone/integration-suite/canary-policies.mjs +0 -45
- package/.next/standalone/integration-suite/capture-tokens.sh +0 -48
- package/.next/standalone/integration-suite/ci-entrypoint.sh +0 -179
- package/.next/standalone/integration-suite/inject-tokens.sh +0 -44
- package/.next/standalone/integration-suite/install-clis.sh +0 -136
- package/.next/standalone/integration-suite/probe-cli.sh +0 -248
- package/.next/standalone/integration-suite/report.js +0 -120
- package/.next/standalone/integration-suite/run.sh +0 -159
- package/.next/standalone/openclaw-plugin/index.js +0 -223
- package/.next/standalone/openclaw-plugin/openclaw.plugin.json +0 -6
- package/.next/standalone/openclaw-plugin/package.json +0 -12
- package/.next/standalone/osv-scanner.toml +0 -17
- package/.next/standalone/pi-extension/index.ts +0 -524
- package/.next/standalone/pi-extension/package.json +0 -12
- package/.next/standalone/postcss.config.mjs +0 -8
- package/.next/standalone/readme-arch-hq.gif +0 -0
- package/.next/standalone/rust-toolchain.toml +0 -4
- package/src/auth/cli.ts +0 -371
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What happens when this CLI meets a home directory written by a different
|
|
3
|
+
* layout.
|
|
4
|
+
*
|
|
5
|
+
* ## This was wipe-and-re-setup. It is a migration now.
|
|
6
|
+
*
|
|
7
|
+
* The original decision, and the reasoning for it, was: a migration has to be
|
|
8
|
+
* right on every path or it half-moves a home, and a half-moved home fails in the
|
|
9
|
+
* worst available way — the daemon writes where the dashboard does not read, and
|
|
10
|
+
* an absent directory is indistinguishable from an idle one. A reset is one
|
|
11
|
+
* destructive operation that is either done or not done.
|
|
12
|
+
*
|
|
13
|
+
* That was correct while there were no customers, no cloud tokens on real
|
|
14
|
+
* machines, no fleet enrolment and no undelivered event spools. All four now
|
|
15
|
+
* exist, and the cost landed on the other side of the ledger: a wipe deleted the
|
|
16
|
+
* cloud token (so the machine dropped off the fleet, silently, still reporting
|
|
17
|
+
* healthy), `daemon.configured` (so it stopped failing closed), every
|
|
18
|
+
* `extra_paths` a person had typed, and events already read out of transcripts
|
|
19
|
+
* and queued — the last of those PERMANENTLY, because `cursors/` survived and the
|
|
20
|
+
* watermark had already moved past them.
|
|
21
|
+
*
|
|
22
|
+
* So the shape inverted. `HOME_CLASSES` in `fp-home.ts` classifies every path by
|
|
23
|
+
* what it HOLDS and the delete list is derived from that, which means the default
|
|
24
|
+
* is now "carried" and deletion is the exception a class has to earn. The
|
|
25
|
+
* half-moved-home worry is answered by keeping the property that made a reset
|
|
26
|
+
* safe: `VERSION` is stamped only after a step completes, so a step that fails
|
|
27
|
+
* leaves the home marked with the OLD layout and the next command retries it. No
|
|
28
|
+
* home is ever marked current on the strength of a partial migration.
|
|
29
|
+
*
|
|
30
|
+
* `migrations.ts` owns the ORDER and the record — which steps exist, which ran,
|
|
31
|
+
* and what was copied aside first. This file owns the MOVES.
|
|
32
|
+
*
|
|
33
|
+
* ## Where the deletion happens, and where it deliberately does not
|
|
34
|
+
*
|
|
35
|
+
* Only a real CLI command resets. A HOOK never deletes anything, because a
|
|
36
|
+
* hook runs unattended, once per tool call, with an agent waiting on stdout —
|
|
37
|
+
* removing a user's audit history and cursors from inside one would be a
|
|
38
|
+
* destructive act nobody asked for and nobody saw.
|
|
39
|
+
*
|
|
40
|
+
* But a hook must not stay silent either. On a stale layout the merged config
|
|
41
|
+
* resolves to nothing, so every builtin policy quietly stops firing: the
|
|
42
|
+
* machine looks protected and is not. That is the single worst outcome here,
|
|
43
|
+
* so the hook path says so on stderr, every time, until setup is re-run.
|
|
44
|
+
*
|
|
45
|
+
* ## Why the hook does not simply deny
|
|
46
|
+
*
|
|
47
|
+
* Failing closed is this product's instinct, and it is the wrong instinct
|
|
48
|
+
* here. A stale layout is the result of an upgrade the user did not ask to be
|
|
49
|
+
* interrupted by, and this branch has already demonstrated — on this very
|
|
50
|
+
* machine — that a blanket deny takes `UserPromptSubmit` with it and locks the
|
|
51
|
+
* user out of their agent entirely, with no way back except hand-editing JSON.
|
|
52
|
+
* A loud warning that survives until setup runs is the proportionate answer.
|
|
53
|
+
*/
|
|
54
|
+
import {
|
|
55
|
+
copyFileSync,
|
|
56
|
+
existsSync,
|
|
57
|
+
mkdirSync,
|
|
58
|
+
readFileSync,
|
|
59
|
+
readdirSync,
|
|
60
|
+
renameSync,
|
|
61
|
+
rmSync,
|
|
62
|
+
rmdirSync,
|
|
63
|
+
statSync,
|
|
64
|
+
writeFileSync,
|
|
65
|
+
} from "node:fs";
|
|
66
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
67
|
+
import { rebuildHookActivityStats } from "./hook-activity-store";
|
|
68
|
+
import {
|
|
69
|
+
LAYOUT_VERSION,
|
|
70
|
+
customPoliciesDir,
|
|
71
|
+
failproofaiHome,
|
|
72
|
+
globalPolicyConfigFile,
|
|
73
|
+
hookActivityDir,
|
|
74
|
+
legacy,
|
|
75
|
+
migrationBackupDir,
|
|
76
|
+
policiesDir,
|
|
77
|
+
spoolDir,
|
|
78
|
+
failedDir,
|
|
79
|
+
resettablePaths,
|
|
80
|
+
} from "./fp-home";
|
|
81
|
+
import {
|
|
82
|
+
detectLayout,
|
|
83
|
+
projectConfig,
|
|
84
|
+
projectCredentials,
|
|
85
|
+
readConfig,
|
|
86
|
+
updateConfig,
|
|
87
|
+
writeConfig,
|
|
88
|
+
writeCredentials,
|
|
89
|
+
writeVersionFile,
|
|
90
|
+
type FpConfig,
|
|
91
|
+
type FpCredentials,
|
|
92
|
+
type LayoutState,
|
|
93
|
+
} from "./fp-config";
|
|
94
|
+
import {
|
|
95
|
+
daemonServiceStatus,
|
|
96
|
+
daemonStatusCommand,
|
|
97
|
+
daemonVersionSkew,
|
|
98
|
+
isDaemonSupportedPlatform,
|
|
99
|
+
probeDaemonEndToEnd,
|
|
100
|
+
} from "./daemon-service";
|
|
101
|
+
|
|
102
|
+
export interface ResetOutcome {
|
|
103
|
+
/** Paths that existed and were removed. */
|
|
104
|
+
removed: string[];
|
|
105
|
+
/** Basenames of user-authored policy files moved into `custom-policies/`. */
|
|
106
|
+
migrated: string[];
|
|
107
|
+
/** Basenames of decision-log pages carried into layout 2's `hook-activity/`. */
|
|
108
|
+
activity: string[];
|
|
109
|
+
/** Keys of the layout-1 policy config carried into layout 2's. */
|
|
110
|
+
policyConfig: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Undelivered event batches moved out of a legacy root `spool/`/`failed/`.
|
|
113
|
+
*
|
|
114
|
+
* Reported rather than discarded because these are events that had NOT been
|
|
115
|
+
* shipped: if the number is ever non-zero on a real machine it is the only
|
|
116
|
+
* evidence that a root spool existed at all, and silence would make a carry
|
|
117
|
+
* indistinguishable from a directory that was never there.
|
|
118
|
+
*/
|
|
119
|
+
spooled: string[];
|
|
120
|
+
/** The layout that was found before the reset. */
|
|
121
|
+
from: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Script files a person could have written by hand. `.d.ts` is a type stub. */
|
|
125
|
+
/**
|
|
126
|
+
* Move the user's own policy directory back up: layout 2's
|
|
127
|
+
* `policies/custom-policies/` into layout 3's `policies/`, where the loader
|
|
128
|
+
* reads it.
|
|
129
|
+
*
|
|
130
|
+
* Left alone, nothing would ever load those files again — and worse, the reset
|
|
131
|
+
* DELETES `custom-policies/` (it is in `resettablePaths()`), so they would not
|
|
132
|
+
* merely stop loading, they would be gone.
|
|
133
|
+
*
|
|
134
|
+
* EVERY entry moves — every file regardless of extension, and every
|
|
135
|
+
* subdirectory — not just the loadable sources. Moving only `*.{js,mjs,ts}`
|
|
136
|
+
* deleted the rest along with the directory, and the two things it deleted are
|
|
137
|
+
* exactly the things a real policy depends on: a `lib/` of shared helpers the
|
|
138
|
+
* policy imports, and the `.json` data file it reads its rules from. The
|
|
139
|
+
* surviving policy then referenced a `./lib/rules.mjs` that no longer existed,
|
|
140
|
+
* so the migration turned a working policy into a broken one AND destroyed
|
|
141
|
+
* source nothing regenerates. Reproduced exactly that way on a seeded home.
|
|
142
|
+
*
|
|
143
|
+
* Moving the whole directory is also what keeps relative imports valid: every
|
|
144
|
+
* entry keeps its position relative to every other, so `./lib/rules.mjs`
|
|
145
|
+
* resolves after the move for the same reason it did before.
|
|
146
|
+
*
|
|
147
|
+
* A destination that already exists is never overwritten. Two things make that
|
|
148
|
+
* safe rather than lossy, and BOTH are required:
|
|
149
|
+
*
|
|
150
|
+
* - DIRECTORIES MERGE. A colliding directory is not a conflict — `lib/` on both
|
|
151
|
+
* sides usually holds different files, and the likeliest collision of all is
|
|
152
|
+
* exactly `lib/`, because layout 1 → 2 left one behind (it moved only
|
|
153
|
+
* `*.{js,mjs,ts}`, which is the bug this function now fixes). Skipping the
|
|
154
|
+
* whole directory on a name match discarded every file inside it. Only a
|
|
155
|
+
* genuine leaf collision — the same name, and at least one side a file — is
|
|
156
|
+
* left alone.
|
|
157
|
+
* - WHAT CANNOT MOVE IS NOT DELETED. `custom-policies/` is deliberately NOT in
|
|
158
|
+
* `resettablePaths()`; this function removes it itself, and only once it is
|
|
159
|
+
* empty. It used to be on that list, so every entry deliberately "left in
|
|
160
|
+
* place" was deleted seconds later by the same `resetHome()` call that had
|
|
161
|
+
* just decided to preserve it — the user lost hand-written source, and the
|
|
162
|
+
* policy that DID move was left importing a `./lib/rules.mjs` that no longer
|
|
163
|
+
* existed. Verified on a seeded home: the helper was gone from the machine
|
|
164
|
+
* entirely and the surviving policy failed to load.
|
|
165
|
+
*/
|
|
166
|
+
export function migrateConventionPolicies(): string[] {
|
|
167
|
+
// LAYOUT 3 REVERSED THIS. Layout 2 moved the user's `*.mjs` DOWN into
|
|
168
|
+
// `policies/custom-policies/`; layout 3 reads them straight out of
|
|
169
|
+
// `policies/`, which is where layout 1 kept them all along. So:
|
|
170
|
+
//
|
|
171
|
+
// • from layout 1 — nothing to do. The files are already in the directory
|
|
172
|
+
// that now loads them, and moving them anywhere would be a regression.
|
|
173
|
+
// • from layout 2 — move them back UP, out of `custom-policies/`, or the
|
|
174
|
+
// reset deletes that directory (it is in `resettablePaths()`) and takes
|
|
175
|
+
// the user's own policies with it.
|
|
176
|
+
//
|
|
177
|
+
// `resetHome` calls this BEFORE it deletes anything, which is what makes the
|
|
178
|
+
// second case safe rather than a race.
|
|
179
|
+
const from = legacy.customPoliciesDir();
|
|
180
|
+
if (!existsSync(from)) return [];
|
|
181
|
+
const moved: string[] = [];
|
|
182
|
+
|
|
183
|
+
// Returns the names it moved, relative to `dir`. Recurses only where both
|
|
184
|
+
// sides are directories; everything else either moves whole or stays put.
|
|
185
|
+
const mergeInto = (dir: string, dest: string, prefix: string): void => {
|
|
186
|
+
let entries;
|
|
187
|
+
try {
|
|
188
|
+
// No filter: this whole directory is the user's — policy sources, the
|
|
189
|
+
// helpers they import, and the data files they read.
|
|
190
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
191
|
+
} catch {
|
|
192
|
+
// A directory we cannot read is not worth aborting a reset over, and its
|
|
193
|
+
// contents stay where they are rather than being lost.
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (entries.length === 0) return;
|
|
197
|
+
try {
|
|
198
|
+
mkdirSync(dest, { recursive: true });
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const source = resolve(dir, entry.name);
|
|
204
|
+
const target = resolve(dest, entry.name);
|
|
205
|
+
const name = `${prefix}${entry.name}`;
|
|
206
|
+
if (!existsSync(target)) {
|
|
207
|
+
try {
|
|
208
|
+
renameSync(source, target);
|
|
209
|
+
moved.push(name);
|
|
210
|
+
} catch {
|
|
211
|
+
// One unmovable entry must not strand the rest — and because this
|
|
212
|
+
// directory is no longer on the reset list, "not moved" now really
|
|
213
|
+
// does mean "still on disk".
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Both directories: merge their contents rather than discarding the
|
|
218
|
+
// source wholesale. Anything genuinely colliding inside is left alone by
|
|
219
|
+
// the same rule, one level down.
|
|
220
|
+
if (entry.isDirectory() && statSync(target).isDirectory()) {
|
|
221
|
+
mergeInto(source, target, `${name}/`);
|
|
222
|
+
// AND remove the child once the recursion drained it. Without this, a
|
|
223
|
+
// merged directory leaves an empty husk behind, so the `rmdirSync(from)`
|
|
224
|
+
// below throws ENOTEMPTY into a swallowing `catch` and
|
|
225
|
+
// `custom-policies/` survives the migration it just completed —
|
|
226
|
+
// permanently, because the next run recurses into the same empty child
|
|
227
|
+
// and fails the same way, so it never self-heals.
|
|
228
|
+
//
|
|
229
|
+
// Only reachable when the merge moved EVERYTHING: a genuine leaf
|
|
230
|
+
// collision inside leaves a file here, `rmdirSync` refuses, and the
|
|
231
|
+
// catch is then correct — that husk is the user's remaining source,
|
|
232
|
+
// which is the one thing this function must not delete.
|
|
233
|
+
try {
|
|
234
|
+
rmdirSync(source);
|
|
235
|
+
} catch {
|
|
236
|
+
// Not empty, so something was deliberately left. Keep it.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
mergeInto(from, policiesDir(), "");
|
|
243
|
+
|
|
244
|
+
// Remove the directory ONLY if the merge emptied it. A non-empty
|
|
245
|
+
// `custom-policies/` is the user's remaining files, and this is the one place
|
|
246
|
+
// that decides their fate — `resettablePaths()` no longer lists it.
|
|
247
|
+
try {
|
|
248
|
+
rmdirSync(from);
|
|
249
|
+
} catch {
|
|
250
|
+
// Not empty, or not removable. Either way it stays, which is the point.
|
|
251
|
+
}
|
|
252
|
+
return moved.sort((a, b) => a.localeCompare(b));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Carry layout 1's decision log into layout 2, WITHOUT re-shipping it.
|
|
257
|
+
*
|
|
258
|
+
* `cache/hook-activity` was deleted along with the rest of `cache/`, so an
|
|
259
|
+
* upgrade silently discarded every decision the machine had ever recorded —
|
|
260
|
+
* the data the dashboard's activity tab exists to show, and on a connected
|
|
261
|
+
* machine the data an operator had already been billed the collection of.
|
|
262
|
+
*
|
|
263
|
+
* # Why this MOVES rather than copies
|
|
264
|
+
*
|
|
265
|
+
* The collector keys its cursors on `(device, inode)` — deliberately, because
|
|
266
|
+
* the store rotates by RENAMING `current.jsonl`, and a path-keyed cursor would
|
|
267
|
+
* both re-ship the rotated file and carry its offset onto the fresh one.
|
|
268
|
+
* `rename()` preserves the inode, so a moved page is still the file its cursor
|
|
269
|
+
* belongs to and resumes at the right offset. A copy gives every page a NEW
|
|
270
|
+
* inode, which reads as "never seen before" and re-ships all of it.
|
|
271
|
+
*
|
|
272
|
+
* `head_fingerprint` is what makes that safe rather than lucky: the cursor
|
|
273
|
+
* verifies the file's first bytes whenever a resumed cursor's path has changed,
|
|
274
|
+
* which is exactly this situation. It was added for inode REUSE; a migration is
|
|
275
|
+
* the same question asked the other way round.
|
|
276
|
+
*
|
|
277
|
+
* The fallback for `EXDEV` (a `cache/` on a different filesystem — rare, since
|
|
278
|
+
* both live under one home, but possible with bind mounts) is a copy, accepting
|
|
279
|
+
* that those pages re-ship. Ingest dedups on a content hash and collapses them
|
|
280
|
+
* at merge, so the cost is bandwidth, not duplicate rows.
|
|
281
|
+
*
|
|
282
|
+
* # What is deliberately NOT carried
|
|
283
|
+
*
|
|
284
|
+
* `current.count` and `stats.json` are derived state, and two of each cannot be
|
|
285
|
+
* merged without inventing a number, so they are dropped — and then REBUILT here,
|
|
286
|
+
* explicitly, by `rebuildHookActivityStats()`.
|
|
287
|
+
*
|
|
288
|
+
* That call is the fix for a real loss. This comment used to say the store rebuilt
|
|
289
|
+
* them by itself; it did not. `stats.json` is incremental — one entry folded in per
|
|
290
|
+
* append, nothing ever rescans — so a dropped file simply read as zeroes and began
|
|
291
|
+
* accumulating again from the next event. A user upgrading from a pre-daemon home
|
|
292
|
+
* kept every record and lost every total: the dashboard listed their history while
|
|
293
|
+
* reporting 0 events, 0 denies and no top policy. Verified on a seeded home before
|
|
294
|
+
* and after. Dropping it is still right — the numbers are exactly recomputable
|
|
295
|
+
* because pages are never pruned — but only if something actually recomputes them. The legacy `current.jsonl` is moved under a PAGE name rather than onto
|
|
296
|
+
* the destination's own `current.jsonl`, which may already exist and may be
|
|
297
|
+
* mid-write — a rotated page is exactly what the store would have made of it.
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* Move layout 1's root `spool/` and `failed/` into the daemon's `state/` pair.
|
|
301
|
+
*
|
|
302
|
+
* Both root paths are on the retired list, so the migration DELETES them, and
|
|
303
|
+
* nothing regenerates an undelivered event: once the file is gone the decision it
|
|
304
|
+
* records was never reported and never will be. `HOME_CLASSES` classes the layout-3
|
|
305
|
+
* equivalents `undelivered` with the note "never deleted" — so without this the two
|
|
306
|
+
* halves of the same module contradicted each other and the delete won.
|
|
307
|
+
*
|
|
308
|
+
* It is insurance rather than a live path, and that is worth being precise about:
|
|
309
|
+
* no PUBLISHED version writes a root spool. `fpai-collect` used `home.join("spool")`
|
|
310
|
+
* only on the unmerged daemon branch; the commit that reached `main` already wrote
|
|
311
|
+
* `state/spool`, and the pre-daemon line (0.0.x) has no spool concept at all —
|
|
312
|
+
* checked against the published 0.0.10, 0.0.14, 0.0.15 and 1.0.0-beta.0 tarballs.
|
|
313
|
+
* So on every real machine this finds nothing and costs a single `existsSync`.
|
|
314
|
+
*
|
|
315
|
+
* It exists because "listed for deletion, with no carry and no backup" is a trap
|
|
316
|
+
* regardless of whether anything currently falls into it: the next thing to write a
|
|
317
|
+
* root spool would lose undelivered telemetry silently, and the cost of closing it
|
|
318
|
+
* now is one directory walk.
|
|
319
|
+
*
|
|
320
|
+
* Carried INTO the live spool rather than into the backup, so the events actually
|
|
321
|
+
* ship — `drainSpoolAfterMigrating()` flushes `state/spool` moments later. Safe for
|
|
322
|
+
* an unknown-format file because the uploader quarantines a batch it cannot send
|
|
323
|
+
* into `failed/` rather than failing on it, so the worst case is a preserved file
|
|
324
|
+
* in the place designed to hold preserved files.
|
|
325
|
+
*/
|
|
326
|
+
function migrateLegacySpool(): string[] {
|
|
327
|
+
const moved: string[] = [];
|
|
328
|
+
for (const [from, to] of [
|
|
329
|
+
[legacy.spoolDir(), spoolDir()],
|
|
330
|
+
[legacy.failedDir(), failedDir()],
|
|
331
|
+
] as const) {
|
|
332
|
+
if (!existsSync(from)) continue;
|
|
333
|
+
try {
|
|
334
|
+
mkdirSync(to, { recursive: true });
|
|
335
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
336
|
+
if (!entry.isFile()) continue;
|
|
337
|
+
// Never overwrite: a same-named batch in the destination is a different
|
|
338
|
+
// batch with different events, and losing either defeats the point.
|
|
339
|
+
let name = entry.name;
|
|
340
|
+
let n = 0;
|
|
341
|
+
while (existsSync(resolve(to, name))) name = `legacy-${n++}-${entry.name}`;
|
|
342
|
+
try {
|
|
343
|
+
renameSync(resolve(from, entry.name), resolve(to, name));
|
|
344
|
+
moved.push(name);
|
|
345
|
+
} catch {
|
|
346
|
+
try {
|
|
347
|
+
copyFileSync(resolve(from, entry.name), resolve(to, name));
|
|
348
|
+
moved.push(name);
|
|
349
|
+
} catch {
|
|
350
|
+
// Unreadable. Left where it is; the delete below may remove it, which
|
|
351
|
+
// is the pre-existing behaviour rather than a regression.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
// A spool directory we cannot read is not worth aborting a reset over.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return moved;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function migrateHookActivity(): string[] {
|
|
363
|
+
const from = legacy.hookActivityDir();
|
|
364
|
+
if (!existsSync(from)) return [];
|
|
365
|
+
const moved: string[] = [];
|
|
366
|
+
try {
|
|
367
|
+
const entries = readdirSync(from, { withFileTypes: true }).filter(
|
|
368
|
+
(e) => e.isFile() && e.name.endsWith(".jsonl"),
|
|
369
|
+
);
|
|
370
|
+
if (entries.length === 0) return [];
|
|
371
|
+
|
|
372
|
+
const to = hookActivityDir();
|
|
373
|
+
mkdirSync(to, { recursive: true });
|
|
374
|
+
|
|
375
|
+
// One timestamp for the whole migration, with a per-file counter, so the
|
|
376
|
+
// names are stable, ordered, and cannot collide with each other.
|
|
377
|
+
const stamp = Date.now();
|
|
378
|
+
let seq = 0;
|
|
379
|
+
|
|
380
|
+
for (const entry of entries) {
|
|
381
|
+
const source = resolve(from, entry.name);
|
|
382
|
+
// `current.jsonl` becomes a page: the destination has its own, and the
|
|
383
|
+
// store's reader treats pages and current identically.
|
|
384
|
+
let name = entry.name === "current.jsonl" ? `page-${stamp}-${seq++}.jsonl` : entry.name;
|
|
385
|
+
let target = resolve(to, name);
|
|
386
|
+
// Never overwrite. A same-named page in the destination is a different
|
|
387
|
+
// file with different records, and losing either is worse than a rename.
|
|
388
|
+
while (existsSync(target)) {
|
|
389
|
+
name = `page-${stamp}-${seq++}.jsonl`;
|
|
390
|
+
target = resolve(to, name);
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
renameSync(source, target);
|
|
394
|
+
moved.push(name);
|
|
395
|
+
} catch {
|
|
396
|
+
// Fall back to a copy on ANY rename failure, not just EXDEV.
|
|
397
|
+
//
|
|
398
|
+
// EXDEV (a rename across filesystems) was the only code handled, on the
|
|
399
|
+
// reasoning that it is the only one a copy can rescue. It is not: a
|
|
400
|
+
// rename needs write permission on the SOURCE DIRECTORY, while a copy
|
|
401
|
+
// needs only read on the file and write on the destination — so EACCES,
|
|
402
|
+
// EPERM and EROFS on `cache/` all fail the rename and all succeed as a
|
|
403
|
+
// copy. Those were silently dropped from the carry with no attempt made.
|
|
404
|
+
//
|
|
405
|
+
// Getting this wrong is permanent rather than deferred. The comment here
|
|
406
|
+
// used to say a page left behind is merely "still there", which is true
|
|
407
|
+
// of the file and false of its fate: `resetHome` stamps VERSION at the
|
|
408
|
+
// end regardless, `detectLayout` then reports `current`, and this
|
|
409
|
+
// function never runs again — so the page is not left for a retry, it is
|
|
410
|
+
// abandoned in the old layout where nothing reads it.
|
|
411
|
+
//
|
|
412
|
+
// The copy leaves the original in place, which is the right trade in the
|
|
413
|
+
// one direction that matters: the store's reader is keyed on the pages it
|
|
414
|
+
// finds under the CURRENT layout, so a duplicate there would double-count
|
|
415
|
+
// and a duplicate left behind is inert.
|
|
416
|
+
try {
|
|
417
|
+
copyFileSync(source, target);
|
|
418
|
+
moved.push(name);
|
|
419
|
+
} catch {
|
|
420
|
+
// Genuinely unreadable. Reported by omission from `activity`, which is
|
|
421
|
+
// the signal `resetHome`'s caller prints.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
// An activity directory we cannot read is not worth aborting a reset over.
|
|
427
|
+
}
|
|
428
|
+
// AFTER the pages are in place, so the rebuild sees the carried history rather
|
|
429
|
+
// than only whatever the new layout already had. Best-effort: a machine whose
|
|
430
|
+
// totals cannot be rewritten still keeps every record, which is the half that
|
|
431
|
+
// matters, and the next append starts accumulating from whatever it managed.
|
|
432
|
+
if (moved.length > 0) {
|
|
433
|
+
try {
|
|
434
|
+
rebuildHookActivityStats();
|
|
435
|
+
} catch {
|
|
436
|
+
// Never fail a migration over a derived number.
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return moved.sort((a, b) => a.localeCompare(b));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The eight keys the layout-2 carry used to move, kept only as a record.
|
|
444
|
+
*
|
|
445
|
+
* It was an ALLOWLIST, and that was the bug: anything outside these eight names
|
|
446
|
+
* was dropped, including a key a NEWER build had written into a layout-2 file.
|
|
447
|
+
* The carry preserves every key now and deletes only the retired ones
|
|
448
|
+
* (`RETIRED_POLICY_CONFIG_KEYS`), which is the same rule `writeConfig` follows for
|
|
449
|
+
* `config.json` — unowned keys survive, dead ones go.
|
|
450
|
+
*
|
|
451
|
+
* The one exclusion this list existed for is still enforced, by that other
|
|
452
|
+
* constant: layout 1's file also carried a `collector` block, and layout 2 moved
|
|
453
|
+
* collector settings to `config.toml` in snake_case — deliberately, because
|
|
454
|
+
* `fpai-collect`'s `Settings` deserializes them and camelCase keys would make
|
|
455
|
+
* every field silently fall back to its default (see the note on `Settings` in
|
|
456
|
+
* `crates/fpai-collect/src/config.rs`, which records that exact bug). Carrying
|
|
457
|
+
* `collector` forward would put a block into the new file that nothing reads,
|
|
458
|
+
* looking like a preserved setting and behaving like an absent one.
|
|
459
|
+
*/
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Carry the user's policy SELECTION across the layout-1 → layout-2 move.
|
|
463
|
+
*
|
|
464
|
+
* Layout 1 kept it at `~/.failproofai/policies-config.json`; layout 2 keeps it
|
|
465
|
+
* at `policies/local-policies/policies-config.json`, and both were on the reset
|
|
466
|
+
* list. So an upgrade silently emptied `enabledPolicies` — every builtin the
|
|
467
|
+
* user had turned on, every explicit `customPoliciesPaths` entry, and every
|
|
468
|
+
* per-policy parameter. The machine still read as configured afterwards
|
|
469
|
+
* (`isConfigured()` is a union that sees the agent CLIs' untouched settings
|
|
470
|
+
* files), so hooks kept firing against a policy set that had quietly become
|
|
471
|
+
* the default one. That is the same silent enforcement gap
|
|
472
|
+
* `migrateConventionPolicies()` exists to close, by a different route.
|
|
473
|
+
*
|
|
474
|
+
* This is deliberately NARROW. The standing decision for layout 1 is
|
|
475
|
+
* wipe-and-re-setup rather than migrate, because a half-migrated home that
|
|
476
|
+
* reads "no data" is worse than one that says so — see `legacy` in
|
|
477
|
+
* `fp-home.ts`. Everything derived (cursors, spool, health, audit cache) still
|
|
478
|
+
* goes and is rebuilt. What is carried is only what a person typed and nothing
|
|
479
|
+
* regenerates, which is the same test `migrateHookActivity()` applies to the
|
|
480
|
+
* decision log.
|
|
481
|
+
*
|
|
482
|
+
* # Two phases, because the source and the destination are BOTH on the list
|
|
483
|
+
*
|
|
484
|
+
* Unlike the two migrations above, this one cannot run entirely before the
|
|
485
|
+
* deletions. Its source (`legacy.policyConfig()`) is removed by them, and so is
|
|
486
|
+
* its destination (`globalPolicyConfigFile()`) — rightly, since clearing a
|
|
487
|
+
* stale selection on a layout migration is the documented behaviour. Writing
|
|
488
|
+
* first would have the reset delete the carry moments after it happened, which
|
|
489
|
+
* is exactly what the note on `hookActivityDir()` in `resettablePaths()` records
|
|
490
|
+
* happening once already.
|
|
491
|
+
*
|
|
492
|
+
* So: READ before, WRITE after.
|
|
493
|
+
*/
|
|
494
|
+
/**
|
|
495
|
+
* Keys the policy config used to hold that nothing reads any more.
|
|
496
|
+
*
|
|
497
|
+
* `collector` is the whole list, and it is why the layout-1 carry was an
|
|
498
|
+
* ALLOWLIST rather than a copy: layout 1 kept collector settings here in
|
|
499
|
+
* camelCase, and layout 2 moved them to `config.toml`/`config.json` in
|
|
500
|
+
* snake_case, where `fpai-collect`'s `Settings` deserializes them. A camelCase
|
|
501
|
+
* `collector` block sitting in the layout-3 file reads as a preserved setting and
|
|
502
|
+
* does nothing — the worst of both, because it looks answered.
|
|
503
|
+
*
|
|
504
|
+
* A NAMED list, not "everything outside the keep-list". The file survives the
|
|
505
|
+
* reset now (`HOME_CLASSES` classes it `user-typed`), so dropping by exclusion
|
|
506
|
+
* would delete every key a NEWER build had written — which is the bug Phase 1
|
|
507
|
+
* fixed for `config.json`, arriving here by a different door.
|
|
508
|
+
*/
|
|
509
|
+
const RETIRED_POLICY_CONFIG_KEYS = ["collector"] as const;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Remove the retired keys from the policy config, in place.
|
|
513
|
+
*
|
|
514
|
+
* Runs regardless of which layout we came from: a key is retired or it is not,
|
|
515
|
+
* and a layout-2 home carrying a stray layout-1 root file should be cleaned the
|
|
516
|
+
* same way. Returns what it removed.
|
|
517
|
+
*
|
|
518
|
+
* The file is DELETED if stripping empties it — a `{}` policy config is not a
|
|
519
|
+
* selection, and the old behaviour for a file holding nothing but `collector`
|
|
520
|
+
* was no file at all.
|
|
521
|
+
*/
|
|
522
|
+
export function retirePolicyConfigKeys(): string[] {
|
|
523
|
+
const at = globalPolicyConfigFile();
|
|
524
|
+
let parsed: Record<string, unknown>;
|
|
525
|
+
try {
|
|
526
|
+
const raw = JSON.parse(readFileSync(at, "utf8")) as unknown;
|
|
527
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
|
528
|
+
parsed = raw as Record<string, unknown>;
|
|
529
|
+
} catch {
|
|
530
|
+
// Absent or unparseable. Not worth aborting a reset over, and there is
|
|
531
|
+
// nothing to retire — the same answer the carry gives for the same input.
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
const removed = RETIRED_POLICY_CONFIG_KEYS.filter((k) => parsed[k] !== undefined);
|
|
535
|
+
if (removed.length === 0) return [];
|
|
536
|
+
for (const key of removed) delete parsed[key];
|
|
537
|
+
try {
|
|
538
|
+
if (Object.keys(parsed).length === 0) rmSync(at, { force: true });
|
|
539
|
+
else writeFileSync(at, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
540
|
+
} catch {
|
|
541
|
+
// A file we cannot rewrite keeps its dead key. Misleading, but not fatal,
|
|
542
|
+
// and not worth failing the migration over.
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
return [...removed];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function readCarriedPolicyConfig(): Record<string, unknown> | null {
|
|
549
|
+
// LAYOUT 2's NESTED COPY ONLY — `policies/local-policies/policies-config.json`.
|
|
550
|
+
//
|
|
551
|
+
// This used to prefer that path and fall back to `legacy.policyConfig()`, the
|
|
552
|
+
// layout-1 file at the home root. That fallback is gone because the file is:
|
|
553
|
+
// layout 3 puts the live config back at exactly that path, `HOME_CLASSES`
|
|
554
|
+
// classes it `user-typed`, and it is no longer deleted — so a layout-1 home
|
|
555
|
+
// keeps it untouched, with EVERY key rather than the eight this function knows
|
|
556
|
+
// to carry. Reading it here and writing it back would be the only thing that
|
|
557
|
+
// could still narrow it.
|
|
558
|
+
//
|
|
559
|
+
// Layout 2's copy is different: `legacy.localPoliciesDir()` is on the retired
|
|
560
|
+
// list and really is deleted, so without this it is lost. A home cannot hold
|
|
561
|
+
// both — layout 2 never wrote the root file, and going 1 → 2 removed it.
|
|
562
|
+
const from = resolve(legacy.localPoliciesDir(), "policies-config.json");
|
|
563
|
+
if (!existsSync(from)) return null;
|
|
564
|
+
|
|
565
|
+
let parsed: Record<string, unknown>;
|
|
566
|
+
try {
|
|
567
|
+
parsed = JSON.parse(readFileSync(from, "utf8")) as Record<string, unknown>;
|
|
568
|
+
} catch {
|
|
569
|
+
// Unparseable is not worth aborting a reset over, and there is nothing to
|
|
570
|
+
// carry. The file is removed with the rest of layout 1, which is also what
|
|
571
|
+
// would have happened before this function existed.
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
575
|
+
|
|
576
|
+
// EVERY key except the retired ones. This was an eight-name ALLOWLIST, and it
|
|
577
|
+
// dropped anything outside it — including a key a NEWER build had written into a
|
|
578
|
+
// layout-2 file, which is the same loss Phase 1 fixed for `config.json` arriving
|
|
579
|
+
// by a third door. Caught by a smoke test on a seeded home: a `futureKey` in the
|
|
580
|
+
// nested config was gone after the migration, silently.
|
|
581
|
+
//
|
|
582
|
+
// Retired keys still go, because they are dead rather than unknown — see
|
|
583
|
+
// `RETIRED_POLICY_CONFIG_KEYS` for why leaving `collector` here is worse than
|
|
584
|
+
// deleting it.
|
|
585
|
+
const carried: Record<string, unknown> = {};
|
|
586
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
587
|
+
if ((RETIRED_POLICY_CONFIG_KEYS as readonly string[]).includes(key)) continue;
|
|
588
|
+
carried[key] = value;
|
|
589
|
+
}
|
|
590
|
+
// Nothing worth carrying — a file holding only retired fields must not produce
|
|
591
|
+
// an empty config that looks like a real one.
|
|
592
|
+
if (Object.keys(carried).length === 0) return null;
|
|
593
|
+
|
|
594
|
+
// `enabledPolicies` is required by the type and by every reader. A layout-1
|
|
595
|
+
// file that somehow lacked it would otherwise produce a layout-2 file that
|
|
596
|
+
// throws on read — worse than the empty default it replaces.
|
|
597
|
+
if (carried.enabledPolicies === undefined) carried.enabledPolicies = [];
|
|
598
|
+
rewriteCarriedCustomPaths(carried);
|
|
599
|
+
return carried;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Repoint `customPoliciesPaths` at where the files now live, in place.
|
|
604
|
+
*
|
|
605
|
+
* `migrateConventionPolicies()` moves layout 2's `policies/custom-policies/*` up
|
|
606
|
+
* into `policies/`, and nothing rewrote the paths the user had REGISTERED — so
|
|
607
|
+
* every explicit entry still named the directory the migration had just deleted.
|
|
608
|
+
* Reproduced on a seeded layout-2 home: the file was correctly at
|
|
609
|
+
* `policies/acme.mjs`, and the config still said
|
|
610
|
+
* `policies/custom-policies/acme.mjs`, which resolved to nothing.
|
|
611
|
+
*
|
|
612
|
+
* Layout 3 collapses `customPoliciesDir()` onto `policiesDir()`, so a moved file
|
|
613
|
+
* is still discovered BY CONVENTION and usually keeps firing — which is exactly
|
|
614
|
+
* what made this quiet. It is not harmless: a convention-loaded policy gets a
|
|
615
|
+
* different id from an explicitly-pathed one, and `disabledCustomPolicies` records
|
|
616
|
+
* a disable against that id, so a policy the user had switched off can come back.
|
|
617
|
+
* Repointing the path keeps the id it had.
|
|
618
|
+
*
|
|
619
|
+
* Layout-2 shaped on purpose. Layout 1 kept these files in `policies/` already —
|
|
620
|
+
* the same place layout 3 does — so a layout-1 path needs no rewrite, and its
|
|
621
|
+
* config is not carried through here anyway.
|
|
622
|
+
*/
|
|
623
|
+
function rewriteCarriedCustomPaths(carried: Record<string, unknown>): void {
|
|
624
|
+
const fromDir = legacy.customPoliciesDir();
|
|
625
|
+
const toDir = policiesDir();
|
|
626
|
+
const repoint = (value: unknown): unknown => {
|
|
627
|
+
if (typeof value !== "string" || value === "") return value;
|
|
628
|
+
const abs = resolve(value);
|
|
629
|
+
// `relative()` rather than a prefix test on the string: `policies/custom-policies-old`
|
|
630
|
+
// starts with `policies/custom-policies` and is a DIFFERENT directory the
|
|
631
|
+
// migration never touched, so a prefix match would move a path that is still
|
|
632
|
+
// correct. An entry outside the moved tree is returned untouched.
|
|
633
|
+
const rel = relative(fromDir, abs);
|
|
634
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return value;
|
|
635
|
+
return resolve(toDir, rel);
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
if (Array.isArray(carried.customPoliciesPaths)) {
|
|
639
|
+
carried.customPoliciesPaths = carried.customPoliciesPaths.map(repoint);
|
|
640
|
+
}
|
|
641
|
+
// The legacy singular field, still accepted on read.
|
|
642
|
+
if (typeof carried.customPoliciesPath === "string") {
|
|
643
|
+
carried.customPoliciesPath = repoint(carried.customPoliciesPath);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Second phase of {@link readCarriedPolicyConfig}; runs AFTER the deletions.
|
|
649
|
+
*
|
|
650
|
+
* MERGES onto whatever is at the destination rather than replacing it. The
|
|
651
|
+
* destination is `user-typed` now and survives the reset, so a plain write would
|
|
652
|
+
* truncate a live file down to the keys this carry happens to know about — the
|
|
653
|
+
* same class of loss the carry exists to prevent, arriving by the other door.
|
|
654
|
+
* The carried values still win: they are the ones being migrated.
|
|
655
|
+
*/
|
|
656
|
+
export function writeCarriedPolicyConfig(carried: Record<string, unknown> | null): string[] {
|
|
657
|
+
if (!carried) return [];
|
|
658
|
+
const to = globalPolicyConfigFile();
|
|
659
|
+
let existing: Record<string, unknown> = {};
|
|
660
|
+
try {
|
|
661
|
+
const parsed = JSON.parse(readFileSync(to, "utf8")) as unknown;
|
|
662
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
663
|
+
existing = parsed as Record<string, unknown>;
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
// Absent or unparseable — the carry is the whole file, as before.
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
670
|
+
writeFileSync(to, JSON.stringify({ ...existing, ...carried }, null, 2) + "\n", "utf8");
|
|
671
|
+
} catch {
|
|
672
|
+
// A destination we cannot write is not worth aborting the reset over; the
|
|
673
|
+
// user re-runs setup, which is the pre-existing behaviour.
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
return Object.keys(carried).sort((a, b) => a.localeCompare(b));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Delete every path layout 1 or layout 2 could have written, then stamp
|
|
681
|
+
* VERSION so the next run reads as current.
|
|
682
|
+
*
|
|
683
|
+
* Enumerated rather than "remove the home directory": a reset must never take
|
|
684
|
+
* out something a future layout adds that this list has not been taught about,
|
|
685
|
+
* and `bin/` and `run/` are excluded on purpose — a downloaded daemon binary is
|
|
686
|
+
* large, version-pinned and re-verified on use, and `run/` holds sockets
|
|
687
|
+
* belonging to a process that may be alive right now.
|
|
688
|
+
*/
|
|
689
|
+
/**
|
|
690
|
+
* Carry a telemetry OPT-OUT across a layout-2 upgrade.
|
|
691
|
+
*
|
|
692
|
+
* Layout 2 kept it in `config.toml`, which the reset deletes, and layout 3
|
|
693
|
+
* writes a fresh `config.json` whose default is `telemetry.enabled: true`. So an
|
|
694
|
+
* upgrade silently turned anonymous telemetry back ON for anyone who had turned
|
|
695
|
+
* it off — and that file is the ONLY off-switch that reaches the daemon, because
|
|
696
|
+
* a system-scope service unit does not inherit `FAILPROOFAI_TELEMETRY_DISABLED`
|
|
697
|
+
* from anyone's shell. An opt-out that a routine upgrade revokes is not an
|
|
698
|
+
* opt-out.
|
|
699
|
+
*
|
|
700
|
+
* Deliberately one-directional: only `false` is carried. Everything else in that
|
|
701
|
+
* file is either re-derived by setup or a thing the wizard re-asks, and carrying
|
|
702
|
+
* an ENABLED flag forward would be carrying the default, which is not a choice
|
|
703
|
+
* anyone made. This matches the narrow rule the policy-config carry follows —
|
|
704
|
+
* move only what a person typed and nothing regenerates.
|
|
705
|
+
*
|
|
706
|
+
* A regex rather than a TOML parser, for the reason `readLegacyTomlVersion`
|
|
707
|
+
* gives: the dependency is gone, and this is a flat `key = value` file this
|
|
708
|
+
* codebase wrote. Read BEFORE the deletions, applied after — same two-phase
|
|
709
|
+
* shape, same reason.
|
|
710
|
+
*/
|
|
711
|
+
export function readCarriedTelemetryOptOut(): boolean {
|
|
712
|
+
const from = legacy.configToml();
|
|
713
|
+
if (!existsSync(from)) return false;
|
|
714
|
+
try {
|
|
715
|
+
const text = readFileSync(from, "utf8");
|
|
716
|
+
// Scoped to the `[telemetry]` table, not the whole file — a stray
|
|
717
|
+
// `enabled = false` under `[collector]` is not a telemetry opt-out.
|
|
718
|
+
//
|
|
719
|
+
// Sliced rather than matched with a single expression: the obvious form ends
|
|
720
|
+
// the section with `(?=^\s*\[|\Z)`, and JavaScript HAS NO `\Z`. It parses as
|
|
721
|
+
// a literal "Z", so the lookahead only ever succeeded via its other branch —
|
|
722
|
+
// meaning the section was found when another table followed it and MISSED
|
|
723
|
+
// whenever `[telemetry]` was last in the file, which is exactly where a
|
|
724
|
+
// hand-added opt-out tends to be. Caught by the test for this function; the
|
|
725
|
+
// live fixture happened to have `[collector]` after it and passed.
|
|
726
|
+
const start = text.search(/^\s*\[telemetry\]\s*$/m);
|
|
727
|
+
if (start === -1) return false;
|
|
728
|
+
const body = text.slice(start).replace(/^\s*\[telemetry\]\s*$/m, "");
|
|
729
|
+
const nextTable = body.search(/^\s*\[/m);
|
|
730
|
+
const section = nextTable === -1 ? body : body.slice(0, nextTable);
|
|
731
|
+
return /^\s*enabled\s*=\s*false\s*$/m.test(section);
|
|
732
|
+
} catch {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Parse the TOML subset layout 2 actually wrote, into the object shape layout 3
|
|
739
|
+
* parses out of JSON.
|
|
740
|
+
*
|
|
741
|
+
* NOT a TOML implementation, and it does not need to be: layout 2's `config.toml`
|
|
742
|
+
* and `credentials.toml` were written by this codebase, by two functions that
|
|
743
|
+
* emitted nothing but `[table]` / `[dotted.table]` headers and `key = <value>`
|
|
744
|
+
* lines where every value went through `JSON.stringify`. So `JSON.parse` on the
|
|
745
|
+
* right-hand side is EXACT rather than approximate — strings, booleans, numbers
|
|
746
|
+
* and the one array (`extra_paths`) all round-trip by construction. Removing the
|
|
747
|
+
* `toml` dependency was half the point of layout 3; adding it back to read two
|
|
748
|
+
* files we wrote ourselves would undo that.
|
|
749
|
+
*
|
|
750
|
+
* The output uses layout 2's own snake_case key names, unchanged, because layout
|
|
751
|
+
* 3's JSON uses exactly the same ones (`hooks_verbosity`, `machine_id`,
|
|
752
|
+
* `interval_days`, `sources.<h>.extra_paths`). That is what lets the carry run
|
|
753
|
+
* `projectConfig` / `projectCredentials` — the SAME projections the JSON readers
|
|
754
|
+
* use — instead of a second reader that would have to be kept in step. The only
|
|
755
|
+
* thing that differs between the two layouts is how bytes become an object.
|
|
756
|
+
*
|
|
757
|
+
* A dotted header nests: `[collector.sources.claude]` lands at
|
|
758
|
+
* `collector.sources.claude`, which is where `readSources` looks for it.
|
|
759
|
+
* Anything unparseable is skipped rather than throwing, because a single
|
|
760
|
+
* malformed line must not cost the user the whole file.
|
|
761
|
+
*/
|
|
762
|
+
export function parseLegacyToml(text: string): Record<string, unknown> {
|
|
763
|
+
const root: Record<string, unknown> = {};
|
|
764
|
+
let table = root;
|
|
765
|
+
for (const raw of text.split("\n")) {
|
|
766
|
+
const line = raw.trim();
|
|
767
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
768
|
+
const header = /^\[([^\]]+)\]$/.exec(line);
|
|
769
|
+
if (header) {
|
|
770
|
+
table = root;
|
|
771
|
+
for (const part of header[1].split(".")) {
|
|
772
|
+
const key = part.trim();
|
|
773
|
+
if (!key) break;
|
|
774
|
+
const existing = table[key];
|
|
775
|
+
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
776
|
+
table = existing as Record<string, unknown>;
|
|
777
|
+
} else {
|
|
778
|
+
const created: Record<string, unknown> = {};
|
|
779
|
+
table[key] = created;
|
|
780
|
+
table = created;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const eq = line.indexOf("=");
|
|
786
|
+
if (eq === -1) continue;
|
|
787
|
+
const key = line.slice(0, eq).trim();
|
|
788
|
+
if (!key) continue;
|
|
789
|
+
try {
|
|
790
|
+
table[key] = JSON.parse(line.slice(eq + 1).trim()) as unknown;
|
|
791
|
+
} catch {
|
|
792
|
+
// A value this codebase did not write, or a hand-edit that broke it. Skip
|
|
793
|
+
// the line; the projection then falls back to that field's default, which
|
|
794
|
+
// is the same answer an absent key gets.
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return root;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Carry the whole layout-2 `config.toml` across, not just the telemetry opt-out.
|
|
802
|
+
*
|
|
803
|
+
* `readCarriedTelemetryOptOut()` above rescued one field, and the reason it was
|
|
804
|
+
* only one was that the rest was described as "re-derived by setup or a thing the
|
|
805
|
+
* wizard re-asks". That is true of a machine whose owner is about to re-run
|
|
806
|
+
* setup, and false of every other machine — `config.toml` also holds:
|
|
807
|
+
*
|
|
808
|
+
* mode a machine reverting to `oss` stops reporting entirely
|
|
809
|
+
* daemon.configured the flag that makes the machine FAIL CLOSED
|
|
810
|
+
* collector.* sessions/hooks/verbosity/redact/environment/machine_id
|
|
811
|
+
* collector.sources.* every extra_paths a person typed
|
|
812
|
+
* audit.auto/interval the scheduled scan they switched on
|
|
813
|
+
*
|
|
814
|
+
* Losing `daemon.configured` silently downgrades a machine from fail-closed
|
|
815
|
+
* enforcement to the in-process path, and losing `mode` disconnects it from the
|
|
816
|
+
* fleet — neither with any message, and neither re-derivable without a human.
|
|
817
|
+
* Those are the same failures `HOME_CLASSES` stopped for `config.json`; this is
|
|
818
|
+
* the layout-2 leg of the same problem, in the other format.
|
|
819
|
+
*
|
|
820
|
+
* Read BEFORE the deletions (the source is on the retired list), applied after.
|
|
821
|
+
*/
|
|
822
|
+
export function readCarriedLegacyConfig(): FpConfig | null {
|
|
823
|
+
const from = legacy.configToml();
|
|
824
|
+
if (!existsSync(from)) return null;
|
|
825
|
+
try {
|
|
826
|
+
return projectConfig(parseLegacyToml(readFileSync(from, "utf8")));
|
|
827
|
+
} catch {
|
|
828
|
+
// Unreadable is not worth aborting a reset over, and the telemetry carry
|
|
829
|
+
// below still gets its own chance at the same bytes.
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Carry the layout-2 `credentials.toml` across.
|
|
836
|
+
*
|
|
837
|
+
* This is the one that takes a machine off the fleet. `credentials.toml` is on
|
|
838
|
+
* the retired list and NOTHING carried it, so a layout-2 → 3 upgrade deleted the
|
|
839
|
+
* cloud token and the ingest key outright: the machine stops reconciling
|
|
840
|
+
* cloud-managed policy, stops delivering anything it spools, and says nothing —
|
|
841
|
+
* it keeps enforcing whatever it last had and keeps reporting healthy. On a fleet
|
|
842
|
+
* that is every box going quiet at once, with no operator action that caused it.
|
|
843
|
+
*
|
|
844
|
+
* `HOME_CLASSES` classes `credentials.json` `user-typed` so this cannot happen
|
|
845
|
+
* again from layout 3 onwards, but 2 → 3 is the upgrade that actually exists to
|
|
846
|
+
* be run, and it needed this.
|
|
847
|
+
*
|
|
848
|
+
* Written through `writeCredentials`, so the file lands 0600 with the home
|
|
849
|
+
* tightened to 0700 — a token must not arrive here by a path that skips that.
|
|
850
|
+
*/
|
|
851
|
+
export function readCarriedLegacyCredentials(): FpCredentials | null {
|
|
852
|
+
// NEWEST SOURCE WINS, and both older layouts have to be handled here because
|
|
853
|
+
// BOTH of their credential files are on the retired list with nothing else
|
|
854
|
+
// carrying them:
|
|
855
|
+
//
|
|
856
|
+
// layout 2 — `credentials.toml`, one file, TOML
|
|
857
|
+
// layout 1 — `cloud.json` + `ingest.json`, two files, JSON, camelCase
|
|
858
|
+
//
|
|
859
|
+
// Layout 1 is the one that matters most in practice: the published `latest`
|
|
860
|
+
// npm tag is still a pre-daemon 0.0.x release, so "install the current stable,
|
|
861
|
+
// then upgrade" is a LAYOUT-1 → 3 migration, which makes this the upgrade real
|
|
862
|
+
// users actually perform. Losing the token there takes the machine off the
|
|
863
|
+
// fleet exactly as the layout-2 case does — silently, still enforcing whatever
|
|
864
|
+
// it last had, still reporting healthy.
|
|
865
|
+
const toml = legacy.credentialsToml();
|
|
866
|
+
if (existsSync(toml)) {
|
|
867
|
+
try {
|
|
868
|
+
const creds = projectCredentials(parseLegacyToml(readFileSync(toml, "utf8")));
|
|
869
|
+
// An empty object means the file held nothing that passed validation — a
|
|
870
|
+
// cloud table with no token, say. Writing that would create a credentials
|
|
871
|
+
// file that looks present and authenticates nothing.
|
|
872
|
+
if (Object.keys(creds).length > 0) return creds;
|
|
873
|
+
} catch {
|
|
874
|
+
// Fall through to layout 1's files rather than returning: an unreadable
|
|
875
|
+
// layout-2 file is not evidence that a layout-1 one is absent.
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Layout 1. `machineId` is CAMELCASE in `cloud.json` — layout 2 moved to
|
|
880
|
+
// snake_case — so the raw object is rebuilt in the shape `projectCredentials`
|
|
881
|
+
// reads rather than passed through. Reusing that projection is the point: "a
|
|
882
|
+
// cloud block without a token is not a cloud block" is a security property,
|
|
883
|
+
// and a second validator here is how a half-written credential comes to be
|
|
884
|
+
// treated as live.
|
|
885
|
+
const readJson = (path: string): Record<string, unknown> | null => {
|
|
886
|
+
try {
|
|
887
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
888
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
889
|
+
? (parsed as Record<string, unknown>)
|
|
890
|
+
: null;
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
const cloud = readJson(legacy.cloudCredentials());
|
|
896
|
+
const ingest = readJson(legacy.ingestCredentials());
|
|
897
|
+
if (!cloud && !ingest) return null;
|
|
898
|
+
const raw: Record<string, unknown> = {};
|
|
899
|
+
if (cloud) {
|
|
900
|
+
raw.cloud = {
|
|
901
|
+
url: cloud.url,
|
|
902
|
+
machine_id: cloud.machineId,
|
|
903
|
+
token: cloud.token,
|
|
904
|
+
// Layout 1 had no machine label; absent is correct rather than empty.
|
|
905
|
+
...(typeof cloud.machineLabel === "string" ? { machine_label: cloud.machineLabel } : {}),
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (ingest) raw.ingest = { url: ingest.url, key: ingest.key };
|
|
909
|
+
const creds = projectCredentials(raw);
|
|
910
|
+
return Object.keys(creds).length > 0 ? creds : null;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function resetHome(from: number): ResetOutcome {
|
|
914
|
+
// BEFORE the deletions, so a file that is mid-move is never one the reset
|
|
915
|
+
// then walks over.
|
|
916
|
+
const migrated = migrateConventionPolicies();
|
|
917
|
+
const activity = migrateHookActivity();
|
|
918
|
+
const pendingPolicyConfig = readCarriedPolicyConfig();
|
|
919
|
+
// Read before the deletions for the same reason as the policy config: their
|
|
920
|
+
// sources (`config.toml`, `credentials.toml`) are both on the list below.
|
|
921
|
+
const pendingConfig = readCarriedLegacyConfig();
|
|
922
|
+
const pendingCredentials = readCarriedLegacyCredentials();
|
|
923
|
+
const telemetryOptOut = readCarriedTelemetryOptOut();
|
|
924
|
+
// BEFORE the deletions, like every other carry here: both source directories are
|
|
925
|
+
// on the retired list below.
|
|
926
|
+
const spooled = migrateLegacySpool();
|
|
927
|
+
const removed: string[] = [];
|
|
928
|
+
for (const path of resettablePaths()) {
|
|
929
|
+
// The guard that stood here skipped `globalPolicyConfigFile()` when the
|
|
930
|
+
// reset was not a layout migration, because clearing a current, valid
|
|
931
|
+
// policy selection is only right when setup is about to re-ask. That path is
|
|
932
|
+
// `user-typed` in `HOME_CLASSES` now and therefore never on this list at
|
|
933
|
+
// all, for either kind of reset — so the special case has nothing left to
|
|
934
|
+
// guard. See the `legacy.policyConfig()` note in `retiredLayoutPaths()`.
|
|
935
|
+
if (!existsSync(path)) continue;
|
|
936
|
+
try {
|
|
937
|
+
rmSync(path, { recursive: true, force: true });
|
|
938
|
+
removed.push(path);
|
|
939
|
+
} catch {
|
|
940
|
+
// A path we cannot remove is not worth aborting the reset over; the
|
|
941
|
+
// VERSION stamp below is what stops this repeating, and a leftover
|
|
942
|
+
// directory from an old layout is inert once nothing reads it.
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const policyConfig = writeCarriedPolicyConfig(pendingPolicyConfig);
|
|
946
|
+
// AFTER the carry, so a layout-2 value that happens to be named `collector`
|
|
947
|
+
// is retired too rather than surviving because it arrived a moment later.
|
|
948
|
+
retirePolicyConfigKeys();
|
|
949
|
+
// After the deletions, onto the fresh files, so the upgrade cannot revoke a
|
|
950
|
+
// choice the user made.
|
|
951
|
+
//
|
|
952
|
+
// The credentials go first and unconditionally: `daemon.configured` below makes
|
|
953
|
+
// the machine fail closed, and a machine that fails closed against a fleet it
|
|
954
|
+
// can no longer authenticate to is worse than either half alone.
|
|
955
|
+
if (pendingCredentials) writeCredentials(pendingCredentials);
|
|
956
|
+
if (pendingConfig) writeConfig(pendingConfig);
|
|
957
|
+
// AFTER the config carry, or a carried `telemetry.enabled: true` — the default,
|
|
958
|
+
// which is not a choice anyone made — would overwrite the opt-out this reads
|
|
959
|
+
// straight out of the same file. Both paths end at the same key, and only one
|
|
960
|
+
// of them represents something a person typed.
|
|
961
|
+
if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } });
|
|
962
|
+
writeVersionFile();
|
|
963
|
+
return { removed, migrated, activity, policyConfig, spooled, from };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export interface LayoutCheck {
|
|
967
|
+
state: LayoutState;
|
|
968
|
+
/** Lines to print. Empty when there is nothing to say. */
|
|
969
|
+
lines: string[];
|
|
970
|
+
/** True when the caller should stop rather than continue. */
|
|
971
|
+
fatal: boolean;
|
|
972
|
+
/**
|
|
973
|
+
* True when this call actually migrated the home.
|
|
974
|
+
*
|
|
975
|
+
* **Reporting only. The caller must NOT force setup on it.**
|
|
976
|
+
*
|
|
977
|
+
* It used to mean "force the wizard", and the reason was a real gap: the reset
|
|
978
|
+
* removed the global policy config while `isConfigured()` is a union that also
|
|
979
|
+
* counts the agent CLIs' settings files — which the reset deliberately leaves
|
|
980
|
+
* alone — so the machine read as configured, the wizard was skipped, and
|
|
981
|
+
* `markLauncherSeen()` back-filled the marker so every later run skipped it
|
|
982
|
+
* too. The user was left with hooks firing on every tool call against no
|
|
983
|
+
* policies at all, and nothing ever said so again.
|
|
984
|
+
*
|
|
985
|
+
* That gap is closed at the source. `policies-config.json`, `config.json` and
|
|
986
|
+
* `credentials.json` are `user-typed` in `HOME_CLASSES` and are no longer
|
|
987
|
+
* removed, so after a migration `isConfigured()` is true because the machine
|
|
988
|
+
* genuinely IS configured — its policy selection, its `daemon.configured`
|
|
989
|
+
* flag and its cloud enrolment all survived. Everything the migration still
|
|
990
|
+
* drops (audit cache, cloud deployments, daemon scratch) is re-derived or
|
|
991
|
+
* re-fetched with nobody present.
|
|
992
|
+
*
|
|
993
|
+
* Forcing setup from here would now mean opening an interactive wizard for a
|
|
994
|
+
* machine with nothing to answer — and on the machines that matter most (a
|
|
995
|
+
* fleet box, a CI runner, a headless gateway) there is nobody to answer it. A
|
|
996
|
+
* home that genuinely never finished setup still reaches the wizard by the
|
|
997
|
+
* ordinary route: `isConfigured()` is false for it, so `shouldOfferFirstRun`
|
|
998
|
+
* fires on its own. That is why forcing was redundant even before it was wrong.
|
|
999
|
+
*/
|
|
1000
|
+
didReset: boolean;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The interactive path: reset a stale home, refuse a future one.
|
|
1005
|
+
*
|
|
1006
|
+
* A future layout is refused rather than reset because the two failures are
|
|
1007
|
+
* not the same. An older home can be rebuilt by re-running setup; a home
|
|
1008
|
+
* written by a NEWER CLI holds data this build cannot read but a simple
|
|
1009
|
+
* upgrade could, and deleting it would destroy something recoverable.
|
|
1010
|
+
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* Deliver what is already spooled, immediately AFTER the migration.
|
|
1013
|
+
*
|
|
1014
|
+
* The order is the whole subtlety here, and the obvious one is wrong. Flushing
|
|
1015
|
+
* FIRST reads intuitive — get the events out before touching the disk — and it
|
|
1016
|
+
* cannot work: everything a flush needs to run is in a file the stale layout
|
|
1017
|
+
* hasn't got. `readConfig()` reads `config.json` and `readIngestCredential()`
|
|
1018
|
+
* reads `credentials.json`, both of which are LAYOUT-3 files, and a stale home by
|
|
1019
|
+
* definition has neither. Called before the migration, the flush would find no
|
|
1020
|
+
* ingest credential on every machine it ever ran on, refuse, and report nothing
|
|
1021
|
+
* pending — a step that looks like it protects data and is structurally incapable
|
|
1022
|
+
* of doing anything at all.
|
|
1023
|
+
*
|
|
1024
|
+
* Afterwards, both files exist: `readCarriedLegacyCredentials()` has just put the
|
|
1025
|
+
* token back and `readCarriedLegacyConfig()` the mode. So this runs where it can
|
|
1026
|
+
* actually succeed.
|
|
1027
|
+
*
|
|
1028
|
+
* What makes that safe rather than a gamble is that this is NOT the thing
|
|
1029
|
+
* protecting the events. `HOME_CLASSES` classes the spool `undelivered`, so it
|
|
1030
|
+
* survives the migration whatever happens here — losing it would be permanent,
|
|
1031
|
+
* because `cursors/` survives too and the watermark has already advanced past
|
|
1032
|
+
* every batch in it. This is the difference between "delivered" and "delivered on
|
|
1033
|
+
* the next collector pass", which matters only because the collector is unhurried
|
|
1034
|
+
* on purpose (a batch is swept once it is older than two minutes, at most 64 per
|
|
1035
|
+
* pass, on a 60-second cadence) and somebody standing at a dashboard cannot tell
|
|
1036
|
+
* "not yet" from "not working".
|
|
1037
|
+
*
|
|
1038
|
+
* BEST-EFFORT BY CONSTRUCTION. `runFlushCommand` refuses, with its own message
|
|
1039
|
+
* and a non-zero code, on every machine where a flush cannot work: collection
|
|
1040
|
+
* off, no ingest credential, an unsupported platform, no daemon listening. All of
|
|
1041
|
+
* those are ordinary here rather than errors, so the result is read for its COUNT
|
|
1042
|
+
* and its exit code discarded. Bounded at 30s: this sits in front of a command
|
|
1043
|
+
* the user typed, and a spool that will not drain is not a reason to hold their
|
|
1044
|
+
* terminal.
|
|
1045
|
+
*/
|
|
1046
|
+
async function drainSpoolAfterMigrating(): Promise<number> {
|
|
1047
|
+
try {
|
|
1048
|
+
// Cheap gates first, so an OSS machine and one that never connected cost
|
|
1049
|
+
// nothing at all — no dynamic import, no daemon probe. Read AFTER the
|
|
1050
|
+
// migration, so these see the carried values rather than the defaults a
|
|
1051
|
+
// missing layout-3 file would have produced.
|
|
1052
|
+
const cfg = readConfig();
|
|
1053
|
+
if (cfg.mode !== "cloud") return 0;
|
|
1054
|
+
if (!cfg.collector.hooks && !cfg.collector.sessions) return 0;
|
|
1055
|
+
const { runFlushCommand } = await import("./flush-cli");
|
|
1056
|
+
const result = await runFlushCommand({ wait: true, timeoutSecs: 30 });
|
|
1057
|
+
return result.pending;
|
|
1058
|
+
} catch {
|
|
1059
|
+
// A flush that throws is not a migration failure. Reported as "nothing
|
|
1060
|
+
// pending" because the number only adds a line to a message, and inventing a
|
|
1061
|
+
// count from a failed probe would be worse than saying nothing.
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export async function checkLayoutForCli(): Promise<LayoutCheck> {
|
|
1067
|
+
const state = detectLayout();
|
|
1068
|
+
|
|
1069
|
+
if (state.kind === "future") {
|
|
1070
|
+
return {
|
|
1071
|
+
state,
|
|
1072
|
+
fatal: true,
|
|
1073
|
+
didReset: false,
|
|
1074
|
+
lines: [
|
|
1075
|
+
`This machine's failproofai directory was written by a newer version`,
|
|
1076
|
+
`(layout ${state.found}; this build speaks ${LAYOUT_VERSION}).`,
|
|
1077
|
+
``,
|
|
1078
|
+
`Upgrade rather than reset — the data is fine, this build just cannot read it:`,
|
|
1079
|
+
` npm install -g failproofai@latest`,
|
|
1080
|
+
],
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (state.kind === "stale") {
|
|
1085
|
+
// Through the registry, not `resetHome` directly. The two look identical for
|
|
1086
|
+
// today's layouts — the chain from 1 or 2 is one step, and that step IS
|
|
1087
|
+
// `resetHome` — and they stop being identical the moment a layout 4 exists,
|
|
1088
|
+
// at which point this call site needs no change. It also means every
|
|
1089
|
+
// migration this machine has ever run is recorded, and the irreplaceable
|
|
1090
|
+
// files are copied aside first. See `migrations.ts`.
|
|
1091
|
+
const { runMigrations } = await import("./migrations");
|
|
1092
|
+
const run = runMigrations(state.found);
|
|
1093
|
+
const { removed, migrated, activity } = run.outcome;
|
|
1094
|
+
// After, not before — see the function's own note for why the intuitive
|
|
1095
|
+
// order cannot work.
|
|
1096
|
+
const pending = await drainSpoolAfterMigrating();
|
|
1097
|
+
return {
|
|
1098
|
+
state,
|
|
1099
|
+
fatal: false,
|
|
1100
|
+
didReset: true,
|
|
1101
|
+
lines: [
|
|
1102
|
+
`failproofai reorganised ${failproofaiHome()} in this version.`,
|
|
1103
|
+
// "activity history" was in this sentence while the reset was deleting
|
|
1104
|
+
// it, and "policy config" stayed in it after that file stopped being
|
|
1105
|
+
// removed. Both are the same failure: a message describing a delete list
|
|
1106
|
+
// it is not derived from. It names the CLASSES now, which is what
|
|
1107
|
+
// `HOME_CLASSES` actually decides — so it cannot drift again without the
|
|
1108
|
+
// rule itself changing.
|
|
1109
|
+
`Removed ${removed.length} item(s) that this version rebuilds — the audit`,
|
|
1110
|
+
`cache, cloud deployments (re-fetched on the next poll) and daemon scratch.`,
|
|
1111
|
+
`Your settings, cloud enrolment, policy selection, decision history,`,
|
|
1112
|
+
`undelivered events and daemon binary were all kept.`,
|
|
1113
|
+
// Named individually rather than counted. These are files a person
|
|
1114
|
+
// wrote; "moved 3 items" is not something you can check at a glance,
|
|
1115
|
+
// and the whole point of saying it is that they can.
|
|
1116
|
+
...(migrated.length > 0
|
|
1117
|
+
? [
|
|
1118
|
+
``,
|
|
1119
|
+
`Kept your own policy file(s) and moved them to where this version`,
|
|
1120
|
+
`loads them (${customPoliciesDir()}):`,
|
|
1121
|
+
...migrated.map((name) => ` ${name}`),
|
|
1122
|
+
]
|
|
1123
|
+
: []),
|
|
1124
|
+
// Counted, not named. Unlike policy files these are machine-written
|
|
1125
|
+
// pages with generated names — a list of them tells the reader nothing
|
|
1126
|
+
// they could act on, where the COUNT answers the only question they
|
|
1127
|
+
// have: did my history survive.
|
|
1128
|
+
...(activity.length > 0
|
|
1129
|
+
? [
|
|
1130
|
+
``,
|
|
1131
|
+
`Carried ${activity.length} page(s) of decision history into ${hookActivityDir()}.`,
|
|
1132
|
+
]
|
|
1133
|
+
: []),
|
|
1134
|
+
// Said only when a backlog actually survived the flush. Silence here
|
|
1135
|
+
// would be the wrong kind: the events are safe, but "safe" and
|
|
1136
|
+
// "delivered" are different states and only one of them shows up on a
|
|
1137
|
+
// dashboard. Naming the count is what stops a user reading an incomplete
|
|
1138
|
+
// dashboard as data loss.
|
|
1139
|
+
...(pending > 0
|
|
1140
|
+
? [
|
|
1141
|
+
``,
|
|
1142
|
+
`${pending} batch(es) were still undelivered and were carried across.`,
|
|
1143
|
+
`They ship on the next collector pass — \`failproofai flush --wait\` now if you`,
|
|
1144
|
+
`are waiting on a dashboard.`,
|
|
1145
|
+
]
|
|
1146
|
+
: []),
|
|
1147
|
+
// A step that threw leaves the home marked with the OLD layout, so the
|
|
1148
|
+
// next command tries again — which is right, and is also why this must
|
|
1149
|
+
// say so rather than let a partial migration pass for a finished one.
|
|
1150
|
+
...(run.failed
|
|
1151
|
+
? [
|
|
1152
|
+
``,
|
|
1153
|
+
`Step ${run.failed.from} → ${run.failed.to} did not finish: ${run.failed.error}`,
|
|
1154
|
+
`The home is still marked layout ${state.found} and will be retried. Copies of`,
|
|
1155
|
+
`your settings and enrolment were saved first, in ${migrationBackupDir(state.found)}.`,
|
|
1156
|
+
]
|
|
1157
|
+
: []),
|
|
1158
|
+
// No "run `failproofai config` to set up again". There is nothing to set
|
|
1159
|
+
// up: the settings, the enrolment and the policy selection all survived,
|
|
1160
|
+
// so the machine enforces exactly as it did before this command ran. A
|
|
1161
|
+
// home that genuinely never finished setup reaches the wizard through
|
|
1162
|
+
// `shouldOfferFirstRun`, which reads `isConfigured()` — see `didReset`.
|
|
1163
|
+
],
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Write the marker back for a fresh home — and ALSO for one whose layout had
|
|
1168
|
+
// to be recovered from a landmark. `inferred` means the home is genuinely on
|
|
1169
|
+
// this layout and only `VERSION` is missing; leaving it missing means every
|
|
1170
|
+
// later command re-infers it, and `writeVersionFile()` carries the daemon
|
|
1171
|
+
// version forward from the file it just failed to read, so the recorded
|
|
1172
|
+
// daemon version is erased and `daemonVersionSkew()` goes quiet about a stale
|
|
1173
|
+
// daemon. Re-stamping is what "the only thing actually missing is the marker"
|
|
1174
|
+
// was supposed to mean.
|
|
1175
|
+
if (state.kind === "absent" || (state.kind === "current" && state.inferred)) writeVersionFile();
|
|
1176
|
+
return {
|
|
1177
|
+
state,
|
|
1178
|
+
fatal: false,
|
|
1179
|
+
didReset: false,
|
|
1180
|
+
lines: [...(await healDaemonFlag()), ...staleDaemonHint()],
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Clear `daemonConfigured` when the service it points at is provably gone.
|
|
1186
|
+
*
|
|
1187
|
+
* This exists because the combination it repairs bricked a real machine during
|
|
1188
|
+
* development. `daemonConfigured` makes every hook route through failproofaid
|
|
1189
|
+
* and FAIL CLOSED when it cannot be reached — so removing the service without
|
|
1190
|
+
* first clearing the flag denies every tool call on the box, including
|
|
1191
|
+
* `UserPromptSubmit`, which locks the user out of their agent entirely. There
|
|
1192
|
+
* was no command to undo it: the only recovery was hand-editing JSON.
|
|
1193
|
+
*
|
|
1194
|
+
* Deliberately keyed on "not-installed", never on "stopped". A stopped service
|
|
1195
|
+
* is usually a restart in progress, and clearing the flag there would silently
|
|
1196
|
+
* downgrade a healthy machine to the in-process path — trading a loud, correct
|
|
1197
|
+
* failure for a quiet, wrong one.
|
|
1198
|
+
*/
|
|
1199
|
+
async function healDaemonFlag(): Promise<string[]> {
|
|
1200
|
+
try {
|
|
1201
|
+
const cfg = readConfig();
|
|
1202
|
+
if (!cfg.daemon.configured) return [];
|
|
1203
|
+
if (!isDaemonSupportedPlatform()) return [];
|
|
1204
|
+
|
|
1205
|
+
const status = daemonServiceStatus();
|
|
1206
|
+
if (status === "not-installed") {
|
|
1207
|
+
updateConfig({ daemon: { configured: false } });
|
|
1208
|
+
return [
|
|
1209
|
+
`failproofaid is no longer installed, but this machine was still configured`,
|
|
1210
|
+
`to require it — which denies every tool call. Cleared that flag; policies`,
|
|
1211
|
+
`now evaluate in-process. Run \`failproofai config\` to reinstall the daemon.`,
|
|
1212
|
+
``,
|
|
1213
|
+
];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Installed, and systemd has refused to start it: one of the paths the unit
|
|
1217
|
+
// is gated on is gone. This is what `npm rm -g failproofai` leaves behind —
|
|
1218
|
+
// npm runs no uninstall script, so the unit survives the package that
|
|
1219
|
+
// supplies its worker, and every tool call on the machine then denies with
|
|
1220
|
+
// nothing to point at.
|
|
1221
|
+
//
|
|
1222
|
+
// Treated like "not-installed" rather than like "stopped" because systemd
|
|
1223
|
+
// has already made the call and will keep making it at every boot. That is
|
|
1224
|
+
// the distinction `condition-failed` exists to carry; see its definition.
|
|
1225
|
+
if (status === "condition-failed") {
|
|
1226
|
+
updateConfig({ daemon: { configured: false } });
|
|
1227
|
+
return [
|
|
1228
|
+
`failproofaid is installed but cannot start — a file its service requires is`,
|
|
1229
|
+
`gone (most often because failproofai was removed with \`npm rm -g\`, which`,
|
|
1230
|
+
`deletes the worker but leaves the service behind). This machine was`,
|
|
1231
|
+
`configured to require the daemon, which denies every tool call, so that flag`,
|
|
1232
|
+
`is cleared; policies now evaluate in-process.`,
|
|
1233
|
+
``,
|
|
1234
|
+
`Run \`failproofai uninstall\` to remove the leftover service, or`,
|
|
1235
|
+
`\`failproofai config\` to rebuild it. \`${daemonStatusCommand()}\` names the missing path.`,
|
|
1236
|
+
``,
|
|
1237
|
+
];
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Installed and RUNNING is not the same as working, and the difference is
|
|
1241
|
+
// a total lockout. `ExecStart` bakes in `process.execPath`, so an
|
|
1242
|
+
// `nvm uninstall 20` months later leaves a unit systemd still calls active
|
|
1243
|
+
// whose worker dies on every spawn. Nothing else catches it: the install
|
|
1244
|
+
// probe cannot run retroactively, and the not-installed branch above never
|
|
1245
|
+
// fires because the unit is very much installed.
|
|
1246
|
+
//
|
|
1247
|
+
// Clearing the flag is the whole repair, and it is deliberately NOT
|
|
1248
|
+
// accompanied by an uninstall: this runs unprompted at the top of whatever
|
|
1249
|
+
// command the user typed, and tearing down a root-owned service from there
|
|
1250
|
+
// is not a decision to make on their behalf. Removing and reinstalling the
|
|
1251
|
+
// unit is the wizard's job, where a person is present — see the
|
|
1252
|
+
// `daemonAlreadyHealthy` probe in `configure-wizard.ts`.
|
|
1253
|
+
if (status === "running" && !(await probeDaemonEndToEnd())) {
|
|
1254
|
+
updateConfig({ daemon: { configured: false } });
|
|
1255
|
+
return [
|
|
1256
|
+
`failproofaid is running but cannot evaluate policies — its worker process`,
|
|
1257
|
+
`will not start (most often because the Node install its service was built`,
|
|
1258
|
+
`against is gone). This machine was configured to require it, which denies`,
|
|
1259
|
+
`every tool call, so that flag is cleared; policies now evaluate in-process.`,
|
|
1260
|
+
`Run \`failproofai config\` to rebuild the service.`,
|
|
1261
|
+
``,
|
|
1262
|
+
];
|
|
1263
|
+
}
|
|
1264
|
+
return [];
|
|
1265
|
+
} catch {
|
|
1266
|
+
// Never let a self-heal attempt break the command the user actually typed.
|
|
1267
|
+
return [];
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
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.
|
|
1278
|
+
*/
|
|
1279
|
+
function staleDaemonHint(): string[] {
|
|
1280
|
+
try {
|
|
1281
|
+
const skew = daemonVersionSkew();
|
|
1282
|
+
if (!skew) return [];
|
|
1283
|
+
return [
|
|
1284
|
+
`[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected} — ` +
|
|
1285
|
+
`run \`failproofai config\` to update it.`,
|
|
1286
|
+
``,
|
|
1287
|
+
];
|
|
1288
|
+
} catch {
|
|
1289
|
+
return [];
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* The hook path: never delete, never deny, but never stay quiet either.
|
|
1295
|
+
*
|
|
1296
|
+
* Returns a single stderr line when the layout is not current. Hooks run once
|
|
1297
|
+
* per tool call, so this repeats — deliberately. A warning that appears once
|
|
1298
|
+
* and then stops is a warning nobody sees, and the state it describes (a
|
|
1299
|
+
* machine whose global policies have silently stopped firing) persists until
|
|
1300
|
+
* somebody acts on it.
|
|
1301
|
+
*/
|
|
1302
|
+
/**
|
|
1303
|
+
* Why an UNATTENDED run must not proceed — a different question from the hook's.
|
|
1304
|
+
*
|
|
1305
|
+
* The hook asks "are global policies unenforced?", and answers it about one
|
|
1306
|
+
* file. This asks "is this home on a layout this build understands?", and the
|
|
1307
|
+
* answer must not be softened by anything: the scheduled audit runs with nobody
|
|
1308
|
+
* watching, and the ordinary CLI path RESETS a stale home — which deletes
|
|
1309
|
+
* `config.toml` (revoking a telemetry opt-out and the `[audit] auto` flag that
|
|
1310
|
+
* scheduled the run) and `credentials.toml` (dropping cloud enrolment). Doing
|
|
1311
|
+
* that on a timer, with the explanation going only to the service journal, is
|
|
1312
|
+
* the failure this gate exists to prevent.
|
|
1313
|
+
*
|
|
1314
|
+
* Sharing `layoutWarningForHook()` for both is what made this subtle: teaching
|
|
1315
|
+
* that function to stay quiet when the global policy config IS readable — right
|
|
1316
|
+
* for the hook, since layout 1 and layout 3 keep it at the same path — silently
|
|
1317
|
+
* turned this gate off for exactly the homes most likely to hit it.
|
|
1318
|
+
*/
|
|
1319
|
+
export function layoutBlockerForScheduledRun(): string | null {
|
|
1320
|
+
const state = detectLayout();
|
|
1321
|
+
if (state.kind === "current" || state.kind === "absent") return null;
|
|
1322
|
+
if (state.kind === "future") {
|
|
1323
|
+
return (
|
|
1324
|
+
`[failproofai] this directory was written by a newer version ` +
|
|
1325
|
+
`(layout ${state.found} vs ${LAYOUT_VERSION}) — refusing to run unattended. ` +
|
|
1326
|
+
`Upgrade failproofai.`
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
return (
|
|
1330
|
+
`[failproofai] setup predates this version (layout ${state.found} vs ${LAYOUT_VERSION}) — ` +
|
|
1331
|
+
`refusing to run unattended, because completing it would reset this home. ` +
|
|
1332
|
+
`Run \`failproofai config\`.`
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
export function layoutWarningForHook(): string | null {
|
|
1337
|
+
const state = detectLayout();
|
|
1338
|
+
if (state.kind === "current" || state.kind === "absent") return null;
|
|
1339
|
+
if (state.kind === "future") {
|
|
1340
|
+
return (
|
|
1341
|
+
`[failproofai] this directory was written by a newer version ` +
|
|
1342
|
+
`(layout ${state.found} vs ${LAYOUT_VERSION}) — policies are NOT being enforced. ` +
|
|
1343
|
+
`Upgrade failproofai.`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
// The claim is about ONE FILE — the global policy config — so ask about that
|
|
1347
|
+
// file rather than about the layout number. Layout 1 and layout 3 keep it at
|
|
1348
|
+
// the SAME path (`~/.failproofai/policies-config.json`), so a home carrying
|
|
1349
|
+
// one is read as layout 1 by the landmark fallback while the hook path loads
|
|
1350
|
+
// and enforces it perfectly well. Hand-writing that file is something
|
|
1351
|
+
// `docs/configuration.mdx` explicitly tells people to do, and they were told
|
|
1352
|
+
// on every single tool call that their policies were not being enforced
|
|
1353
|
+
// WHILE THEY WERE — the deny and the warning printed together.
|
|
1354
|
+
//
|
|
1355
|
+
// A warning that contradicts the behaviour it describes is worse than no
|
|
1356
|
+
// warning: it teaches people to ignore the one channel that will matter when
|
|
1357
|
+
// enforcement really has stopped.
|
|
1358
|
+
if (existsSync(globalPolicyConfigFile())) return null;
|
|
1359
|
+
return (
|
|
1360
|
+
`[failproofai] setup predates this version — global policies are NOT being enforced. ` +
|
|
1361
|
+
`Run \`failproofai config\` to re-create it.`
|
|
1362
|
+
);
|
|
1363
|
+
}
|