failproofai 1.0.0 → 1.0.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +0 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +5 -5
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/routes-manifest.json +0 -6
- 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 +4 -3
- 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 +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- 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 +4 -3
- 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/_not-found/__PAGE__.segment.rsc +14 -14
- 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 +2 -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 +3 -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 +2 -2
- 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 +4 -3
- 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 +2 -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/status/route.js +4 -3
- 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.nft.json +1 -1
- 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 +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +4 -3
- 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/server-reference-manifest.json +10 -10
- package/.next/standalone/.next/server/app/policies/page.js +6 -5
- 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/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +5 -4
- 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/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/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +5 -4
- 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/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js +6 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +0 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0nne229._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ny9q5d._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1hstmft._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p8poxd._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0jx6cp6._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0f-x2b0._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1f290jp._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0hwvl7e._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1g2ji1t._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1ctjdzl._.js} +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03eykvd._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07175vb._.js → [root-of-the-server]__07oh8b5._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cxe_2_._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g2rg69._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m98kzy._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0sjzdjg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0v5_91z._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-a3135._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1hvwiw8._.js → [root-of-the-server]__11z_299._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apc90t._.js → [root-of-the-server]__15c_z97._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1wmno1q._.js → [root-of-the-server]__17d_ffl._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e5g_ur._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hmqefd._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +6 -0
- package/.next/standalone/.next/server/chunks/ssr/_04j57fb._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_05enntk._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0hom1s1._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1nq-35m._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/_1q7j05x._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +3 -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/app_settings_settings-client_tsx_20lq-mq._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- 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 +15 -15
- package/.next/standalone/.next/static/chunks/00q6cfzuzpc85.js +1 -0
- package/.next/standalone/.next/static/chunks/{0zm3_8jpqitxy.js → 054eg7y_cjyf9.js} +22 -22
- package/.next/standalone/.next/static/chunks/{2ouq44jv_0x04.js → 0mjufa_0xxz75.js} +1 -1
- package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +1 -0
- package/.next/standalone/.next/static/chunks/{0zi8zjav5a4rq.js → 1-oz4co31nvc5.js} +1 -1
- package/.next/standalone/.next/static/chunks/13--2yvqek_s1.js +1 -0
- package/.next/standalone/.next/static/chunks/136ldjmzxhush.js +1 -0
- package/.next/standalone/.next/static/chunks/18fqy3doo2ial.js +6 -0
- package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
- package/.next/standalone/.next/static/chunks/{31k3yxpy57_fy.js → 1o5sxp_mdaoci.js} +1 -1
- package/.next/standalone/.next/static/chunks/29-iqnp9v_axt.css +1 -0
- package/.next/standalone/.next/static/chunks/{2676v-lyzgo91.js → 2es4z1ei90vsv.js} +1 -1
- package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +1 -0
- package/.next/standalone/.next/static/chunks/3peix2vxzj7eh.js +1 -0
- package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +1 -0
- package/.next/standalone/.next/static/chunks/{0km-jn9o5xyqs.css → 3wq9x0c-7ld5d.css} +1 -1
- package/.next/standalone/app/actions/get-scheduled-audit.ts +65 -6
- package/.next/standalone/app/actions/update-scheduled-audit.ts +90 -17
- package/.next/standalone/app/api/auth/status/route.ts +7 -17
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +2 -6
- package/.next/standalone/app/audit/_components/come-back-better-section.tsx +101 -281
- package/.next/standalone/app/audit/_components/empty-state.tsx +2 -2
- package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
- package/.next/standalone/app/audit/audit-styles.css +150 -26
- package/.next/standalone/app/globals.css +39 -1
- package/.next/standalone/app/settings/page.tsx +38 -21
- package/.next/standalone/app/settings/settings-client.tsx +583 -392
- package/.next/standalone/app/settings/settings.css +354 -0
- package/.next/standalone/components/navbar.tsx +33 -4
- package/.next/standalone/lib/auth/api-server-client.ts +51 -29
- package/.next/standalone/lib/auth/auth-store.ts +44 -59
- package/.next/standalone/package.json +12 -10
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +6 -0
- package/bin/failproofaid-shim.mjs +0 -0
- package/dist/cli.mjs +1889 -426
- package/dist/worker.mjs +27 -5
- package/lib/auth/api-server-client.ts +51 -29
- package/lib/auth/auth-store.ts +44 -59
- package/package.json +12 -10
- package/scripts/docs-audit.ts +493 -0
- package/scripts/translate-docs/cli.ts +16 -2
- package/scripts/translate-docs/mdx-translator.ts +6 -1
- package/scripts/translate-docs/readme-translator.ts +4 -2
- package/src/audit/cli-login.ts +330 -0
- package/src/audit/cli.ts +124 -6
- package/src/audit/dashboard-cache.ts +44 -8
- package/src/audit/harm-report.ts +260 -0
- package/src/audit/machine-store.ts +120 -0
- package/src/audit/redact-example.ts +312 -0
- package/src/audit/report-harm.ts +167 -0
- package/src/audit/schedule-cli.ts +352 -0
- package/src/hooks/builtin-policies.ts +26 -0
- package/src/hooks/daemon-service.ts +76 -0
- package/src/hooks/fp-config.ts +96 -5
- package/src/hooks/fp-home.ts +118 -8
- package/src/hooks/fp-reset.ts +79 -9
- package/src/hooks/migrations.ts +241 -4
- package/src/hooks/tui.ts +61 -3
- package/.next/standalone/.next/server/app/api/auth/reminder/route/app-paths-manifest.json +0 -3
- package/.next/standalone/.next/server/app/api/auth/reminder/route/build-manifest.json +0 -12
- package/.next/standalone/.next/server/app/api/auth/reminder/route/server-reference-manifest.json +0 -4
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js +0 -9
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.map +0 -5
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +0 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route_client-reference-manifest.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-xggru._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__00ds05_._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dxagv_._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fgqj1_._.js +0 -3
- package/.next/standalone/.next/server/chunks/_084toou._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0g4t8c9._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0km521a._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0t8kefc._.js +0 -3
- package/.next/standalone/.next/server/chunks/_16dwkfn._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1phm9ui._.js +0 -3
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_auth_reminder_route_actions_1kjgxf8.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dk8r_l._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kviq2m._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ogyhu0._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-rfu32._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1t62w37._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_032wo7w._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_08840em._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0gl2ujp._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0j0ggkc._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0kbv5-q._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0r1jgvp._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0t5xq1b._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0w_be0g._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_15gba46._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_1qyhb9r._.js +0 -23
- package/.next/standalone/.next/server/chunks/ssr/_1vel2ey._.js +0 -3
- package/.next/standalone/.next/static/chunks/0wd503tv801gy.js +0 -1
- package/.next/standalone/.next/static/chunks/1hfocb0sdl85c.js +0 -1
- package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +0 -1
- package/.next/standalone/.next/static/chunks/2kopjq_or743g.js +0 -6
- package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +0 -2
- package/.next/standalone/.next/static/chunks/2ynl0-6aqng_b.js +0 -1
- package/.next/standalone/.next/static/chunks/3xvtgz6arztp_.js +0 -1
- package/.next/standalone/app/api/auth/reminder/route.ts +0 -213
- /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `failproofai audit --schedule [days]` / `--no-schedule` / `--status`.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Until now the only way to turn scheduled audits on was the dashboard's
|
|
7
|
+
* settings page — a browser. `failproofaid` is a SYSTEM service:
|
|
8
|
+
* `WantedBy=multi-user.target`, starts at boot, needs no login, survives
|
|
9
|
+
* logout. That design exists for headless boxes, detached tmux, cron and CI
|
|
10
|
+
* runners, and not one of those can open a settings page. The feature was
|
|
11
|
+
* built for machines that had no way to switch it on.
|
|
12
|
+
*
|
|
13
|
+
* ## Parity is structural, not a promise
|
|
14
|
+
*
|
|
15
|
+
* Every write here goes through the same `updateConfig` the dashboard's server
|
|
16
|
+
* actions call, and the session goes through the same `auth-store`. There is
|
|
17
|
+
* one `config.json`, one `audit/session.json`, and one writer function for
|
|
18
|
+
* each — so "the CLI and the dashboard are always in sync" is a consequence of
|
|
19
|
+
* the shape rather than something to keep true by hand. Two files, or two
|
|
20
|
+
* writers, is where that promise starts needing tests to defend it.
|
|
21
|
+
*
|
|
22
|
+
* Two doc comments in `app/actions/` used to claim `failproofai config` already
|
|
23
|
+
* wrote these keys. It never did — the wizard calls `updateConfig` zero times.
|
|
24
|
+
* Those comments are corrected in this change rather than left describing a
|
|
25
|
+
* command that did not exist.
|
|
26
|
+
*/
|
|
27
|
+
import { readConfig, updateConfig } from "../hooks/fp-config";
|
|
28
|
+
import { daemonServiceStatus, isDaemonSupportedPlatform } from "../hooks/daemon-service";
|
|
29
|
+
import { readAuth } from "../../lib/auth/auth-store";
|
|
30
|
+
import { readAuditSchedule } from "./audit-schedule";
|
|
31
|
+
import { readDashboardCacheMeta } from "./dashboard-cache";
|
|
32
|
+
import { readMachineIdentity } from "./machine-store";
|
|
33
|
+
import { ensureSignedIn, invalidEmail, LoginError } from "./cli-login";
|
|
34
|
+
import {
|
|
35
|
+
ANSI_BOLD,
|
|
36
|
+
ANSI_DIM,
|
|
37
|
+
ANSI_RESET,
|
|
38
|
+
brandAnsi,
|
|
39
|
+
colorsEnabled,
|
|
40
|
+
outro,
|
|
41
|
+
step,
|
|
42
|
+
} from "../hooks/tui";
|
|
43
|
+
|
|
44
|
+
/** Mirrors `fp-config`'s own bounds so the error can name them before writing. */
|
|
45
|
+
const MIN_DAYS = 1;
|
|
46
|
+
const MAX_DAYS = 90;
|
|
47
|
+
|
|
48
|
+
export class ScheduleCliError extends Error {}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Colour and the spine answer to ONE gate, checked at call time.
|
|
52
|
+
*
|
|
53
|
+
* They used to disagree: these helpers emitted ANSI unconditionally while the
|
|
54
|
+
* frame asked `colorsEnabled`, so a piped `--status` came out as escape codes
|
|
55
|
+
* with no structure — the worst of both. `colorsEnabled` is false off a TTY and
|
|
56
|
+
* under `NO_COLOR`, which is exactly when a readout should be plain text.
|
|
57
|
+
*/
|
|
58
|
+
const styled = () => colorsEnabled(process.stdout);
|
|
59
|
+
const wrap = (open: string, s: string) => (styled() ? `${open}${s}${ANSI_RESET}` : s);
|
|
60
|
+
const pink = (s: string) => wrap(brandAnsi("pink"), s);
|
|
61
|
+
const green = (s: string) => wrap(brandAnsi("guide"), s);
|
|
62
|
+
const dim = (s: string) => wrap(ANSI_DIM, s);
|
|
63
|
+
const bold = (s: string) => wrap(ANSI_BOLD, s);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The readout's left margin.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately NOT the `│` spine the sign-in uses. A spine means "a flow is
|
|
69
|
+
* happening, with a beginning and an end"; `--status` is a snapshot of a
|
|
70
|
+
* machine, and hanging one off a frame that never opened reads as an unfinished
|
|
71
|
+
* wizard. Alignment does the work here instead.
|
|
72
|
+
*/
|
|
73
|
+
const rail = () => " ";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One labelled row of the `--status` readout.
|
|
77
|
+
*
|
|
78
|
+
* A fixed label column so the values line up into a second column that can be
|
|
79
|
+
* read straight down — the whole point of this command is answering "what is
|
|
80
|
+
* this machine doing" at a glance, and a ragged left edge makes four facts read
|
|
81
|
+
* as four sentences.
|
|
82
|
+
*/
|
|
83
|
+
const LABEL_WIDTH = 15;
|
|
84
|
+
function row(label: string, value: string, note?: string): string {
|
|
85
|
+
const gap = " ".repeat(Math.max(1, LABEL_WIDTH - label.length));
|
|
86
|
+
return `${rail()} ${dim(label)}${gap}${value}${note ? ` ${dim(note)}` : ""}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Turn scheduled audits on, signing in first if needed.
|
|
91
|
+
*
|
|
92
|
+
* Scheduling and mailing are ONE decision — the reason to put a scan on a timer
|
|
93
|
+
* is to be told what it found — so this requires a session, exactly as the
|
|
94
|
+
* dashboard's `setAutoAuditAction` does. A timer set with nobody to tell is a
|
|
95
|
+
* switch that reads as on and produces nothing, discoverable only by noticing
|
|
96
|
+
* that no digest ever arrives.
|
|
97
|
+
*
|
|
98
|
+
* The interval is written and then RE-READ, so what is printed is what the
|
|
99
|
+
* config actually kept — `readIntervalDays` owns the 1..90 clamp and a second
|
|
100
|
+
* copy of those bounds here would be one more thing to drift.
|
|
101
|
+
*/
|
|
102
|
+
export async function runScheduleOn(
|
|
103
|
+
daysArg: string | undefined,
|
|
104
|
+
emailArg?: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
let days: number | undefined;
|
|
107
|
+
if (daysArg !== undefined) {
|
|
108
|
+
const parsed = Number(daysArg);
|
|
109
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
110
|
+
throw new ScheduleCliError(
|
|
111
|
+
`\`--schedule\` takes a whole number of days (got: ${daysArg}).`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (parsed < MIN_DAYS || parsed > MAX_DAYS) {
|
|
115
|
+
throw new ScheduleCliError(
|
|
116
|
+
`\`--schedule\` must be between ${MIN_DAYS} and ${MAX_DAYS} days (got: ${parsed}).`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
days = parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Checked here, beside the day count and before anything is drawn or sent: a
|
|
123
|
+
// typo'd flag should read as a usage error, not as a sign-in that opened a
|
|
124
|
+
// frame and then gave up.
|
|
125
|
+
if (emailArg !== undefined) {
|
|
126
|
+
const bad = invalidEmail(emailArg);
|
|
127
|
+
if (bad) throw new ScheduleCliError(bad);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { user, prompted } = await ensureSignedIn(emailArg);
|
|
131
|
+
|
|
132
|
+
const next = updateConfig({
|
|
133
|
+
// Stamped in the SAME call that sets `auto`, never separately: this records
|
|
134
|
+
// that a person completed a sign-in and read the disclosure printed below,
|
|
135
|
+
// and it is what `reportHarm` gates sending on. A machine that inherited
|
|
136
|
+
// `auto` from a release where it meant "scan locally" has no stamp and
|
|
137
|
+
// sends nothing until it comes through here.
|
|
138
|
+
audit: {
|
|
139
|
+
auto: true,
|
|
140
|
+
reportsConsentedAt: Date.now(),
|
|
141
|
+
...(days !== undefined ? { intervalDays: days } : {}),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const interval = next.audit.intervalDays;
|
|
145
|
+
|
|
146
|
+
// Two rows rather than one long one: at 80 columns the combined sentence
|
|
147
|
+
// wrapped, and a wrapped summary loses the spine on its second row.
|
|
148
|
+
// The third row enumerates what leaves the machine, and it is not optional.
|
|
149
|
+
// This is the ONLY opt-in path on the headless boxes the whole feature was
|
|
150
|
+
// built for — the settings panel says "sends: counts, redacted examples, and
|
|
151
|
+
// this machine's name" and argues in its own comment that a checkable list
|
|
152
|
+
// beats a stronger claim, and that reasoning applies here at least as much.
|
|
153
|
+
// The list is the real payload from `report-harm.ts`: machine id, hostname,
|
|
154
|
+
// platform, the window bounds and the redacted examples.
|
|
155
|
+
const summary = [
|
|
156
|
+
`every ${interval} day${interval === 1 ? "" : "s"} · reports to ${user.email}`,
|
|
157
|
+
"you only hear from it when a scan finds something harmful",
|
|
158
|
+
"each report sends: finding counts, redacted example commands, this machine's name",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
if (prompted) {
|
|
162
|
+
// A sign-in just drew the frame, so the result continues it and the `└`
|
|
163
|
+
// closes both at once — rather than the frame ending and a loose line
|
|
164
|
+
// appearing underneath.
|
|
165
|
+
step("scheduled audits are on", summary);
|
|
166
|
+
} else {
|
|
167
|
+
// Nothing was asked, so nothing was a flow: a spine here would open a frame
|
|
168
|
+
// that has no beginning.
|
|
169
|
+
process.stdout.write(
|
|
170
|
+
`\n${green("✓")} ${bold("scheduled audits are on")}\n` +
|
|
171
|
+
summary.map((r) => ` ${dim(r)}\n`).join(""),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The switch is config; whether anything RUNS is the daemon. Saying "on"
|
|
176
|
+
// without checking would be the same "on but silent" state the settings panel
|
|
177
|
+
// exists to make visible.
|
|
178
|
+
warnIfDaemonWontRun();
|
|
179
|
+
|
|
180
|
+
if (prompted) {
|
|
181
|
+
outro("failproofai audit --status · when the next scan is due");
|
|
182
|
+
} else {
|
|
183
|
+
process.stdout.write(dim(`\n failproofai audit --status when the next scan is due\n\n`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function runScheduleOff(): void {
|
|
188
|
+
const before = readConfig().audit.auto;
|
|
189
|
+
const next = updateConfig({ audit: { auto: false } });
|
|
190
|
+
if (next.audit.auto) {
|
|
191
|
+
process.stdout.write("Could not turn scheduled audits off.\n");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!before) {
|
|
195
|
+
process.stdout.write(dim("\nScheduled audits were already off.\n\n"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write(
|
|
199
|
+
`\n${green("✓")} ${bold("scheduled audits are off")}\n` +
|
|
200
|
+
` ${dim("nothing runs on a timer and nothing is sent")}\n` +
|
|
201
|
+
` ${dim("your session is untouched — sign out from the dashboard for that")}\n\n`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What this machine is actually doing.
|
|
207
|
+
*
|
|
208
|
+
* The one command with no equivalent anywhere else: on a headless box there was
|
|
209
|
+
* previously no way to ask whether scheduling was on, when the last scan ran, or
|
|
210
|
+
* whether the daemon was even up. Every value is read from the same places the
|
|
211
|
+
* dashboard reads them.
|
|
212
|
+
*/
|
|
213
|
+
export function runScheduleStatus(): void {
|
|
214
|
+
const config = readConfig();
|
|
215
|
+
const auth = readAuth();
|
|
216
|
+
const sched = readAuditSchedule();
|
|
217
|
+
const meta = readDashboardCacheMeta();
|
|
218
|
+
const machine = readMachineIdentity();
|
|
219
|
+
const daemon = daemonServiceStatus();
|
|
220
|
+
|
|
221
|
+
const on = config.audit.auto;
|
|
222
|
+
const out: string[] = [""];
|
|
223
|
+
|
|
224
|
+
// The state first and alone, in the accent that matches it — everything below
|
|
225
|
+
// is detail about a machine that is either doing this or not, and reading the
|
|
226
|
+
// detail first is reading the answer to a question nobody asked yet.
|
|
227
|
+
out.push(
|
|
228
|
+
`${rail()} ${bold("scheduled audit")} ${on ? green("on") : dim("off")}` +
|
|
229
|
+
(on ? dim(` every ${config.audit.intervalDays} days`) : ""),
|
|
230
|
+
);
|
|
231
|
+
out.push(rail());
|
|
232
|
+
|
|
233
|
+
// A session whose refresh window has closed cannot mint another access token,
|
|
234
|
+
// so it is a destination in name only. Showing the address for one would tell
|
|
235
|
+
// somebody their digests are going somewhere they are not.
|
|
236
|
+
const live = auth && auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
|
|
237
|
+
out.push(row("reports to", live ? live.user.email : dim("— signed out")));
|
|
238
|
+
if (on && !live) {
|
|
239
|
+
// The state the reporter surfaces as "signed-out". Named here for the same
|
|
240
|
+
// reason the settings panel names it: the scans keep running, so silence
|
|
241
|
+
// about the digests would look like the feature failing.
|
|
242
|
+
out.push(row("", pink("scans continue; digests are paused until you sign in")));
|
|
243
|
+
} else if (on && config.audit.reportsConsentedAt === undefined) {
|
|
244
|
+
// Signed in, scheduled, and still not sending: this machine set `audit.auto`
|
|
245
|
+
// when it only meant "scan locally", so nothing has consented to the digest
|
|
246
|
+
// leaving the box. Without this row the status screen would show a healthy
|
|
247
|
+
// schedule and a live address and still mail nothing, with no explanation
|
|
248
|
+
// anywhere the user can see.
|
|
249
|
+
out.push(
|
|
250
|
+
row("", pink("scans continue; digests need a fresh opt-in — run `--schedule` to turn them on")),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
out.push(row("daemon", describeDaemon(daemon)));
|
|
255
|
+
|
|
256
|
+
if (sched?.nextDueAtMs != null && on) {
|
|
257
|
+
out.push(row("next scan", untilPhrase(sched.nextDueAtMs)));
|
|
258
|
+
}
|
|
259
|
+
if (sched?.lastRunAtMs != null) {
|
|
260
|
+
const exit = sched.lastExitCode;
|
|
261
|
+
out.push(
|
|
262
|
+
row(
|
|
263
|
+
"last scheduled",
|
|
264
|
+
agoPhrase(sched.lastRunAtMs),
|
|
265
|
+
exit != null && exit !== 0 && exit !== 75 ? pink(`exit ${exit}`) : undefined,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
out.push(
|
|
270
|
+
row("last result", meta?.cachedAt ? agoPhrase(Date.parse(meta.cachedAt)) : dim("none yet")),
|
|
271
|
+
);
|
|
272
|
+
if (machine?.last_reported_at) {
|
|
273
|
+
out.push(row("last reported", agoPhrase(Date.parse(machine.last_reported_at))));
|
|
274
|
+
}
|
|
275
|
+
out.push("");
|
|
276
|
+
|
|
277
|
+
process.stdout.write(out.join("\n"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function describeDaemon(status: ReturnType<typeof daemonServiceStatus>): string {
|
|
281
|
+
switch (status) {
|
|
282
|
+
case "running":
|
|
283
|
+
return green("running");
|
|
284
|
+
case "stopped":
|
|
285
|
+
return pink("stopped — run `failproofai config` to repair it");
|
|
286
|
+
case "not-installed":
|
|
287
|
+
return pink("not installed — run `failproofai config`");
|
|
288
|
+
case "condition-failed":
|
|
289
|
+
return pink("installed but its binary is missing — run `failproofai config`");
|
|
290
|
+
default:
|
|
291
|
+
return isDaemonSupportedPlatform()
|
|
292
|
+
? dim(String(status))
|
|
293
|
+
: dim("unavailable on this platform");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Printed after turning scheduling on, where the answer changes what to do. */
|
|
298
|
+
function warnIfDaemonWontRun(): void {
|
|
299
|
+
const status = daemonServiceStatus();
|
|
300
|
+
if (status === "running") return;
|
|
301
|
+
// "unknown" is not "broken", and on macOS it is the ORDINARY reading.
|
|
302
|
+
// `daemonServiceStatus` needs `sudo -n` to interrogate a LaunchDaemon, and a
|
|
303
|
+
// Mac with no cached sudo credential — the overwhelmingly common state —
|
|
304
|
+
// answers "unknown" for a service that is running perfectly. Treating every
|
|
305
|
+
// non-`running` value as a fault told those users "nothing will run on the
|
|
306
|
+
// timer yet" in the same breath as confirming their schedule was on. The
|
|
307
|
+
// dashboard already special-cases it; this is the same call.
|
|
308
|
+
if (status === "unknown") return;
|
|
309
|
+
if (!isDaemonSupportedPlatform()) {
|
|
310
|
+
process.stderr.write(
|
|
311
|
+
`\n ${pink("!")} The background service is not available on this platform,\n` +
|
|
312
|
+
` so nothing will run on the timer here. \`failproofai audit\` still works.\n`,
|
|
313
|
+
);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
process.stderr.write(
|
|
317
|
+
`\n ${pink("!")} The background service is ${status.replace("-", " ")}, ` +
|
|
318
|
+
`so nothing will run on the timer yet.\n` +
|
|
319
|
+
` Run \`failproofai config\` to install or repair it.\n`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function untilPhrase(ms: number): string {
|
|
324
|
+
const diff = ms - Date.now();
|
|
325
|
+
if (diff <= 0) return "due now";
|
|
326
|
+
const d = Math.floor(diff / 86_400_000);
|
|
327
|
+
const h = Math.floor((diff % 86_400_000) / 3_600_000);
|
|
328
|
+
if (d > 0) return `in ${d}d${h > 0 ? ` ${h}h` : ""}`;
|
|
329
|
+
if (h > 0) return `in ${h}h`;
|
|
330
|
+
return `in ${Math.max(1, Math.floor(diff / 60_000))}m`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function agoPhrase(ms: number): string {
|
|
334
|
+
if (!Number.isFinite(ms)) return "unknown";
|
|
335
|
+
const diff = Date.now() - ms;
|
|
336
|
+
if (diff < 0) return "just now";
|
|
337
|
+
const d = Math.floor(diff / 86_400_000);
|
|
338
|
+
const h = Math.floor(diff / 3_600_000);
|
|
339
|
+
const m = Math.floor(diff / 60_000);
|
|
340
|
+
if (d > 0) return `${d}d ago`;
|
|
341
|
+
if (h > 0) return `${h}h ago`;
|
|
342
|
+
if (m > 0) return `${m}m ago`;
|
|
343
|
+
return "just now";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Turn a `LoginError` into the CLI's own error type, keeping its message. */
|
|
347
|
+
export function asScheduleError(err: unknown): never {
|
|
348
|
+
if (err instanceof LoginError || err instanceof ScheduleCliError) {
|
|
349
|
+
throw new ScheduleCliError(err.message);
|
|
350
|
+
}
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
@@ -140,6 +140,32 @@ const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/;
|
|
|
140
140
|
// sanitizeBearerTokens
|
|
141
141
|
const BEARER_TOKEN_RE = /Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}/i;
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Every pattern the `sanitize-*` policies treat as a secret, as one list.
|
|
145
|
+
*
|
|
146
|
+
* Exported so the audit's harm reporter can redact against the SAME definition
|
|
147
|
+
* of "secret" that the engine blocks on, rather than growing a second pattern
|
|
148
|
+
* list beside this one. Two lists is the shape that eventually disagrees, and
|
|
149
|
+
* the direction it disagrees in here is a live credential leaving a machine.
|
|
150
|
+
*
|
|
151
|
+
* The `sanitize-*` FUNCTIONS cannot be reused for this — they are detectors that
|
|
152
|
+
* return a `deny` with a message, not transforms that return scrubbed text. The
|
|
153
|
+
* patterns are the reusable part, so the patterns are what is shared.
|
|
154
|
+
*
|
|
155
|
+
* Ordered most-specific first, which is load-bearing for the API keys: a
|
|
156
|
+
* generic `sk-[A-Za-z0-9]{20,}` placed before `sk-ant-…` would label an
|
|
157
|
+
* Anthropic key as an OpenAI one. (It does not currently MATCH one — the
|
|
158
|
+
* hyphens in `sk-ant-` break the character class — but the ordering is what
|
|
159
|
+
* makes that a design rather than a coincidence.)
|
|
160
|
+
*/
|
|
161
|
+
export const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
162
|
+
[PRIVATE_KEY_RE, "private key"],
|
|
163
|
+
[JWT_RE, "JWT"],
|
|
164
|
+
[BEARER_TOKEN_RE, "bearer token"],
|
|
165
|
+
[CONNECTION_STRING_RE, "database credentials"],
|
|
166
|
+
...API_KEY_PATTERNS,
|
|
167
|
+
];
|
|
168
|
+
|
|
143
169
|
// warnDestructiveSql / warnSchemaAlteration
|
|
144
170
|
const SQL_TOOL_RE = /\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/;
|
|
145
171
|
const DESTRUCTIVE_SQL_RE = /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\b)/i;
|
|
@@ -1797,3 +1797,79 @@ export function daemonServiceStatus(): DaemonServiceStatus {
|
|
|
1797
1797
|
return "stopped";
|
|
1798
1798
|
}
|
|
1799
1799
|
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* When the running daemon started, as epoch ms — the source for /settings'
|
|
1803
|
+
* "up 11d" sub-line. Null whenever the answer isn't knowable.
|
|
1804
|
+
*
|
|
1805
|
+
* **From the MONOTONIC stamp, not the printed date.** `ActiveEnterTimestamp`
|
|
1806
|
+
* renders in the host's locale and timezone abbreviation (`Fri 2026-08-14
|
|
1807
|
+
* 19:45:13 IST`), which `Date.parse` reads as invalid on most abbreviations and,
|
|
1808
|
+
* worse, silently mis-parses on the few it recognises — a settings page
|
|
1809
|
+
* claiming the daemon started three hours in the future is a worse failure than
|
|
1810
|
+
* one that says nothing. `ActiveEnterTimestampMonotonic` is microseconds since
|
|
1811
|
+
* boot, locale-free, and pairs with a reading of the SAME clock to give the
|
|
1812
|
+
* epoch time back.
|
|
1813
|
+
*
|
|
1814
|
+
* An EPOCH time rather than a duration, so the page keeps counting without
|
|
1815
|
+
* re-fetching: a duration computed on the server is wrong the moment it renders.
|
|
1816
|
+
*
|
|
1817
|
+
* Linux only for now. launchd exposes no equivalent, so macOS would need the
|
|
1818
|
+
* job's pid out of `launchctl print` and then `ps -o etime=` — a SECOND
|
|
1819
|
+
* privileged call on every settings render, since reading a LaunchDaemon in the
|
|
1820
|
+
* system domain needs elevation. The sub-line is not worth doubling the sudo
|
|
1821
|
+
* traffic of the page; the status itself still renders there.
|
|
1822
|
+
*/
|
|
1823
|
+
export function daemonStartedAtMs(): number | null {
|
|
1824
|
+
if (process.platform !== "linux") return null;
|
|
1825
|
+
if (!existsSync(systemdUnitPath())) return null;
|
|
1826
|
+
try {
|
|
1827
|
+
const raw = execFileSync(
|
|
1828
|
+
"systemctl",
|
|
1829
|
+
["show", systemdUnitName(), "-p", "ActiveEnterTimestampMonotonic", "--value"],
|
|
1830
|
+
{ stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
|
|
1831
|
+
)
|
|
1832
|
+
.toString()
|
|
1833
|
+
.trim();
|
|
1834
|
+
// `process.hrtime.bigint()`, not `os.uptime()`. See the note on the
|
|
1835
|
+
// function below: the two count suspend differently, and mixing them is
|
|
1836
|
+
// what produced "up 30d" for a daemon started yesterday.
|
|
1837
|
+
return startedAtFromMonotonic(Number(raw), Math.floor(Number(process.hrtime.bigint()) / 1000), Date.now());
|
|
1838
|
+
} catch {
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* The arithmetic behind `daemonStartedAtMs`, split out so it can be tested
|
|
1845
|
+
* without a systemd on the machine running the tests.
|
|
1846
|
+
*
|
|
1847
|
+
* BOTH arguments must be readings of the SAME clock. systemd's
|
|
1848
|
+
* `ActiveEnterTimestampMonotonic` is `CLOCK_MONOTONIC`, which on Linux STOPS
|
|
1849
|
+
* while the machine is suspended; `os.uptime()` reads `/proc/uptime`, which
|
|
1850
|
+
* KEEPS COUNTING through suspend. Subtracting one from the other therefore adds
|
|
1851
|
+
* every second the laptop ever spent asleep to the daemon's apparent age — a
|
|
1852
|
+
* machine that suspends nightly read "up 30d" for a service started yesterday.
|
|
1853
|
+
* The old `< 0` guard caught only the impossible direction; this error is
|
|
1854
|
+
* always positive, so nothing rejected it.
|
|
1855
|
+
*
|
|
1856
|
+
* `process.hrtime.bigint()` is `CLOCK_MONOTONIC` on Linux (libuv's `uv_hrtime`),
|
|
1857
|
+
* the same clock systemd stamped with, so the subtraction is between two points
|
|
1858
|
+
* on one timeline.
|
|
1859
|
+
*
|
|
1860
|
+
* systemd writes 0 for a unit that has never been activated, and a stamp ahead
|
|
1861
|
+
* of the current reading cannot be true — both mean "no answer" rather than a
|
|
1862
|
+
* number, because a wrong uptime is indistinguishable from a right one to the
|
|
1863
|
+
* person reading it.
|
|
1864
|
+
*/
|
|
1865
|
+
export function startedAtFromMonotonic(
|
|
1866
|
+
activeEnterMonotonicUs: number,
|
|
1867
|
+
monotonicNowUs: number,
|
|
1868
|
+
nowMs: number,
|
|
1869
|
+
): number | null {
|
|
1870
|
+
if (!Number.isFinite(activeEnterMonotonicUs) || activeEnterMonotonicUs <= 0) return null;
|
|
1871
|
+
if (!Number.isFinite(monotonicNowUs) || monotonicNowUs <= 0) return null;
|
|
1872
|
+
const activeForMs = (monotonicNowUs - activeEnterMonotonicUs) / 1000;
|
|
1873
|
+
if (activeForMs < 0) return null;
|
|
1874
|
+
return Math.round(nowMs - activeForMs);
|
|
1875
|
+
}
|
package/src/hooks/fp-config.ts
CHANGED
|
@@ -102,6 +102,20 @@ export function detectLayout(): LayoutState {
|
|
|
102
102
|
// went missing is the exact failure this module exists to prevent, and it
|
|
103
103
|
// announced itself as a routine "reorganised your home" message.
|
|
104
104
|
if (existsSync(configFile())) {
|
|
105
|
+
// `config.json` proves layout 3 OR LATER — it cannot tell them apart, since
|
|
106
|
+
// layout 4 changed nothing about it. What separates the two is solely WHERE
|
|
107
|
+
// the audit's files sit, so ask that directly: any of layout 3's three
|
|
108
|
+
// root-level positions still occupied means the 3 → 4 move has not run.
|
|
109
|
+
//
|
|
110
|
+
// When none of them exist the two layouts are IDENTICAL on disk (the step
|
|
111
|
+
// would move nothing), and "current" is the correct, non-destructive answer.
|
|
112
|
+
const layoutThreePositions = [
|
|
113
|
+
legacy.authJson(),
|
|
114
|
+
legacy.nextAudit(),
|
|
115
|
+
legacy.auditSchedule(),
|
|
116
|
+
];
|
|
117
|
+
if (layoutThreePositions.some((p) => existsSync(p))) return { kind: "stale", found: 3 };
|
|
118
|
+
|
|
105
119
|
// `inferred`: the layout is right but the MARKER is missing, and nothing
|
|
106
120
|
// else rewrites it — so every later command re-derives it from a landmark,
|
|
107
121
|
// and the daemon version recorded in that file is gone for good
|
|
@@ -112,7 +126,16 @@ export function detectLayout(): LayoutState {
|
|
|
112
126
|
|
|
113
127
|
// `config.toml` and no `config.json` is genuinely layout 2, and a reset is
|
|
114
128
|
// right: its files are the ones being replaced.
|
|
115
|
-
|
|
129
|
+
//
|
|
130
|
+
// The literal 2, NOT `LAYOUT_VERSION - 1`. That expression was correct while
|
|
131
|
+
// current was 3 and became a data-loss bug the moment layout 4 landed: it
|
|
132
|
+
// reported a real layout-2 home as layout 3, so `planMigration` ran only the
|
|
133
|
+
// 3 → 4 step — which finds none of layout 3's files, moves nothing, and stamps
|
|
134
|
+
// the home as current. `config.toml` and `credentials.toml` would never be
|
|
135
|
+
// carried into JSON, orphaning the cloud token and `daemon.configured` on a
|
|
136
|
+
// machine that now reads as fully migrated. A landmark identifies ONE layout;
|
|
137
|
+
// it is never relative to whatever this build happens to speak.
|
|
138
|
+
if (existsSync(legacy.configToml())) return { kind: "stale", found: 2 };
|
|
116
139
|
|
|
117
140
|
// Layout 1 if any of its landmarks are present, otherwise this is simply a
|
|
118
141
|
// home that has not been set up yet.
|
|
@@ -187,12 +210,23 @@ export function readVersionFile(): VersionFile | null {
|
|
|
187
210
|
}
|
|
188
211
|
}
|
|
189
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Stamp `VERSION`.
|
|
215
|
+
*
|
|
216
|
+
* `layout` defaults to {@link LAYOUT_VERSION} — every ordinary caller is saying
|
|
217
|
+
* "this home now speaks what this build speaks". It is honoured when passed
|
|
218
|
+
* ONLY so a failed migration can put the marker back where the home actually
|
|
219
|
+
* is: the signature has always accepted `layout` (it is part of `VersionFile`)
|
|
220
|
+
* and the body used to ignore it, so a caller asking for 3 silently got 4, and
|
|
221
|
+
* the one place that needs to ask is the one place where being wrong strands a
|
|
222
|
+
* half-migrated home as "current" forever. See `runMigrations`.
|
|
223
|
+
*/
|
|
190
224
|
export function writeVersionFile(
|
|
191
225
|
v: Partial<VersionFile> & { /** Erase the daemon version rather than keeping it. */ clearDaemon?: boolean } = {},
|
|
192
226
|
): void {
|
|
193
227
|
const existing = readVersionFile();
|
|
194
228
|
const next: VersionFile = {
|
|
195
|
-
layout: LAYOUT_VERSION,
|
|
229
|
+
layout: v.layout ?? LAYOUT_VERSION,
|
|
196
230
|
cli: v.cli ?? cliVersion,
|
|
197
231
|
// `undefined` means "leave whatever is there" — a CLI-only rewrite must not
|
|
198
232
|
// drop a daemon version it never touched. Erasing it is therefore an
|
|
@@ -274,7 +308,22 @@ export interface FpConfig {
|
|
|
274
308
|
};
|
|
275
309
|
audit: {
|
|
276
310
|
/**
|
|
277
|
-
* Scan this machine
|
|
311
|
+
* Scan this machine on a schedule, and mail a digest when a scan finds
|
|
312
|
+
* something harmful.
|
|
313
|
+
*
|
|
314
|
+
* ONE switch, not two. An earlier revision split this into `auto` (scan
|
|
315
|
+
* locally, no account) and `email_enabled` (mail me, sign in), and the two
|
|
316
|
+
* could only ever disagree — a machine scanning on a timer with nothing to
|
|
317
|
+
* report it to is a feature that looks on and does nothing visible. The
|
|
318
|
+
* reason to put a scan on a timer is to be TOLD, so scheduling and mailing
|
|
319
|
+
* are the same decision and take the same switch.
|
|
320
|
+
*
|
|
321
|
+
* A signed-out machine with this on is therefore a real, expected state
|
|
322
|
+
* rather than a contradiction: it keeps scanning and keeps its local
|
|
323
|
+
* dashboard current, and the UI says "signed out" until somebody signs back
|
|
324
|
+
* in. Auth gates SETTING it up, never the machine's ongoing work — a
|
|
325
|
+
* refresh token expiring must not silently switch off a background feature
|
|
326
|
+
* somebody configured months ago.
|
|
278
327
|
*
|
|
279
328
|
* OFF by default, and the asymmetry with `telemetry.enabled` above is
|
|
280
329
|
* deliberate: the audit reads the CONTENTS of every session transcript on
|
|
@@ -282,6 +331,28 @@ export interface FpConfig {
|
|
|
282
331
|
* timer until somebody asks for it.
|
|
283
332
|
*/
|
|
284
333
|
auto: boolean;
|
|
334
|
+
/**
|
|
335
|
+
* When this machine's owner agreed that a scheduled scan may send what it
|
|
336
|
+
* finds off the box, as epoch ms. Absent means they never did.
|
|
337
|
+
*
|
|
338
|
+
* This does NOT reintroduce the second switch the comment above rejects,
|
|
339
|
+
* and it is never drawn as one. `auto` is what a person sets; this is a
|
|
340
|
+
* record of the disclosure they were shown when they set it. The two cannot
|
|
341
|
+
* drift, because every path that turns `auto` on stamps this in the same
|
|
342
|
+
* call and nothing stamps it alone.
|
|
343
|
+
*
|
|
344
|
+
* It exists because `auto` CHANGED MEANING. Through 1.0.0 it meant "scan
|
|
345
|
+
* this machine on a timer" and nothing more — no account, no network, and
|
|
346
|
+
* the toggle that wrote it said as much in as many words. Harm digests gave
|
|
347
|
+
* the same stored bit a second job: uploading redacted transcript excerpts
|
|
348
|
+
* to the api-server and mailing them. Without a separate record, every
|
|
349
|
+
* machine that opted into the old meaning would have started sending on
|
|
350
|
+
* upgrade, having agreed to nothing of the kind, with the only notice a
|
|
351
|
+
* line in the journal. Gating on the stamp rather than the switch is what
|
|
352
|
+
* keeps that upgrade silent in the safe direction: those machines keep
|
|
353
|
+
* scanning locally and send nothing until somebody opts in again.
|
|
354
|
+
*/
|
|
355
|
+
reportsConsentedAt?: number;
|
|
285
356
|
/** Days between scheduled runs. Wall clock, so it survives suspend. */
|
|
286
357
|
intervalDays: number;
|
|
287
358
|
};
|
|
@@ -458,7 +529,17 @@ export function projectConfig(parsed: Record<string, unknown>): FpConfig {
|
|
|
458
529
|
// scheduled scan on. Absent, misspelled, or `"yes"` all read as off,
|
|
459
530
|
// because the failure direction here is a machine that starts reading
|
|
460
531
|
// every transcript it can find on a timer nobody set.
|
|
461
|
-
audit: {
|
|
532
|
+
audit: {
|
|
533
|
+
auto: audit.auto === true,
|
|
534
|
+
intervalDays: readIntervalDays(audit.interval_days),
|
|
535
|
+
// A finite number or nothing. A garbage value reads as absent, which is
|
|
536
|
+
// the direction that sends nothing.
|
|
537
|
+
reportsConsentedAt:
|
|
538
|
+
typeof audit.reports_consented_at === "number" &&
|
|
539
|
+
Number.isFinite(audit.reports_consented_at)
|
|
540
|
+
? audit.reports_consented_at
|
|
541
|
+
: undefined,
|
|
542
|
+
},
|
|
462
543
|
// Same shape as `audit.auto` above and for the same reason: only an
|
|
463
544
|
// explicit `true` opts in. Anything else — absent, misspelled, `"yes"` —
|
|
464
545
|
// reads as off, because the failure direction is a machine that starts
|
|
@@ -504,6 +585,7 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [
|
|
|
504
585
|
["telemetry", "enabled"],
|
|
505
586
|
["audit", "auto"],
|
|
506
587
|
["audit", "interval_days"],
|
|
588
|
+
["audit", "reports_consented_at"],
|
|
507
589
|
];
|
|
508
590
|
|
|
509
591
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
@@ -604,7 +686,16 @@ export function writeConfig(config: FpConfig, raw?: Record<string, unknown>): vo
|
|
|
604
686
|
// nobody can see is the same as a switch that does not exist. Emitting both
|
|
605
687
|
// keys unconditionally also makes "a user's setting survives a rewrite"
|
|
606
688
|
// total rather than conditional.
|
|
607
|
-
audit: {
|
|
689
|
+
audit: {
|
|
690
|
+
auto: config.audit.auto,
|
|
691
|
+
interval_days: config.audit.intervalDays,
|
|
692
|
+
// Written only once there IS consent, so an untouched machine's config
|
|
693
|
+
// does not grow a key implying it was asked. It is in
|
|
694
|
+
// `OWNED_CONFIG_KEYS`, so omitting it here really removes it.
|
|
695
|
+
...(config.audit.reportsConsentedAt === undefined
|
|
696
|
+
? {}
|
|
697
|
+
: { reports_consented_at: config.audit.reportsConsentedAt }),
|
|
698
|
+
},
|
|
608
699
|
};
|
|
609
700
|
// Start from the previous bytes, strip the keys this build owns — so an
|
|
610
701
|
// omission above really removes — then lay the projection on top. What is left
|