failproofai 1.0.0-beta.0 → 1.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -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]__06hexd0._.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]__0g0vcwt._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ky_n0r._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0lk_935._.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]__10oa-v9._.js → [root-of-the-server]__0v2fpxe._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +3 -3
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__1_ps0jw._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__1a7i2q4._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1iny24d._.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]__1tyedjt._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +5 -0
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +5 -0
- package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +5 -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]__04pwh4t._.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]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__10butu6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1a2hcb_._.js +3 -0
- 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]__1apb7k_._.js → [root-of-the-server]__1rdruw4._.js} +2 -2
- 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]__1tyf0tc._.js +5 -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/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1yw368c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_04mekdy._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0r-dsre._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0se484m._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0uz7o9y._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1fahg9e._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1gmzkwj._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/_1w8sola._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1w_6p9g._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1y4qr7u._.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/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 +5 -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/00adubtgov21s.js +1 -0
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1grd7y94-igz9.js → 0yudogeqddpzr.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/1bm0q12hgs22l.css +2 -0
- package/.next/standalone/.next/static/chunks/{1d6ncspjagm53.js → 1fu90ch80qngv.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2vr7aazw6k2-n.js → 1m416lie3qwsj.js} +1 -1
- package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
- package/.next/standalone/.next/static/chunks/1ypqzvoal8j56.js +6 -0
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/{29k6ef49zppxj.js → 2h4sbmp0ad78w.js} +1 -1
- package/.next/standalone/.next/static/chunks/2obwg4tterjpa.js +1 -0
- package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
- package/.next/standalone/.next/static/chunks/{29ydr-8q0zis6.js → 30ufy4_e2n78e.js} +22 -22
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{1s-_swlc80y43.js → 3p4j0l1o_p-nm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0ip1n8xmgxarv.js → 3uxnxwxcctyj0.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/actions/get-hooks-config.ts +2 -1
- 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/auth-dialog.css +159 -0
- package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
- package/.next/standalone/app/audit/audit-styles.css +6 -149
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/app/settings/page.tsx +31 -0
- package/.next/standalone/app/settings/settings-client.tsx +488 -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 -4
- 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 +17 -10
- package/.next/standalone/proxy.ts +89 -1
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +518 -79
- package/dist/cli.mjs +11484 -6046
- package/dist/worker.mjs +2019 -272
- package/lib/auth/api-server-client.ts +2 -3
- package/lib/auth/auth-store.ts +4 -4
- 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 +17 -10
- package/scripts/build-daemon-packages.mjs +210 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/install-telemetry.mjs +8 -1
- package/scripts/launch.ts +25 -3
- package/scripts/parse-script-args.ts +17 -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/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 -4
- 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 -1
- package/src/hooks/backfill-cli.ts +247 -0
- package/src/hooks/builtin-policies.ts +390 -2
- package/src/hooks/cloud-connection.ts +354 -0
- package/src/hooks/cloud-enrollment-cli.ts +325 -0
- package/src/hooks/cloud-enrollment.ts +243 -0
- package/src/hooks/cloud-introspect.ts +147 -0
- package/src/hooks/cloud-managed-policies.ts +169 -0
- package/src/hooks/collector-config.ts +234 -0
- package/src/hooks/configure-wizard.ts +915 -158
- package/src/hooks/custom-hooks-loader.ts +191 -33
- package/src/hooks/daemon-client.ts +136 -17
- package/src/hooks/daemon-download.ts +192 -30
- package/src/hooks/daemon-service.ts +1190 -92
- package/src/hooks/first-run-gate.ts +50 -0
- package/src/hooks/fp-config.ts +497 -0
- package/src/hooks/fp-home.ts +375 -0
- package/src/hooks/fp-reset.ts +464 -0
- package/src/hooks/handler.ts +185 -6
- package/src/hooks/hook-activity-store.ts +85 -20
- package/src/hooks/hook-logger.ts +2 -1
- package/src/hooks/hook-telemetry.ts +5 -1
- package/src/hooks/hooks-config.ts +4 -3
- package/src/hooks/loader-utils.ts +171 -8
- package/src/hooks/manager.ts +5 -4
- package/src/hooks/onboarding-attempt.ts +179 -0
- package/src/hooks/onboarding-lock.ts +119 -0
- package/src/hooks/policy-types.ts +28 -0
- package/src/hooks/session-pause-cli.ts +159 -0
- package/src/hooks/session-pause.ts +268 -0
- package/src/hooks/setup-state.ts +256 -0
- package/src/hooks/tui.ts +119 -4
- package/src/hooks/uninstall-cli.ts +345 -0
- package/src/hooks/worker-server.ts +65 -3
- 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]__0259h38._.js +0 -3
- 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]__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]__1zj-87g._.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_1-upb14.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/0u9hc6oxx-gpj.js +0 -6
- 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/3wrgbih-516uj.js +0 -1
- 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/{7Lxtd352rSaHB-pmm6G8M → pBBcTvE6_VueYGbacy1kg}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{7Lxtd352rSaHB-pmm6G8M → pBBcTvE6_VueYGbacy1kg}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{7Lxtd352rSaHB-pmm6G8M → pBBcTvE6_VueYGbacy1kg}/_ssgManifest.js +0 -0
|
@@ -8,13 +8,24 @@
|
|
|
8
8
|
* `configure-wizard.ts` calls the functions here directly, the same
|
|
9
9
|
* relationship it already has with `manager.ts`'s `installHooks()`.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
13
21
|
import { resolve, dirname } from "node:path";
|
|
14
22
|
import { execFileSync } from "node:child_process";
|
|
15
23
|
import { hookLogWarn } from "./hook-logger";
|
|
16
24
|
import { getConfigPathForScope } from "./hooks-config";
|
|
17
|
-
import { downloadFailproofaidBinary, installedBinaryPath } from "./daemon-download";
|
|
25
|
+
import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download";
|
|
26
|
+
import { logsDir } from "./fp-home";
|
|
27
|
+
import { version } from "../../package.json";
|
|
28
|
+
import { readVersionFile, updateConfig, writeVersionFile } from "./fp-config";
|
|
18
29
|
|
|
19
30
|
/**
|
|
20
31
|
* Every `systemctl --user` / `launchctl` call is bounded. Both talk to a
|
|
@@ -45,6 +56,14 @@ const SERVICE_START_POLL_MS = 100;
|
|
|
45
56
|
*/
|
|
46
57
|
const SERVICE_SETTLE_MS = 750;
|
|
47
58
|
|
|
59
|
+
/**
|
|
60
|
+
* How long `sudo -v` may sit at its password prompt. Unlike every other
|
|
61
|
+
* timeout here, this one is waiting on a human typing, not on a service
|
|
62
|
+
* manager — three attempts at a password is well inside two minutes, and
|
|
63
|
+
* sudo gives up on its own long before that.
|
|
64
|
+
*/
|
|
65
|
+
const SUDO_PROMPT_TIMEOUT_MS = 120_000;
|
|
66
|
+
|
|
48
67
|
/**
|
|
49
68
|
* Writes (or clears) the machine-wide `daemonConfigured` marker in
|
|
50
69
|
* `~/.failproofai/policies-config.json`.
|
|
@@ -60,25 +79,40 @@ const SERVICE_SETTLE_MS = 750;
|
|
|
60
79
|
* Global scope only: whether *this machine* runs a daemon is not a
|
|
61
80
|
* per-project setting.
|
|
62
81
|
*/
|
|
63
|
-
export function setDaemonConfigured(value: boolean): void {
|
|
64
|
-
const path = getConfigPathForScope("user");
|
|
65
|
-
let config: Record<string, unknown> = {};
|
|
66
|
-
try {
|
|
67
|
-
if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
68
|
-
} catch {
|
|
69
|
-
return; // a malformed global config is the install path's problem, not ours
|
|
70
|
-
}
|
|
71
|
-
if (value) config.daemonConfigured = true;
|
|
72
|
-
else delete config.daemonConfigured;
|
|
82
|
+
export function setDaemonConfigured(value: boolean, installedVersion?: string): void {
|
|
73
83
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
updateConfig({ daemon: { configured: value } });
|
|
85
|
+
// The version lives in VERSION, not here. One file is ABOUT versions and
|
|
86
|
+
// one copy cannot disagree with itself; keeping a second in config.toml
|
|
87
|
+
// would be a field that drifts out of step with the file beside it —
|
|
88
|
+
// exactly the class of bug the single path module exists to prevent.
|
|
89
|
+
// Cleared on uninstall: a recorded binary version for a service that is no
|
|
90
|
+
// longer configured is a claim about this machine that is no longer true.
|
|
91
|
+
writeVersionFile({ daemon: value ? installedVersion : undefined, clearDaemon: !value });
|
|
76
92
|
} catch {
|
|
77
93
|
/* best-effort: never fail a completed setup (or uninstall) over this flag */
|
|
78
94
|
}
|
|
79
95
|
}
|
|
80
96
|
|
|
81
|
-
export type DaemonServiceStatus =
|
|
97
|
+
export type DaemonServiceStatus =
|
|
98
|
+
| "running"
|
|
99
|
+
| "stopped"
|
|
100
|
+
/**
|
|
101
|
+
* Installed, but systemd evaluated its `ConditionPathExists=` and refused to
|
|
102
|
+
* start it — the daemon binary or the worker script is gone.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately its own state rather than folded into "stopped". "Stopped" is
|
|
105
|
+
* ambiguous on purpose (a restart in flight looks identical), which is why
|
|
106
|
+
* nothing is allowed to act destructively on it. A failed condition carries
|
|
107
|
+
* no such ambiguity: systemd has already decided this unit will not run, and
|
|
108
|
+
* will keep deciding that at every boot until the missing path returns. That
|
|
109
|
+
* is provable enough to clear `daemonConfigured` on, and leaving it
|
|
110
|
+
* indistinguishable from a transient stop is what kept a machine denying
|
|
111
|
+
* every tool call with no explanation.
|
|
112
|
+
*/
|
|
113
|
+
| "condition-failed"
|
|
114
|
+
| "not-installed"
|
|
115
|
+
| "unsupported-platform";
|
|
82
116
|
|
|
83
117
|
/** Linux + macOS only, per the plan's platform scope — full stop. */
|
|
84
118
|
export function isDaemonSupportedPlatform(): boolean {
|
|
@@ -129,13 +163,19 @@ export function resolveFailproofaidBinaryPath(): string | null {
|
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
/**
|
|
132
|
-
* Resolves the binary,
|
|
133
|
-
*
|
|
166
|
+
* Resolves the binary, installing one if it is not already on disk.
|
|
167
|
+
*
|
|
168
|
+
* Only the install path calls this — `failproofai config` choosing the global
|
|
169
|
+
* scope is the moment a machine that opted into a daemon actually acquires
|
|
170
|
+
* one. Two channels, in this order:
|
|
134
171
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
172
|
+
* 1. The `@failproofai/failproofaid-<os>-<arch>` platform package, which a
|
|
173
|
+
* plain `npm install failproofai` already brought down as an optional
|
|
174
|
+
* dependency. No network, so it is also the only channel that works
|
|
175
|
+
* air-gapped or behind a proxy that blocks github.com.
|
|
176
|
+
* 2. The GitHub Release asset for this exact version, checksum-verified.
|
|
177
|
+
* Covers installs that skipped optional dependencies, older tarballs, and
|
|
178
|
+
* anyone installing the daemon standalone.
|
|
139
179
|
*/
|
|
140
180
|
export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> {
|
|
141
181
|
const existing = resolveFailproofaidBinaryPath();
|
|
@@ -146,9 +186,18 @@ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reaso
|
|
|
146
186
|
return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` };
|
|
147
187
|
}
|
|
148
188
|
|
|
189
|
+
const fromNpm = installFromNpmPackage(key);
|
|
190
|
+
if (fromNpm.path) return { path: fromNpm.path };
|
|
191
|
+
|
|
149
192
|
const result = await downloadFailproofaidBinary(key);
|
|
150
193
|
if (result.path) return { path: result.path };
|
|
151
|
-
|
|
194
|
+
// Both channels are worth reporting: "not installed" alone reads as a broken
|
|
195
|
+
// package, and the download error alone hides that npm could have supplied it.
|
|
196
|
+
return {
|
|
197
|
+
reason: result.error
|
|
198
|
+
? `${result.error} (${fromNpm.error})`
|
|
199
|
+
: "failproofaid binary could not be installed",
|
|
200
|
+
};
|
|
152
201
|
}
|
|
153
202
|
|
|
154
203
|
/**
|
|
@@ -168,24 +217,209 @@ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reaso
|
|
|
168
217
|
* here, once, at install time, and handing it to the daemon via the
|
|
169
218
|
* environment closes that gap entirely.
|
|
170
219
|
*/
|
|
171
|
-
function resolveWorkerCommand(): string | null {
|
|
220
|
+
export function resolveWorkerCommand(): string | null {
|
|
172
221
|
if (process.env.FAILPROOFAI_WORKER_CMD) return process.env.FAILPROOFAI_WORKER_CMD;
|
|
173
222
|
|
|
174
223
|
const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
175
224
|
if (!packageRoot) return null;
|
|
176
225
|
const workerScript = resolve(packageRoot, "dist", "worker.mjs");
|
|
177
226
|
if (!existsSync(workerScript)) return null;
|
|
178
|
-
|
|
227
|
+
// Both paths are shell-quoted: the daemon runs this value through `sh -c`
|
|
228
|
+
// (`WorkerCommand::Shell` in crates/failproofaid/src/worker.rs), so an
|
|
229
|
+
// unquoted path splits on its spaces and the worker never starts. Not
|
|
230
|
+
// hypothetical on macOS, where `/Users/First Last/...` is ordinary — and
|
|
231
|
+
// more exposed since execPath replaced a bare `node`, because execPath is
|
|
232
|
+
// home-derived. systemd's own `Environment="..."` quoting does not help
|
|
233
|
+
// here; that protects the unit parse, not the later shell split.
|
|
234
|
+
//
|
|
235
|
+
// `process.execPath`, not a bare `node`. A system-scope service does not
|
|
236
|
+
// inherit a login environment, so its PATH is the system default — and the
|
|
237
|
+
// single most common way to install Node is nvm, which puts it under
|
|
238
|
+
// ~/.nvm/versions/node/*/bin and nowhere on that PATH. A bare `node` would
|
|
239
|
+
// resolve fine when the wizard runs it and then fail inside the service,
|
|
240
|
+
// silently, on exactly the machines least likely to notice. execPath is
|
|
241
|
+
// whatever runtime is executing this CLI right now (node for the published
|
|
242
|
+
// bin, bun in a source checkout), absolute either way.
|
|
243
|
+
return `${shellQuote(process.execPath)} ${shellQuote(workerScript)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolves the command the daemon should use to run a one-shot `failproofai`
|
|
248
|
+
* CLI task — today the scheduled audit — passed through as
|
|
249
|
+
* `FAILPROOFAI_CLI_CMD` in the service's own environment.
|
|
250
|
+
*
|
|
251
|
+
* Everything `resolveWorkerCommand` says about `process.execPath` and the
|
|
252
|
+
* shell quoting applies here verbatim, for the same reason: a system-scope
|
|
253
|
+
* unit gets no login environment, so its PATH is the system default, and the
|
|
254
|
+
* single most common Node install (nvm) is on no system PATH. The difference
|
|
255
|
+
* is the consequence of getting it wrong. A worker that cannot start makes the
|
|
256
|
+
* daemon visibly unhealthy; an audit that cannot start is SILENT — the config
|
|
257
|
+
* says the scan is on, nothing ever runs, and the only symptom is a dashboard
|
|
258
|
+
* that quietly stops moving.
|
|
259
|
+
*
|
|
260
|
+
* `dist/cli.mjs` (package.json's `bin.failproofai`), not `bin/failproofai.mjs`
|
|
261
|
+
* — the latter has a `#!/usr/bin/env bun` shebang and uses syntax node cannot
|
|
262
|
+
* load (a bare `import … from "../package.json"`, extensionless `.ts`
|
|
263
|
+
* specifiers). Only the bundle is node-runnable, and it sits beside the
|
|
264
|
+
* `dist/worker.mjs` the worker command already points at.
|
|
265
|
+
*/
|
|
266
|
+
export function resolveCliCommand(): string | null {
|
|
267
|
+
if (process.env.FAILPROOFAI_CLI_CMD) return process.env.FAILPROOFAI_CLI_CMD;
|
|
268
|
+
|
|
269
|
+
const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
270
|
+
if (!packageRoot) return null;
|
|
271
|
+
const cliScript = resolve(packageRoot, "dist", "cli.mjs");
|
|
272
|
+
if (!existsSync(cliScript)) return null;
|
|
273
|
+
return `${shellQuote(process.execPath)} ${shellQuote(cliScript)}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** POSIX single-quoting: everything is literal inside '…', and a literal
|
|
277
|
+
* quote is closed, escaped and reopened. */
|
|
278
|
+
function shellQuote(value: string): string {
|
|
279
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The raw, unquoted path to the worker script the service will run — the same
|
|
284
|
+
* file `resolveWorkerCommand` builds its shell command around.
|
|
285
|
+
*
|
|
286
|
+
* It exists separately because the unit needs the path as a PATH (for
|
|
287
|
+
* `ConditionPathExists=`), not as a shell word, and recovering one from the
|
|
288
|
+
* other means unparsing POSIX quoting for a value we already had.
|
|
289
|
+
*
|
|
290
|
+
* Returns null when `FAILPROOFAI_WORKER_CMD` is set: that value is an arbitrary
|
|
291
|
+
* shell command — a wrapper script, an interpreter with flags, `exec`ing
|
|
292
|
+
* something else entirely — and guessing which token in it is "the file that
|
|
293
|
+
* must exist" would gate the service on a path nobody promised. An absent
|
|
294
|
+
* condition is the correct answer to a question we cannot answer.
|
|
295
|
+
*/
|
|
296
|
+
export function workerScriptPath(): string | null {
|
|
297
|
+
if (process.env.FAILPROOFAI_WORKER_CMD) return null;
|
|
298
|
+
const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
299
|
+
if (!packageRoot) return null;
|
|
300
|
+
const workerScript = resolve(packageRoot, "dist", "worker.mjs");
|
|
301
|
+
return existsSync(workerScript) ? workerScript : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The environment the service definition carries, built once so the systemd
|
|
306
|
+
* and launchd renderers cannot drift apart — a variable added to one and not
|
|
307
|
+
* the other is invisible until somebody runs the other platform.
|
|
308
|
+
*
|
|
309
|
+
* Order is fixed rather than incidental: `upgradedServiceDefinition` rebuilds
|
|
310
|
+
* a definition from these renderers, so a stable order is what makes a
|
|
311
|
+
* rewritten unit byte-identical to a freshly installed one rather than merely
|
|
312
|
+
* equivalent — which is the difference between a diff a human can read and one
|
|
313
|
+
* they stop reading.
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Characters that cannot appear in a value interpolated into a systemd unit.
|
|
317
|
+
*
|
|
318
|
+
* A newline ENDS THE DIRECTIVE, so a path containing one injects arbitrary
|
|
319
|
+
* settings into a file that is installed root-owned at `/etc/systemd/system`
|
|
320
|
+
* and loaded at every boot. A double quote closes `Environment="..."` early and
|
|
321
|
+
* does the same; a backslash is systemd's own escape character inside a quoted
|
|
322
|
+
* value and makes the result unpredictable rather than merely wrong.
|
|
323
|
+
*
|
|
324
|
+
* The mechanism is demonstrated by this repo's own test, which sets
|
|
325
|
+
* `FAILPROOFAI_CLI_CMD` to `/usr/bin/true"\nUser=failproofai-no-such-user` and
|
|
326
|
+
* relies on systemd HONOURING the injected `User=` — it passes only because the
|
|
327
|
+
* injected user does not exist. A valid one (`User=root`, or an added
|
|
328
|
+
* `ExecStartPre=`) would have succeeded silently, undoing the
|
|
329
|
+
* "root-installed but never root-run" invariant the whole design rests on.
|
|
330
|
+
*
|
|
331
|
+
* These values are resolved paths and commands; none can legitimately contain
|
|
332
|
+
* any of these characters. So this REJECTS rather than escaping — a unit that
|
|
333
|
+
* refuses to be written is a clear failure, and inventing an escaping scheme
|
|
334
|
+
* for systemd's grammar is a larger surface than the problem.
|
|
335
|
+
*/
|
|
336
|
+
const UNIT_UNSAFE_RE = /["\\\r\n]/;
|
|
337
|
+
|
|
338
|
+
/** Throws when a value cannot be safely interpolated into a unit file. */
|
|
339
|
+
function assertUnitSafe(value: string, field: string): string {
|
|
340
|
+
if (UNIT_UNSAFE_RE.test(value)) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`refusing to write a service definition: ${field} contains a quote, backslash or newline ` +
|
|
343
|
+
`(${JSON.stringify(value)}), which would inject directives into a root-owned unit file`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return value;
|
|
179
347
|
}
|
|
180
348
|
|
|
181
|
-
function
|
|
349
|
+
function serviceEnvironment(workerCmd: string | null, cliCmd: string | null): [string, string][] {
|
|
350
|
+
const entries: [string, string][] = [];
|
|
351
|
+
if (workerCmd) entries.push(["FAILPROOFAI_WORKER_CMD", workerCmd]);
|
|
352
|
+
if (cliCmd) entries.push(["FAILPROOFAI_CLI_CMD", cliCmd]);
|
|
353
|
+
return entries;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The account the daemon runs as. The service is root-*installed* but never
|
|
358
|
+
* root-*run*: everything it touches (the socket, the lock, the policy config)
|
|
359
|
+
* lives in one user's home and is peer-checked against that user's uid.
|
|
360
|
+
*/
|
|
361
|
+
function serviceUser(): string {
|
|
362
|
+
return userInfo().username;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* `/etc/systemd/system/failproofaid@<user>.service`.
|
|
367
|
+
*
|
|
368
|
+
* The `@<user>` suffix is systemd's convention for a per-user instance, but
|
|
369
|
+
* this is a concrete unit file rather than an instance of a template: every
|
|
370
|
+
* field that matters is user-specific (the ExecStart path is under the user's
|
|
371
|
+
* own ~/.failproofai/bin, so is HOME, so is the worker command), so a shared
|
|
372
|
+
* template would need a per-instance drop-in for all of them and buy nothing.
|
|
373
|
+
* Naming it per-user is what keeps a second user's install from silently
|
|
374
|
+
* stealing the first's unit — a single `failproofaid.service` would.
|
|
375
|
+
*/
|
|
376
|
+
function systemdUnitName(user: string = serviceUser()): string {
|
|
377
|
+
return `failproofaid@${user}.service`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function systemdUnitPath(user: string = serviceUser()): string {
|
|
381
|
+
return resolve("/etc/systemd/system", systemdUnitName(user));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The pre-1.0.0-beta.1 user-scope unit. Still removed on install and
|
|
386
|
+
* uninstall: it holds the same flock the new service needs, so leaving one
|
|
387
|
+
* behind means the system unit starts, loses the singleton race, and the
|
|
388
|
+
* machine sits fail-closed against a daemon that never came up.
|
|
389
|
+
*/
|
|
390
|
+
function legacySystemdUserUnitPath(): string {
|
|
182
391
|
return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service");
|
|
183
392
|
}
|
|
184
393
|
|
|
185
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Per user, for the same reason the systemd unit is: a LaunchDaemon's
|
|
396
|
+
* contents are entirely user-specific — `UserName`, the ExecStart path under
|
|
397
|
+
* that user's `~/.failproofai/bin`, the log paths under that user's home — so
|
|
398
|
+
* one shared label means the second user's install silently overwrites the
|
|
399
|
+
* first's daemon, and their uninstall deletes it.
|
|
400
|
+
*/
|
|
401
|
+
function launchdLabel(user: string = serviceUser()): string {
|
|
402
|
+
return `ai.failproof.failproofaid.${user}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function launchdPlistPath(user: string = serviceUser()): string {
|
|
406
|
+
return `/Library/LaunchDaemons/${launchdLabel(user)}.plist`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The pre-1.0.0-beta.1 LaunchAgent. */
|
|
410
|
+
function legacyLaunchAgentPlistPath(): string {
|
|
186
411
|
return resolve(homedir(), "Library", "LaunchAgents", "ai.failproof.failproofaid.plist");
|
|
187
412
|
}
|
|
188
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The 1.0.0-beta.1 LaunchDaemon, before the label was namespaced. Removed on
|
|
416
|
+
* install like the LaunchAgent is: it holds the same singleton flock, so
|
|
417
|
+
* leaving it would leave two daemons racing for one socket.
|
|
418
|
+
*/
|
|
419
|
+
function legacySharedLaunchDaemonPath(): string {
|
|
420
|
+
return "/Library/LaunchDaemons/ai.failproof.failproofaid.plist";
|
|
421
|
+
}
|
|
422
|
+
|
|
189
423
|
/**
|
|
190
424
|
* The service-definition file `installDaemonService` would write on this
|
|
191
425
|
* platform (the systemd unit or the launchd plist) — exposed so the config
|
|
@@ -197,23 +431,260 @@ export function daemonServiceFilePath(): string | null {
|
|
|
197
431
|
return process.platform === "linux" ? systemdUnitPath() : launchdPlistPath();
|
|
198
432
|
}
|
|
199
433
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
434
|
+
/**
|
|
435
|
+
* The command a user runs to inspect their own daemon — surfaced so the
|
|
436
|
+
* wizard can print it instead of leaving people to discover which service
|
|
437
|
+
* manager, and which scope, is involved.
|
|
438
|
+
*/
|
|
439
|
+
export function daemonStatusCommand(): string | null {
|
|
440
|
+
if (!isDaemonSupportedPlatform()) return null;
|
|
441
|
+
return process.platform === "linux"
|
|
442
|
+
? `systemctl status ${systemdUnitName()}`
|
|
443
|
+
: `sudo launchctl print system/${launchdLabel()}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The command that restarts the daemon, for the places where a config change
|
|
448
|
+
* only takes effect on the next start.
|
|
449
|
+
*
|
|
450
|
+
* `--disconnect` is the motivating case: the collector manager starts once for
|
|
451
|
+
* the daemon's lifetime and the uploader caches its bearer key at construction,
|
|
452
|
+
* so removing the credential file changes nothing about the process already
|
|
453
|
+
* running.
|
|
454
|
+
*/
|
|
455
|
+
export function daemonRestartCommand(): string | null {
|
|
456
|
+
if (!isDaemonSupportedPlatform()) return null;
|
|
457
|
+
return process.platform === "linux"
|
|
458
|
+
? `sudo systemctl restart ${systemdUnitName()}`
|
|
459
|
+
: `sudo launchctl kickstart -k system/${launchdLabel()}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Acquires sudo credentials up front, prompting in the terminal if needed.
|
|
464
|
+
*
|
|
465
|
+
* `sudo -v` refreshes the user's sudo timestamp and returns; every later
|
|
466
|
+
* `sudo -n` in this run then succeeds against that cached credential. Doing it
|
|
467
|
+
* here, once, at a known point in the wizard is what makes the rest of the
|
|
468
|
+
* install non-interactive — the alternative, an interactive sudo firing from
|
|
469
|
+
* underneath a half-drawn TUI screen, is a password prompt the user cannot
|
|
470
|
+
* see.
|
|
471
|
+
*
|
|
472
|
+
* NOT the same as running the whole CLI under sudo, which is actively wrong:
|
|
473
|
+
* `homedir()` would become /root, so the hooks, the policy config, the
|
|
474
|
+
* downloaded binary and the unit's own `User=` would all be root's rather
|
|
475
|
+
* than the user's, quietly configuring the wrong account.
|
|
476
|
+
*
|
|
477
|
+
* `stdio: "inherit"` so the prompt and the typed password go straight to the
|
|
478
|
+
* real terminal. The timeout is generous because a human is typing.
|
|
479
|
+
*/
|
|
480
|
+
export function primeElevation(): boolean {
|
|
481
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return true;
|
|
482
|
+
if (canElevate()) return true; // NOPASSWD, or already primed
|
|
483
|
+
try {
|
|
484
|
+
execFileSync("sudo", ["-v"], { stdio: "inherit", timeout: SUDO_PROMPT_TIMEOUT_MS });
|
|
485
|
+
return canElevate();
|
|
486
|
+
} catch {
|
|
487
|
+
// Wrong password, no sudo rights, or the user pressed ctrl-C. All of them
|
|
488
|
+
// mean the same thing here: carry on without a daemon.
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* True when privileged commands can run without prompting for a password.
|
|
495
|
+
*
|
|
496
|
+
* Exported because onboarding re-checks it: `needs_root` is the most common
|
|
497
|
+
* reason setup aborts, and it is the one most likely to stop being true (the
|
|
498
|
+
* user gets sudo rights, or primes their timestamp in another terminal). One
|
|
499
|
+
* `sudo -n true`, no prompt, milliseconds — and never on the hook path, which
|
|
500
|
+
* does not reach the first-run gate at all.
|
|
501
|
+
*/
|
|
502
|
+
export function canElevate(): boolean {
|
|
503
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return true;
|
|
504
|
+
try {
|
|
505
|
+
execFileSync("sudo", ["-n", "true"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
|
|
506
|
+
return true;
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Runs one privileged command, non-interactively.
|
|
514
|
+
*
|
|
515
|
+
* `sudo -n` on purpose: the wizard owns the terminal, and a sudo password
|
|
516
|
+
* prompt fired from underneath a TUI is unreadable at best. A machine that
|
|
517
|
+
* cannot elevate silently gets a clear reason and the manual commands
|
|
518
|
+
* instead — it keeps working exactly as it did, on the in-process path.
|
|
519
|
+
*/
|
|
520
|
+
function runPrivileged(command: string, args: string[]): void {
|
|
521
|
+
const root = typeof process.getuid === "function" && process.getuid() === 0;
|
|
522
|
+
const [cmd, argv] = root ? [command, args] : ["sudo", ["-n", command, ...args]];
|
|
523
|
+
execFileSync(cmd, argv, { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Installs a file into a root-owned location via a temp file, because the
|
|
528
|
+
* caller is not root and cannot write there directly. `install -m` sets the
|
|
529
|
+
* mode in the same step, so the file is never briefly world-writable.
|
|
530
|
+
*/
|
|
531
|
+
function writePrivilegedFile(destination: string, contents: string, mode = "0644"): void {
|
|
532
|
+
// mkdtempSync, not a name built from pid + timestamp in the shared tmpdir.
|
|
533
|
+
// That name is guessable, and `writeFileSync` follows a symlink already
|
|
534
|
+
// sitting at the path — so on a multi-user box another local user could
|
|
535
|
+
// pre-create it and have `install` copy content they control into
|
|
536
|
+
// /etc/systemd/system as root. mkdtemp gives a 0700 directory with a name
|
|
537
|
+
// they cannot predict, so there is nothing to pre-create.
|
|
538
|
+
const stagingDir = mkdtempSync(resolve(tmpdir(), "failproofaid-stage-"));
|
|
539
|
+
const staging = resolve(stagingDir, "service-definition");
|
|
540
|
+
try {
|
|
541
|
+
writeFileSync(staging, contents, "utf8");
|
|
542
|
+
runPrivileged("install", ["-m", mode, staging, destination]);
|
|
543
|
+
} finally {
|
|
544
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Removes a daemon installed by an earlier version into the user's own
|
|
550
|
+
* session scope. Best-effort and never privileged — these paths are all
|
|
551
|
+
* inside the user's home.
|
|
552
|
+
*/
|
|
553
|
+
function removeLegacyUserService(): void {
|
|
554
|
+
try {
|
|
555
|
+
if (process.platform === "linux") {
|
|
556
|
+
const legacy = legacySystemdUserUnitPath();
|
|
557
|
+
if (!existsSync(legacy)) return;
|
|
558
|
+
try {
|
|
559
|
+
execFileSync("systemctl", ["--user", "disable", "--now", "failproofaid.service"], {
|
|
560
|
+
stdio: "ignore",
|
|
561
|
+
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
562
|
+
});
|
|
563
|
+
} catch {
|
|
564
|
+
// Not loaded — removing the file is still the point.
|
|
565
|
+
}
|
|
566
|
+
unlinkSync(legacy);
|
|
567
|
+
try {
|
|
568
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], {
|
|
569
|
+
stdio: "ignore",
|
|
570
|
+
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
571
|
+
});
|
|
572
|
+
} catch {
|
|
573
|
+
/* best-effort */
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
const legacyAgent = legacyLaunchAgentPlistPath();
|
|
577
|
+
if (existsSync(legacyAgent)) {
|
|
578
|
+
try {
|
|
579
|
+
execFileSync("launchctl", ["unload", "-w", legacyAgent], {
|
|
580
|
+
stdio: "ignore",
|
|
581
|
+
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
582
|
+
});
|
|
583
|
+
} catch {
|
|
584
|
+
/* not loaded */
|
|
585
|
+
}
|
|
586
|
+
unlinkSync(legacyAgent);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The 1.0.0-beta.1 daemon, before the label was per-user. Privileged,
|
|
590
|
+
// and only reachable on a path that has already elevated.
|
|
591
|
+
const legacyDaemon = legacySharedLaunchDaemonPath();
|
|
592
|
+
if (existsSync(legacyDaemon)) {
|
|
593
|
+
try {
|
|
594
|
+
runPrivileged("launchctl", ["unload", "-w", legacyDaemon]);
|
|
595
|
+
} catch {
|
|
596
|
+
/* not loaded */
|
|
597
|
+
}
|
|
598
|
+
runPrivileged("rm", ["-f", legacyDaemon]);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
hookLogWarn(`could not remove the legacy user-scope daemon: ${err instanceof Error ? err.message : String(err)}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function systemdUnitContents(
|
|
607
|
+
binaryPath: string,
|
|
608
|
+
workerCmd: string | null,
|
|
609
|
+
cliCmd: string | null = null,
|
|
610
|
+
): string {
|
|
611
|
+
// Quoted because every value here contains a space or a path — systemd's
|
|
612
|
+
// Environment= requires quoting whenever the value does.
|
|
613
|
+
const envLines = serviceEnvironment(workerCmd, cliCmd)
|
|
614
|
+
.map(([key, value]) => `Environment="${key}=${assertUnitSafe(value, key)}"\n`)
|
|
615
|
+
.join("");
|
|
616
|
+
const user = assertUnitSafe(serviceUser(), "User");
|
|
617
|
+
assertUnitSafe(binaryPath, "ExecStart");
|
|
618
|
+
assertUnitSafe(homedir(), "HOME");
|
|
619
|
+
|
|
620
|
+
// Gate the unit on the two files it cannot run without, so that an install
|
|
621
|
+
// which is no longer there STOPS rather than thrashes.
|
|
622
|
+
//
|
|
623
|
+
// `npm rm -g failproofai` is the case this is for, and npm runs no uninstall
|
|
624
|
+
// script — see the note on `failproofai uninstall`. It deletes the package,
|
|
625
|
+
// which takes `dist/worker.mjs` with it, while the daemon binary under
|
|
626
|
+
// ~/.failproofai survives. Without a condition systemd keeps a daemon alive
|
|
627
|
+
// whose worker cannot spawn; with a deleted BINARY it is worse, because
|
|
628
|
+
// ExecStart fails 203/EXEC under `Restart=on-failure` and cycles until it
|
|
629
|
+
// trips the start-limit and latches into "start request repeated too
|
|
630
|
+
// quickly" — a state that then refuses a legitimate restart later.
|
|
631
|
+
//
|
|
632
|
+
// A failed condition is not a failure: systemd SKIPS the job, leaves the unit
|
|
633
|
+
// inactive, and `systemctl status` names the exact path that was missing.
|
|
634
|
+
// That turns an unexplained crash-loop into a one-line diagnosis, and
|
|
635
|
+
// `daemonServiceStatus()` reads it back as `condition-failed` so the next CLI
|
|
636
|
+
// command can clear `daemonConfigured` instead of leaving the machine denying
|
|
637
|
+
// every tool call.
|
|
638
|
+
//
|
|
639
|
+
// This deliberately does NOT soften the hook path's fail-closed deny. A
|
|
640
|
+
// machine that was configured to require the daemon still denies while the
|
|
641
|
+
// daemon is absent — being skipped by systemd is not consent to stop
|
|
642
|
+
// enforcing. It shortens how long that lasts and explains why.
|
|
643
|
+
// Only paths that EXIST right now are gated on, and that filter is load-
|
|
644
|
+
// bearing rather than belt-and-braces.
|
|
645
|
+
//
|
|
646
|
+
// `binaryPath` is an ExecStart value, not necessarily a bare path: systemd
|
|
647
|
+
// accepts arguments there, and `FAILPROOFAI_DAEMON_BINARY` is documented as
|
|
648
|
+
// "someone named a binary explicitly" — this repo's own systemd tests set it
|
|
649
|
+
// to `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so
|
|
650
|
+
// gating on that string looks for a file literally named "sleep infinity",
|
|
651
|
+
// never finds it, and skips a unit that would have run perfectly. Splitting
|
|
652
|
+
// on whitespace to recover the binary is worse, because a path may legally
|
|
653
|
+
// contain spaces and there is no way to tell the two apart from here.
|
|
654
|
+
//
|
|
655
|
+
// Existence at render time answers it without guessing: a real binary is on
|
|
656
|
+
// disk when its unit is written (installDaemonService just put it there), and
|
|
657
|
+
// a command-with-arguments is not. Same rule the worker script already
|
|
658
|
+
// follows — never gate on a path that is not there, or the freshly installed
|
|
659
|
+
// unit skips on its very first start.
|
|
660
|
+
const conditionPaths = [binaryPath, workerScriptPath()].filter(
|
|
661
|
+
(p): p is string => typeof p === "string" && existsSync(p),
|
|
662
|
+
);
|
|
663
|
+
const conditionLines = conditionPaths
|
|
664
|
+
.map((p) => `ConditionPathExists=${assertUnitSafe(p, "ConditionPathExists")}\n`)
|
|
665
|
+
.join("");
|
|
666
|
+
|
|
205
667
|
return `[Unit]
|
|
206
|
-
Description=failproofai background daemon (failproofaid)
|
|
668
|
+
Description=failproofai background daemon (failproofaid) for ${user}
|
|
207
669
|
After=network.target
|
|
208
|
-
|
|
670
|
+
${conditionLines}
|
|
209
671
|
[Service]
|
|
210
672
|
Type=simple
|
|
211
|
-
|
|
673
|
+
User=${user}
|
|
674
|
+
# Set explicitly rather than relying on systemd deriving it from User=:
|
|
675
|
+
# failproofaid is user-scope by construction and refuses to start without
|
|
676
|
+
# HOME ("HOME is not set; failproofaid is user-scope only"), so the one
|
|
677
|
+
# variable it cannot do without is not left to a version-dependent default.
|
|
678
|
+
Environment="HOME=${homedir()}"
|
|
679
|
+
${envLines}ExecStart=${binaryPath}
|
|
212
680
|
Restart=on-failure
|
|
213
681
|
RestartSec=2
|
|
214
682
|
|
|
215
683
|
[Install]
|
|
216
|
-
|
|
684
|
+
# multi-user.target, not default.target: this is the whole point of a
|
|
685
|
+
# system unit — it starts at boot, with no login and no lingering, and
|
|
686
|
+
# keeps running after the installing user logs out.
|
|
687
|
+
WantedBy=multi-user.target
|
|
217
688
|
`;
|
|
218
689
|
}
|
|
219
690
|
|
|
@@ -221,12 +692,27 @@ function escapeXml(s: string): string {
|
|
|
221
692
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
222
693
|
}
|
|
223
694
|
|
|
224
|
-
function
|
|
225
|
-
|
|
695
|
+
function unescapeXml(s: string): string {
|
|
696
|
+
// & last, or an escaped `&lt;` would come back as `<`.
|
|
697
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Exported for the same reason `systemdUnitContents` is: it is the only way to
|
|
702
|
+
* assert the plist's shape from a Linux CI runner, and this is the half of the
|
|
703
|
+
* pair no Linux test run can otherwise reach.
|
|
704
|
+
*/
|
|
705
|
+
export function launchdPlistContents(
|
|
706
|
+
binaryPath: string,
|
|
707
|
+
logDir: string,
|
|
708
|
+
workerCmd: string | null,
|
|
709
|
+
cliCmd: string | null = null,
|
|
710
|
+
): string {
|
|
711
|
+
const env = serviceEnvironment(workerCmd, cliCmd);
|
|
712
|
+
const envBlock = env.length
|
|
226
713
|
? ` <key>EnvironmentVariables</key>
|
|
227
714
|
<dict>
|
|
228
|
-
<key
|
|
229
|
-
<string>${escapeXml(workerCmd)}</string>
|
|
715
|
+
${env.map(([key, value]) => ` <key>${key}</key>\n <string>${escapeXml(value)}</string>`).join("\n")}
|
|
230
716
|
</dict>
|
|
231
717
|
`
|
|
232
718
|
: "";
|
|
@@ -235,7 +721,7 @@ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: str
|
|
|
235
721
|
<plist version="1.0">
|
|
236
722
|
<dict>
|
|
237
723
|
<key>Label</key>
|
|
238
|
-
<string
|
|
724
|
+
<string>${escapeXml(launchdLabel())}</string>
|
|
239
725
|
<key>ProgramArguments</key>
|
|
240
726
|
<array>
|
|
241
727
|
<string>${escapeXml(binaryPath)}</string>
|
|
@@ -244,6 +730,12 @@ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: str
|
|
|
244
730
|
<true/>
|
|
245
731
|
<key>KeepAlive</key>
|
|
246
732
|
<true/>
|
|
733
|
+
<!-- A LaunchDaemon runs as root unless told otherwise. This is the
|
|
734
|
+
launchd half of the systemd unit's User=: loaded at boot by the
|
|
735
|
+
system, executed as the one user whose home, socket and lock it
|
|
736
|
+
is allowed to touch. -->
|
|
737
|
+
<key>UserName</key>
|
|
738
|
+
<string>${escapeXml(serviceUser())}</string>
|
|
247
739
|
${envBlock} <key>StandardOutPath</key>
|
|
248
740
|
<string>${escapeXml(resolve(logDir, "failproofaid.log"))}</string>
|
|
249
741
|
<key>StandardErrorPath</key>
|
|
@@ -258,6 +750,29 @@ export interface DaemonInstallResult {
|
|
|
258
750
|
reason?: string;
|
|
259
751
|
}
|
|
260
752
|
|
|
753
|
+
/**
|
|
754
|
+
* The privileged commands an install performs — returned verbatim so a
|
|
755
|
+
* machine that cannot elevate can be told exactly what to run rather than
|
|
756
|
+
* just that something failed.
|
|
757
|
+
*/
|
|
758
|
+
function daemonInstallCommands(
|
|
759
|
+
binaryPath: string,
|
|
760
|
+
workerCmd: string | null,
|
|
761
|
+
cliCmd: string | null,
|
|
762
|
+
): string[] {
|
|
763
|
+
if (process.platform === "linux") {
|
|
764
|
+
return [
|
|
765
|
+
`sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd, cliCmd)}EOF`,
|
|
766
|
+
"sudo systemctl daemon-reload",
|
|
767
|
+
`sudo systemctl enable --now ${systemdUnitName()}`,
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
return [
|
|
771
|
+
`sudo tee ${launchdPlistPath()} < the plist failproofai config would write`,
|
|
772
|
+
`sudo launchctl load -w ${launchdPlistPath()}`,
|
|
773
|
+
];
|
|
774
|
+
}
|
|
775
|
+
|
|
261
776
|
/**
|
|
262
777
|
* Writes and enables the service unit, starting it immediately. Safe to
|
|
263
778
|
* call repeatedly — re-running replaces the unit file (picking up a
|
|
@@ -283,41 +798,51 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
|
|
|
283
798
|
// environment is what makes a *service-managed* daemon actually find its
|
|
284
799
|
// worker regardless of what cwd the service manager starts it from.
|
|
285
800
|
const workerCmd = resolveWorkerCommand();
|
|
801
|
+
// Same best-effort footing as the worker command, and null is survivable in
|
|
802
|
+
// the same way — the daemon just has no way to run an audit. Unlike the
|
|
803
|
+
// worker, there is no relative-path fallback to degrade to, which is exactly
|
|
804
|
+
// why it is resolved here where FAILPROOFAI_PACKAGE_ROOT is reliably set.
|
|
805
|
+
const cliCmd = resolveCliCommand();
|
|
806
|
+
|
|
807
|
+
// The service is installed system-wide, which needs root. Check before
|
|
808
|
+
// writing anything, so a machine that cannot elevate gets the exact
|
|
809
|
+
// commands to run instead of a half-installed service.
|
|
810
|
+
if (!canElevate()) {
|
|
811
|
+
return {
|
|
812
|
+
installed: false,
|
|
813
|
+
reason:
|
|
814
|
+
"root privileges are required to install the failproofaid system service, and sudo credentials were not available. " +
|
|
815
|
+
"Re-run `failproofai config` and approve the sudo prompt — do NOT run the CLI itself under sudo, which would " +
|
|
816
|
+
"configure root's account instead of yours. To install by hand: " +
|
|
817
|
+
daemonInstallCommands(binaryPath, workerCmd, cliCmd).join(" && "),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// A daemon left over from a pre-1.0.0-beta.1 install holds the same
|
|
822
|
+
// singleton lock the new one needs, so the system unit would start, lose
|
|
823
|
+
// the flock race, and leave the machine fail-closed against a daemon that
|
|
824
|
+
// never came up. Clear it first, every time.
|
|
825
|
+
removeLegacyUserService();
|
|
286
826
|
|
|
287
827
|
try {
|
|
288
828
|
if (process.platform === "linux") {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
execFileSync("systemctl", ["--user", "daemon-reload"], {
|
|
293
|
-
stdio: "ignore",
|
|
294
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
295
|
-
});
|
|
296
|
-
execFileSync("systemctl", ["--user", "enable", "--now", "failproofaid.service"], {
|
|
297
|
-
stdio: "ignore",
|
|
298
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
299
|
-
});
|
|
829
|
+
writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd, cliCmd));
|
|
830
|
+
runPrivileged("systemctl", ["daemon-reload"]);
|
|
831
|
+
runPrivileged("systemctl", ["enable", "--now", systemdUnitName()]);
|
|
300
832
|
} else {
|
|
301
833
|
const plistPath = launchdPlistPath();
|
|
302
|
-
const logDir =
|
|
303
|
-
mkdirSync(dirname(plistPath), { recursive: true });
|
|
834
|
+
const logDir = logsDir();
|
|
304
835
|
mkdirSync(logDir, { recursive: true });
|
|
305
|
-
writeFileSync(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd), "utf8");
|
|
306
836
|
// Unload any previously-loaded copy first — reloading with a changed
|
|
307
837
|
// binary path (e.g. after an upgrade) is a no-op under plain `load`
|
|
308
838
|
// if launchd thinks the label is already loaded.
|
|
309
839
|
try {
|
|
310
|
-
|
|
311
|
-
stdio: "ignore",
|
|
312
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
313
|
-
});
|
|
840
|
+
runPrivileged("launchctl", ["unload", plistPath]);
|
|
314
841
|
} catch {
|
|
315
842
|
// Wasn't loaded — fine, this is the common case on a fresh install.
|
|
316
843
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
320
|
-
});
|
|
844
|
+
writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd));
|
|
845
|
+
runPrivileged("launchctl", ["load", "-w", plistPath]);
|
|
321
846
|
}
|
|
322
847
|
} catch (err) {
|
|
323
848
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -337,9 +862,134 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
|
|
|
337
862
|
hookLogWarn(`daemon service install failed: ${reason}`);
|
|
338
863
|
return { installed: false, reason };
|
|
339
864
|
}
|
|
865
|
+
|
|
866
|
+
// NOTE: this reports that the SERVICE is installed and running, which is all
|
|
867
|
+
// it can honestly claim. Whether the daemon can actually evaluate a hook is a
|
|
868
|
+
// separate question, answered by `probeDaemonEndToEnd()` — and the caller
|
|
869
|
+
// must ask it before setting `daemonConfigured`, because that flag is what
|
|
870
|
+
// makes an unanswering daemon deny every tool call. `configure-wizard.ts`
|
|
871
|
+
// does exactly that. Keeping the two apart matters: "the unit was written and
|
|
872
|
+
// systemd started it" is testable against a stub binary, and folding the
|
|
873
|
+
// probe in here would mean this function could never be tested without a real
|
|
874
|
+
// failproofaid.
|
|
340
875
|
return { installed: true };
|
|
341
876
|
}
|
|
342
877
|
|
|
878
|
+
/**
|
|
879
|
+
* How long the health probe waits for the daemon to answer a real hook.
|
|
880
|
+
*
|
|
881
|
+
* Far below the hook path's 30s: this runs from an interactive command, and
|
|
882
|
+
* the cold start it is most likely to be waiting on (`worker.rs` pre-warms the
|
|
883
|
+
* worker, ~700ms of Node startup) is comfortably inside it. A daemon that
|
|
884
|
+
* cannot answer a trivial event in this long is not one to hand a machine's
|
|
885
|
+
* fail-closed enforcement to.
|
|
886
|
+
*/
|
|
887
|
+
const DAEMON_PROBE_TIMEOUT_MS = 5_000;
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* How long the probe keeps waiting for the socket to come up before calling the
|
|
891
|
+
* daemon unreachable. Generous on purpose: this runs once, from an interactive
|
|
892
|
+
* setup command, and the cost of being impatient is aborting setup at a machine
|
|
893
|
+
* whose daemon is merely still starting.
|
|
894
|
+
*/
|
|
895
|
+
const DAEMON_PROBE_READY_TIMEOUT_MS = 10_000;
|
|
896
|
+
const DAEMON_PROBE_RETRY_MS = 150;
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Ask the daemon to evaluate a real hook, end to end.
|
|
900
|
+
*
|
|
901
|
+
* The thing this catches that nothing else did: a unit that is *running* while
|
|
902
|
+
* the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
|
|
903
|
+
* and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
|
|
904
|
+
* systemd reports as perfectly active and a worker that dies on every spawn.
|
|
905
|
+
* Every existing check passes that machine — `waitForDaemonRunning()` asks the
|
|
906
|
+
* service manager, `Ping` is answered in `server.rs` without ever touching the
|
|
907
|
+
* worker, a null `resolveWorkerCommand()` is treated as best-effort, and
|
|
908
|
+
* `Worker::warm()` swallows its own failure. `daemonConfigured` then gets set,
|
|
909
|
+
* and because that flag makes every hook fail closed, the machine denies every
|
|
910
|
+
* tool call across all 12 CLIs — including `UserPromptSubmit`, so the user
|
|
911
|
+
* cannot even talk to their agent to ask why.
|
|
912
|
+
*
|
|
913
|
+
* `SessionStart` rather than a tool event: it traverses the identical path
|
|
914
|
+
* (socket → daemon → worker → `evaluateHookEvent`) but describes nothing a
|
|
915
|
+
* policy would deny and records no tool decision, so probing cannot itself
|
|
916
|
+
* change what the machine does.
|
|
917
|
+
*/
|
|
918
|
+
export type DaemonProbe =
|
|
919
|
+
| { ok: true }
|
|
920
|
+
| {
|
|
921
|
+
ok: false;
|
|
922
|
+
/**
|
|
923
|
+
* `unreachable` — nothing ever accepted a connection on the socket.
|
|
924
|
+
* `worker` — the daemon accepted a connection but could not answer a
|
|
925
|
+
* hook, which is the worker failing to run.
|
|
926
|
+
*
|
|
927
|
+
* Kept apart because the remedies are different, and because reporting
|
|
928
|
+
* "your worker will not start" at someone whose worker is fine sends them
|
|
929
|
+
* to inspect a healthy process. `DaemonFailure` cannot make this
|
|
930
|
+
* distinction: it reports `unreachable` for a refused connection AND for
|
|
931
|
+
* a request that was accepted and never answered.
|
|
932
|
+
*/
|
|
933
|
+
reason: "unreachable" | "worker";
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Ask the daemon to evaluate a real hook, end to end.
|
|
938
|
+
*
|
|
939
|
+
* The thing this catches that nothing else did: a unit that is *running* while
|
|
940
|
+
* the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
|
|
941
|
+
* and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
|
|
942
|
+
* systemd reports as perfectly active and a worker that dies on every spawn.
|
|
943
|
+
*
|
|
944
|
+
* **Why this retries.** It is called moments after `systemctl enable --now`, and
|
|
945
|
+
* a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before
|
|
946
|
+
* the daemon has bound its socket. A single attempt therefore raced the bind
|
|
947
|
+
* and, because the hook path's connect budget is deliberately 150ms, lost that
|
|
948
|
+
* race on any loaded machine. Setup then aborted with "its worker process could
|
|
949
|
+
* not be run" at a daemon that was seconds away from serving happily — the
|
|
950
|
+
* worker had already logged that it was listening. Waiting for the socket is
|
|
951
|
+
* the fix; relaxing the 150ms is NOT, because that budget is what keeps a dead
|
|
952
|
+
* daemon from adding latency to every tool call on the hook path.
|
|
953
|
+
*/
|
|
954
|
+
export async function probeDaemon(): Promise<DaemonProbe> {
|
|
955
|
+
try {
|
|
956
|
+
const { attemptDaemonHook, daemonAcceptsConnections } = await import("./daemon-client");
|
|
957
|
+
const deadline = Date.now() + DAEMON_PROBE_READY_TIMEOUT_MS;
|
|
958
|
+
let everConnected = false;
|
|
959
|
+
|
|
960
|
+
for (;;) {
|
|
961
|
+
if (await daemonAcceptsConnections()) {
|
|
962
|
+
everConnected = true;
|
|
963
|
+
const attempt = await attemptDaemonHook(
|
|
964
|
+
{
|
|
965
|
+
hookEvent: "SessionStart",
|
|
966
|
+
cli: "claude",
|
|
967
|
+
stdin: JSON.stringify({
|
|
968
|
+
hook_event_name: "SessionStart",
|
|
969
|
+
source: "failproofai-health-probe",
|
|
970
|
+
}),
|
|
971
|
+
},
|
|
972
|
+
{ responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
|
|
973
|
+
);
|
|
974
|
+
// A protocol mismatch is a REACHABLE daemon of the wrong vintage, which
|
|
975
|
+
// is a version problem rather than the lockout this probe exists to
|
|
976
|
+
// find. It is reported, and acted on, elsewhere.
|
|
977
|
+
if (attempt.ok || attempt.failure === "protocol-mismatch") return { ok: true };
|
|
978
|
+
}
|
|
979
|
+
if (Date.now() >= deadline) break;
|
|
980
|
+
await new Promise((r) => setTimeout(r, DAEMON_PROBE_RETRY_MS));
|
|
981
|
+
}
|
|
982
|
+
return { ok: false, reason: everConnected ? "worker" : "unreachable" };
|
|
983
|
+
} catch {
|
|
984
|
+
return { ok: false, reason: "unreachable" };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Boolean form, for callers that only branch on healthy/not. */
|
|
989
|
+
export async function probeDaemonEndToEnd(): Promise<boolean> {
|
|
990
|
+
return (await probeDaemon()).ok;
|
|
991
|
+
}
|
|
992
|
+
|
|
343
993
|
/**
|
|
344
994
|
* Waits for the service to report running, then re-checks after a settle
|
|
345
995
|
* window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
|
|
@@ -356,6 +1006,353 @@ async function waitForDaemonRunning(): Promise<boolean> {
|
|
|
356
1006
|
return daemonServiceStatus() === "running";
|
|
357
1007
|
}
|
|
358
1008
|
|
|
1009
|
+
// ── Upgrading a service definition that predates a variable ──────────────────
|
|
1010
|
+
//
|
|
1011
|
+
// A machine that installed the daemon before `FAILPROOFAI_CLI_CMD` existed
|
|
1012
|
+
// keeps its old unit forever: `npm i -g failproofai@latest` replaces the CLI
|
|
1013
|
+
// and never touches /etc/systemd/system, and the wizard's own "already
|
|
1014
|
+
// installed and running — leaving it alone" branch skips it too. The daemon
|
|
1015
|
+
// then has no way to spawn an audit while `config.toml` says the scheduled
|
|
1016
|
+
// scan is on, and nothing anywhere reports a fault. This is the single most
|
|
1017
|
+
// likely way the whole feature ends up dead on real machines, so it gets an
|
|
1018
|
+
// explicit detect-and-rewrite rather than an assumption that reinstalls happen.
|
|
1019
|
+
|
|
1020
|
+
/** Reads the installed unit / plist, or null when no service is installed. */
|
|
1021
|
+
function readInstalledServiceDefinition(): string | null {
|
|
1022
|
+
if (!isDaemonSupportedPlatform()) return null;
|
|
1023
|
+
const path = daemonServiceFilePath();
|
|
1024
|
+
if (!path || !existsSync(path)) return null;
|
|
1025
|
+
try {
|
|
1026
|
+
return readFileSync(path, "utf8");
|
|
1027
|
+
} catch {
|
|
1028
|
+
// Unreadable is not "absent": claiming no service is installed would let a
|
|
1029
|
+
// caller reinstall over something it cannot see. Treated as "nothing to
|
|
1030
|
+
// upgrade" by the one caller below, which is the inert direction.
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Reads one variable back out of an installed service definition.
|
|
1037
|
+
*
|
|
1038
|
+
* Used to carry values FORWARD across a rewrite. A rewrite resolves each
|
|
1039
|
+
* command from this process, and a resolution that comes back null (no
|
|
1040
|
+
* `FAILPROOFAI_PACKAGE_ROOT`, a `dist/` that moved) must never be allowed to
|
|
1041
|
+
* silently DELETE a working line from a working unit — the rewrite exists to
|
|
1042
|
+
* add a variable, not to drop one.
|
|
1043
|
+
*/
|
|
1044
|
+
function installedEnvValue(definition: string, name: string): string | null {
|
|
1045
|
+
if (process.platform === "linux") {
|
|
1046
|
+
const m = new RegExp(`^Environment="${name}=(.*)"$`, "m").exec(definition);
|
|
1047
|
+
return m ? m[1] : null;
|
|
1048
|
+
}
|
|
1049
|
+
const m = new RegExp(`<key>${name}</key>\\s*<string>([^<]*)</string>`).exec(definition);
|
|
1050
|
+
return m ? unescapeXml(m[1]) : null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* The binary an installed definition currently starts.
|
|
1055
|
+
*
|
|
1056
|
+
* Preserved across a rewrite rather than re-resolved, because the two disagree
|
|
1057
|
+
* in exactly the upgrade case this is for: `installedBinaryPath()` is
|
|
1058
|
+
* version-stamped (`failproofaid-<version>`), so right after a CLI upgrade the
|
|
1059
|
+
* binary for the NEW version is not on disk yet and resolution returns null,
|
|
1060
|
+
* while the unit still points at the older binary that is there and running.
|
|
1061
|
+
* Re-resolving would either refuse to rewrite or repoint a live service at a
|
|
1062
|
+
* file that does not exist.
|
|
1063
|
+
*/
|
|
1064
|
+
function installedExecStart(definition: string): string | null {
|
|
1065
|
+
if (process.platform === "linux") {
|
|
1066
|
+
const m = /^ExecStart=(.+)$/m.exec(definition);
|
|
1067
|
+
return m ? m[1].trim() : null;
|
|
1068
|
+
}
|
|
1069
|
+
const m = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(definition);
|
|
1070
|
+
return m ? unescapeXml(m[1]) : null;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* True when a service IS installed but its definition predates
|
|
1075
|
+
* `FAILPROOFAI_CLI_CMD`.
|
|
1076
|
+
*
|
|
1077
|
+
* Deliberately a content check against the definition on disk, not a revision
|
|
1078
|
+
* number mirrored into `config.toml`. The mirror is the tempting shape — it is
|
|
1079
|
+
* how `daemon.installed_version` is modelled — but that field is declared,
|
|
1080
|
+
* read and cleared and has never once been WRITTEN (both `setDaemonConfigured`
|
|
1081
|
+
* call sites pass no version), which is precisely how a mirror fails: it
|
|
1082
|
+
* reports "current" for a unit nobody updated. A false negative here is the
|
|
1083
|
+
* permanently-inert audit lane this function exists to catch, and the unit
|
|
1084
|
+
* itself cannot lie about what it contains.
|
|
1085
|
+
*/
|
|
1086
|
+
export function daemonServiceNeedsUpgrade(): boolean {
|
|
1087
|
+
const definition = readInstalledServiceDefinition();
|
|
1088
|
+
if (definition === null) return false;
|
|
1089
|
+
return !definition.includes("FAILPROOFAI_CLI_CMD");
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* The definition an installed one should become: regenerated by the same
|
|
1094
|
+
* renderers a fresh install uses, carrying forward everything this process
|
|
1095
|
+
* cannot re-resolve. `null` when the input has no readable start command,
|
|
1096
|
+
* which means it is not a definition failproofai wrote and must be left alone.
|
|
1097
|
+
*
|
|
1098
|
+
* Split out from `ensureDaemonServiceCurrent` and kept pure — no privilege, no
|
|
1099
|
+
* service manager, no writes — because the rewrite itself can only be
|
|
1100
|
+
* exercised end-to-end on a machine with root AND the matching service
|
|
1101
|
+
* manager. That is nowhere on a Linux CI runner for the launchd half, and
|
|
1102
|
+
* nowhere at all on a developer box without passwordless sudo, so the one
|
|
1103
|
+
* transformation that must not be wrong would otherwise be the one thing no
|
|
1104
|
+
* ordinary test run ever executes.
|
|
1105
|
+
*/
|
|
1106
|
+
export function upgradedServiceDefinition(
|
|
1107
|
+
definition: string,
|
|
1108
|
+
cliCmd: string,
|
|
1109
|
+
logDir: string,
|
|
1110
|
+
): string | null {
|
|
1111
|
+
const binaryPath = installedExecStart(definition);
|
|
1112
|
+
if (!binaryPath) return null;
|
|
1113
|
+
const workerCmd = resolveWorkerCommand() ?? installedEnvValue(definition, "FAILPROOFAI_WORKER_CMD");
|
|
1114
|
+
try {
|
|
1115
|
+
return process.platform === "linux"
|
|
1116
|
+
? systemdUnitContents(binaryPath, workerCmd, cliCmd)
|
|
1117
|
+
: launchdPlistContents(binaryPath, logDir, workerCmd, cliCmd);
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
// A value that cannot be safely interpolated is reported as "no usable
|
|
1120
|
+
// definition" rather than thrown: this runs against a HEALTHY, RUNNING
|
|
1121
|
+
// daemon and the caller's contract is an outcome, not an exception. The
|
|
1122
|
+
// refusal happens before anything is written or stopped, so the machine is
|
|
1123
|
+
// left exactly as it was.
|
|
1124
|
+
hookLogWarn(
|
|
1125
|
+
`service definition not rewritten: ${err instanceof Error ? err.message : String(err)}`,
|
|
1126
|
+
);
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export type DaemonUpgradeOutcome =
|
|
1132
|
+
/** No service installed, or its definition already carries the variable. */
|
|
1133
|
+
| "current"
|
|
1134
|
+
/** The definition was rewritten and the service came back running. */
|
|
1135
|
+
| "rewritten"
|
|
1136
|
+
/** It needed a rewrite and did not get one. `reason` says why. */
|
|
1137
|
+
| "failed";
|
|
1138
|
+
|
|
1139
|
+
export interface DaemonUpgradeResult {
|
|
1140
|
+
outcome: DaemonUpgradeOutcome;
|
|
1141
|
+
reason?: string;
|
|
1142
|
+
/**
|
|
1143
|
+
* Whether failproofaid is running when this returns — probed, never
|
|
1144
|
+
* inferred from what the commands returned.
|
|
1145
|
+
*
|
|
1146
|
+
* Set on every outcome that touched the service, because on a
|
|
1147
|
+
* `daemonConfigured` machine a stopped daemon is not a lost feature: every
|
|
1148
|
+
* hook event fails closed, across all 12 CLIs, until somebody hand-edits
|
|
1149
|
+
* `policies-config.json`. The caller needs to know that specifically, not
|
|
1150
|
+
* just that "the refresh failed".
|
|
1151
|
+
*/
|
|
1152
|
+
daemonRunning?: boolean;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Restart the systemd unit, clearing any start-limit latch first.
|
|
1157
|
+
*
|
|
1158
|
+
* The unit ships `Restart=on-failure` with `RestartSec=2`, so a definition
|
|
1159
|
+
* systemd accepts but cannot run (a poisoned `User=`, a missing binary) does
|
|
1160
|
+
* not fail once — it cycles, and within `DefaultStartLimitIntervalSec` (10s) it
|
|
1161
|
+
* trips `DefaultStartLimitBurst` (5) and latches into "start request repeated
|
|
1162
|
+
* too quickly". On systemd 255 — what ubuntu-24.04 and GitHub's runners ship —
|
|
1163
|
+
* that latch is sticky at the unit level: a later `systemctl restart` is refused
|
|
1164
|
+
* even after the definition on disk has been REPLACED with a good one. A
|
|
1165
|
+
* rollback that restores a perfectly runnable unit then still cannot start it,
|
|
1166
|
+
* and the machine stays fail-closed against a daemon that never comes back.
|
|
1167
|
+
* `reset-failed` clears the failure counter so the restart is deterministic; on
|
|
1168
|
+
* a healthy unit it is a no-op. Best-effort — a `reset-failed` that errors (the
|
|
1169
|
+
* unit was never failed) must never abort the restart that is the real step.
|
|
1170
|
+
*/
|
|
1171
|
+
function restartSystemdUnit(): void {
|
|
1172
|
+
try {
|
|
1173
|
+
runPrivileged("systemctl", ["reset-failed", systemdUnitName()]);
|
|
1174
|
+
} catch {
|
|
1175
|
+
// The restart below is the operative step; do not let a reset-failed
|
|
1176
|
+
// that errored on an already-clean unit swallow it.
|
|
1177
|
+
}
|
|
1178
|
+
runPrivileged("systemctl", ["restart", systemdUnitName()]);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Puts a previous service definition back and starts the service again,
|
|
1183
|
+
* answering whether failproofaid is running once it has.
|
|
1184
|
+
*
|
|
1185
|
+
* `ensureDaemonServiceCurrent` is the only caller, and it runs exclusively
|
|
1186
|
+
* against a machine whose daemon is UP — which is what makes a half-applied
|
|
1187
|
+
* rewrite so much worse than no rewrite. Stopping a healthy daemon and failing
|
|
1188
|
+
* to start it again trades a dead audit lane for a machine that denies every
|
|
1189
|
+
* tool call, so the refresh must be able to undo itself.
|
|
1190
|
+
*
|
|
1191
|
+
* Best-effort and never throws: it only ever runs where something has already
|
|
1192
|
+
* gone wrong. The return value is `waitForDaemonRunning()` rather than "the
|
|
1193
|
+
* commands succeeded" on purpose — the common rollback failure is a lost sudo
|
|
1194
|
+
* credential, which means nothing was written and the daemon was never down.
|
|
1195
|
+
*/
|
|
1196
|
+
async function restoreServiceDefinition(previous: string): Promise<boolean> {
|
|
1197
|
+
try {
|
|
1198
|
+
if (process.platform === "linux") {
|
|
1199
|
+
writePrivilegedFile(systemdUnitPath(), previous);
|
|
1200
|
+
runPrivileged("systemctl", ["daemon-reload"]);
|
|
1201
|
+
// reset-failed first: the failed rewrite we are undoing has been cycling
|
|
1202
|
+
// under Restart=on-failure and may have tripped systemd's start-limit,
|
|
1203
|
+
// which on systemd 255 refuses to start even this restored, good unit.
|
|
1204
|
+
restartSystemdUnit();
|
|
1205
|
+
} else {
|
|
1206
|
+
const plistPath = launchdPlistPath();
|
|
1207
|
+
try {
|
|
1208
|
+
runPrivileged("launchctl", ["unload", plistPath]);
|
|
1209
|
+
} catch {
|
|
1210
|
+
// Already unloaded — getting the old definition loaded is the point.
|
|
1211
|
+
}
|
|
1212
|
+
writePrivilegedFile(plistPath, previous);
|
|
1213
|
+
runPrivileged("launchctl", ["load", "-w", plistPath]);
|
|
1214
|
+
}
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
hookLogWarn(
|
|
1217
|
+
`daemon service definition rollback failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1218
|
+
);
|
|
1219
|
+
// Not a verdict on the daemon — fall through to the probe below, which is.
|
|
1220
|
+
}
|
|
1221
|
+
return waitForDaemonRunning();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* The one exit for every way a refresh can fail after it started touching the
|
|
1226
|
+
* service. Rolls back when there is something to roll back to, then reports
|
|
1227
|
+
* the daemon's actual state.
|
|
1228
|
+
*/
|
|
1229
|
+
async function refreshFailed(message: string, previous: string | null): Promise<DaemonUpgradeResult> {
|
|
1230
|
+
hookLogWarn(`daemon service unit refresh failed: ${message}`);
|
|
1231
|
+
const running =
|
|
1232
|
+
previous === null ? daemonServiceStatus() === "running" : await restoreServiceDefinition(previous);
|
|
1233
|
+
return {
|
|
1234
|
+
outcome: "failed",
|
|
1235
|
+
daemonRunning: running,
|
|
1236
|
+
reason: running
|
|
1237
|
+
? message
|
|
1238
|
+
: `${message}. The previous service definition could not be restored either, so failproofaid is NOT running`,
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Brings an already-installed service definition up to date, in place.
|
|
1244
|
+
*
|
|
1245
|
+
* Narrower than `installDaemonService` on purpose, and it is the narrowness
|
|
1246
|
+
* that makes it safe to run against a healthy machine: it never downloads, it
|
|
1247
|
+
* never touches a machine that has no service, and it keeps the ExecStart and
|
|
1248
|
+
* any environment value it cannot re-resolve. Everything past the first
|
|
1249
|
+
* privileged write is undone by `restoreServiceDefinition` if the daemon does
|
|
1250
|
+
* not come back, so the worst outcome really is the machine it started with —
|
|
1251
|
+
* and when even the rollback cannot get the daemon up, that fact rides out on
|
|
1252
|
+
* `daemonRunning` rather than being folded into a generic failure, because the
|
|
1253
|
+
* caller has to stop asserting `daemonConfigured` over a daemon that is gone.
|
|
1254
|
+
*
|
|
1255
|
+
* It DOES restart the service, which `installDaemonService` does not do on
|
|
1256
|
+
* Linux (`enable --now` is a no-op against an already-active unit, so a
|
|
1257
|
+
* rewritten unit's environment reaches the running process only at the next
|
|
1258
|
+
* boot). Without the restart this "fix" would leave the audit lane inert for
|
|
1259
|
+
* however long the machine stays up — which is the bug, not a smaller version
|
|
1260
|
+
* of it. The restart costs a sub-second window in which a hook can find no
|
|
1261
|
+
* daemon and fail closed, so it is confined to the wizard: `failproofai
|
|
1262
|
+
* config` is an explicit, attended reconfiguration, and the same window
|
|
1263
|
+
* already exists on macOS, where install unloads and reloads on every run.
|
|
1264
|
+
*/
|
|
1265
|
+
export async function ensureDaemonServiceCurrent(): Promise<DaemonUpgradeResult> {
|
|
1266
|
+
const definition = readInstalledServiceDefinition();
|
|
1267
|
+
if (definition === null || definition.includes("FAILPROOFAI_CLI_CMD")) {
|
|
1268
|
+
return { outcome: "current" };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const cliCmd = resolveCliCommand();
|
|
1272
|
+
if (!cliCmd) {
|
|
1273
|
+
return {
|
|
1274
|
+
outcome: "failed",
|
|
1275
|
+
reason:
|
|
1276
|
+
"the failproofai CLI entry point (dist/cli.mjs) could not be located, so there is nothing to " +
|
|
1277
|
+
"write into the unit. Re-run `failproofai config` from a complete install.",
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const logDir = logsDir();
|
|
1282
|
+
const upgraded = upgradedServiceDefinition(definition, cliCmd, logDir);
|
|
1283
|
+
if (!upgraded) {
|
|
1284
|
+
return {
|
|
1285
|
+
outcome: "failed",
|
|
1286
|
+
reason:
|
|
1287
|
+
`no safe replacement could be composed for the service definition at ${daemonServiceFilePath()} — ` +
|
|
1288
|
+
`either it has no readable start command (it looks hand-edited) or a value that would go into it ` +
|
|
1289
|
+
`contains a quote, backslash or newline. It was left alone.`,
|
|
1290
|
+
// Reported because nothing was touched: the refusal happens before any
|
|
1291
|
+
// write and before any stop, so whatever was running still is. The
|
|
1292
|
+
// wizard branches on this to decide whether to clear `daemonConfigured`,
|
|
1293
|
+
// and omitting it here would have read as "the daemon may be down" on a
|
|
1294
|
+
// machine whose daemon is perfectly healthy — which on a
|
|
1295
|
+
// `daemonConfigured` box is the difference between a lost audit lane and
|
|
1296
|
+
// every tool call denied.
|
|
1297
|
+
daemonRunning: daemonServiceStatus() === "running",
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (!canElevate()) {
|
|
1302
|
+
return {
|
|
1303
|
+
outcome: "failed",
|
|
1304
|
+
reason: `root privileges are required to rewrite ${daemonServiceFilePath()}, and sudo credentials were not available`,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// What to put back if this goes wrong, and from which point. Set BEFORE the
|
|
1309
|
+
// step that can leave the machine changed, not after it succeeds: on Linux
|
|
1310
|
+
// `writePrivilegedFile` is an `install` copy straight onto the unit path, so
|
|
1311
|
+
// a failure part-way through leaves a truncated unit behind, and on macOS
|
|
1312
|
+
// the unload has already stopped the daemon before anything is written.
|
|
1313
|
+
let previous: string | null = null;
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
if (process.platform === "linux") {
|
|
1317
|
+
previous = definition;
|
|
1318
|
+
writePrivilegedFile(systemdUnitPath(), upgraded);
|
|
1319
|
+
runPrivileged("systemctl", ["daemon-reload"]);
|
|
1320
|
+
// `restart`, not `enable --now`: the unit is already enabled and active,
|
|
1321
|
+
// so `--now` would return success having changed nothing and the daemon
|
|
1322
|
+
// would keep the environment it was started with. reset-failed first so a
|
|
1323
|
+
// machine that arrived here with an already-latched start-limit (a prior
|
|
1324
|
+
// failed refresh) can still be upgraded.
|
|
1325
|
+
restartSystemdUnit();
|
|
1326
|
+
} else {
|
|
1327
|
+
const plistPath = launchdPlistPath();
|
|
1328
|
+
mkdirSync(logDir, { recursive: true });
|
|
1329
|
+
previous = definition;
|
|
1330
|
+
try {
|
|
1331
|
+
runPrivileged("launchctl", ["unload", plistPath]);
|
|
1332
|
+
} catch {
|
|
1333
|
+
// Not loaded — the rewrite is still the point.
|
|
1334
|
+
}
|
|
1335
|
+
writePrivilegedFile(plistPath, upgraded);
|
|
1336
|
+
runPrivileged("launchctl", ["load", "-w", plistPath]);
|
|
1337
|
+
}
|
|
1338
|
+
} catch (err) {
|
|
1339
|
+
return refreshFailed(err instanceof Error ? err.message : String(err), previous);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// The service was running when this started, so anything short of running
|
|
1343
|
+
// now is a regression this call caused — and on a daemon-configured machine
|
|
1344
|
+
// that is every tool call failing closed. A unit that parses but will not
|
|
1345
|
+
// start is exactly the case `systemctl restart`'s exit code does not catch,
|
|
1346
|
+
// so this is the check that hands it to the rollback.
|
|
1347
|
+
if (!(await waitForDaemonRunning())) {
|
|
1348
|
+
return refreshFailed(
|
|
1349
|
+
`the service definition was refreshed but failproofaid did not come back within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`,
|
|
1350
|
+
previous,
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
return { outcome: "rewritten", daemonRunning: true };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
359
1356
|
/**
|
|
360
1357
|
* Stops and removes the service, and clears the `daemonConfigured` marker
|
|
361
1358
|
* so this machine goes back to in-process evaluation. Best-effort: never
|
|
@@ -370,40 +1367,44 @@ async function waitForDaemonRunning(): Promise<boolean> {
|
|
|
370
1367
|
export async function uninstallDaemonService(): Promise<void> {
|
|
371
1368
|
setDaemonConfigured(false);
|
|
372
1369
|
if (!isDaemonSupportedPlatform()) return;
|
|
1370
|
+
|
|
1371
|
+
// Always attempted, and never privileged: a legacy user-scope daemon is
|
|
1372
|
+
// the one thing this can still clean up on a machine that cannot elevate.
|
|
1373
|
+
removeLegacyUserService();
|
|
1374
|
+
|
|
373
1375
|
try {
|
|
374
1376
|
if (process.platform === "linux") {
|
|
1377
|
+
const unitPath = systemdUnitPath();
|
|
1378
|
+
if (!existsSync(unitPath)) return;
|
|
375
1379
|
try {
|
|
376
|
-
|
|
377
|
-
stdio: "ignore",
|
|
378
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
379
|
-
});
|
|
1380
|
+
runPrivileged("systemctl", ["disable", "--now", systemdUnitName()]);
|
|
380
1381
|
} catch {
|
|
381
|
-
// Already stopped/not
|
|
1382
|
+
// Already stopped/not enabled — removing the unit is still the point.
|
|
382
1383
|
}
|
|
383
|
-
|
|
384
|
-
if (existsSync(unitPath)) unlinkSync(unitPath);
|
|
1384
|
+
runPrivileged("rm", ["-f", unitPath]);
|
|
385
1385
|
try {
|
|
386
|
-
|
|
387
|
-
stdio: "ignore",
|
|
388
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
389
|
-
});
|
|
1386
|
+
runPrivileged("systemctl", ["daemon-reload"]);
|
|
390
1387
|
} catch {
|
|
391
1388
|
// Best-effort.
|
|
392
1389
|
}
|
|
393
1390
|
} else {
|
|
394
1391
|
const plistPath = launchdPlistPath();
|
|
1392
|
+
if (!existsSync(plistPath)) return;
|
|
395
1393
|
try {
|
|
396
|
-
|
|
397
|
-
stdio: "ignore",
|
|
398
|
-
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
399
|
-
});
|
|
1394
|
+
runPrivileged("launchctl", ["unload", "-w", plistPath]);
|
|
400
1395
|
} catch {
|
|
401
|
-
// Already unloaded
|
|
1396
|
+
// Already unloaded — fine.
|
|
402
1397
|
}
|
|
403
|
-
|
|
1398
|
+
runPrivileged("rm", ["-f", plistPath]);
|
|
404
1399
|
}
|
|
405
1400
|
} catch (err) {
|
|
406
|
-
|
|
1401
|
+
// `daemonConfigured` is already cleared above, so a machine that cannot
|
|
1402
|
+
// elevate is back on the in-process path even though the unit file
|
|
1403
|
+
// survives — it fails open, not closed.
|
|
1404
|
+
hookLogWarn(
|
|
1405
|
+
`daemon service uninstall failed (the service may need removing by hand: ${daemonStatusCommand()}): ` +
|
|
1406
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
1407
|
+
);
|
|
407
1408
|
}
|
|
408
1409
|
}
|
|
409
1410
|
|
|
@@ -412,37 +1413,134 @@ export async function uninstallDaemonService(): Promise<void> {
|
|
|
412
1413
|
* file exists — a unit can be installed but crash-looped into a stopped
|
|
413
1414
|
* state.
|
|
414
1415
|
*/
|
|
1416
|
+
/**
|
|
1417
|
+
* Whether the installed daemon is a different version than this CLI expects.
|
|
1418
|
+
*
|
|
1419
|
+
* The expected version is this CLI's OWN — no lookup, no registry call, no
|
|
1420
|
+
* network. `package.json` and `Cargo.toml` are held identical by CI, and every
|
|
1421
|
+
* downstream path (the download URL, the binary filename, the npm pin) is
|
|
1422
|
+
* derived from that one number, so a CLI can never want a daemon version that
|
|
1423
|
+
* was not published alongside it.
|
|
1424
|
+
*
|
|
1425
|
+
* Returns null when the question does not apply, and the exclusions are the
|
|
1426
|
+
* point:
|
|
1427
|
+
*
|
|
1428
|
+
* • FAILPROOFAI_DAEMON_BINARY — someone named a binary explicitly; its
|
|
1429
|
+
* version is their business, not ours to second-guess.
|
|
1430
|
+
* • a locally-built target/{release,debug} binary — the developer's own
|
|
1431
|
+
* build. Reporting it "stale" on every command would be noise about the
|
|
1432
|
+
* exact setup this repo tells contributors to use.
|
|
1433
|
+
*
|
|
1434
|
+
* Only the managed `~/.failproofai/bin/failproofaid-<version>` path is
|
|
1435
|
+
* checked, because it is the only one whose version we actually own.
|
|
1436
|
+
*/
|
|
1437
|
+
export function daemonVersionSkew(): { installed: string; expected: string } | null {
|
|
1438
|
+
if (process.env.FAILPROOFAI_DAEMON_BINARY) return null;
|
|
1439
|
+
const recorded = readVersionFile()?.daemon;
|
|
1440
|
+
if (!recorded) return null;
|
|
1441
|
+
// Managed installs only — see the exclusions above.
|
|
1442
|
+
if (!existsSync(installedBinaryPath(recorded))) return null;
|
|
1443
|
+
return recorded === version ? null : { installed: recorded, expected: version };
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Why a Linux unit that exists is not active: skipped on a condition, or
|
|
1448
|
+
* merely stopped.
|
|
1449
|
+
*
|
|
1450
|
+
* Only reached once `is-active` has already said "not active", so it costs a
|
|
1451
|
+
* second `systemctl` call on the unhealthy path and nothing at all on the
|
|
1452
|
+
* healthy one.
|
|
1453
|
+
*
|
|
1454
|
+
* `ConditionResult` is systemd's own record of the last condition evaluation
|
|
1455
|
+
* and is `no` only after it actually ran them and one failed. A unit that has
|
|
1456
|
+
* never been started since boot reports `yes` (the field's default), so this
|
|
1457
|
+
* cannot invent a `condition-failed` for a unit systemd has not judged — it
|
|
1458
|
+
* under-reports rather than over-reports, which is the safe direction for a
|
|
1459
|
+
* signal that clears `daemonConfigured`.
|
|
1460
|
+
*/
|
|
1461
|
+
function inactiveLinuxStatus(): DaemonServiceStatus {
|
|
1462
|
+
try {
|
|
1463
|
+
return interpretConditionResult(
|
|
1464
|
+
execFileSync(
|
|
1465
|
+
"systemctl",
|
|
1466
|
+
["show", systemdUnitName(), "--property=ConditionResult", "--value"],
|
|
1467
|
+
{ stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
|
|
1468
|
+
).toString(),
|
|
1469
|
+
);
|
|
1470
|
+
} catch {
|
|
1471
|
+
// No systemd, no systemctl, or a version without `--value`. The unit file
|
|
1472
|
+
// exists, so "stopped" remains the honest answer — and it is the
|
|
1473
|
+
// conservative one, because nothing acts destructively on it.
|
|
1474
|
+
return "stopped";
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* `systemctl show --property=ConditionResult --value` → a status.
|
|
1480
|
+
*
|
|
1481
|
+
* Split out from the subprocess so the interpretation can be tested without
|
|
1482
|
+
* `/etc/systemd/system`, which this code reads at a fixed path and no test may
|
|
1483
|
+
* write. The live behaviour — that systemd actually skips a unit whose
|
|
1484
|
+
* `ConditionPathExists=` fails, and records `no` when it does — is proven
|
|
1485
|
+
* against a real systemd in the container test rather than asserted here.
|
|
1486
|
+
*
|
|
1487
|
+
* Only a literal `no` means condition-failed. Anything else — `yes`, an empty
|
|
1488
|
+
* string from a unit systemd has not evaluated since boot, an unfamiliar word
|
|
1489
|
+
* from a future version — is "stopped", which is the state nothing acts
|
|
1490
|
+
* destructively on. This under-reports rather than over-reports on purpose: the
|
|
1491
|
+
* cost of a missed `condition-failed` is a flag cleared one command later, and
|
|
1492
|
+
* the cost of a false one is a healthy machine silently dropped to the
|
|
1493
|
+
* in-process path.
|
|
1494
|
+
*/
|
|
1495
|
+
export function interpretConditionResult(raw: string): DaemonServiceStatus {
|
|
1496
|
+
return raw.trim() === "no" ? "condition-failed" : "stopped";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
415
1499
|
export function daemonServiceStatus(): DaemonServiceStatus {
|
|
416
1500
|
if (!isDaemonSupportedPlatform()) return "unsupported-platform";
|
|
417
1501
|
|
|
418
1502
|
if (process.platform === "linux") {
|
|
419
1503
|
if (!existsSync(systemdUnitPath())) return "not-installed";
|
|
420
1504
|
try {
|
|
421
|
-
|
|
1505
|
+
// Unprivileged on purpose: reading a system unit's state needs no
|
|
1506
|
+
// root, so status works for the owning user with no sudo at all.
|
|
1507
|
+
const out = execFileSync("systemctl", ["is-active", systemdUnitName()], {
|
|
422
1508
|
stdio: ["ignore", "pipe", "ignore"],
|
|
423
1509
|
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
424
1510
|
})
|
|
425
1511
|
.toString()
|
|
426
1512
|
.trim();
|
|
427
|
-
return out === "active" ? "running" :
|
|
1513
|
+
return out === "active" ? "running" : inactiveLinuxStatus();
|
|
428
1514
|
} catch {
|
|
429
1515
|
// `systemctl is-active` exits non-zero (and execFileSync throws) for
|
|
430
1516
|
// every non-"active" state — inactive, failed, or the command not
|
|
431
|
-
// working at all (no systemd user session, systemctl missing).
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
return
|
|
1517
|
+
// working at all (no systemd user session, systemctl missing). The unit
|
|
1518
|
+
// file existing already ruled out "not-installed", so the only question
|
|
1519
|
+
// left is whether systemd skipped it on a condition.
|
|
1520
|
+
return inactiveLinuxStatus();
|
|
435
1521
|
}
|
|
436
1522
|
}
|
|
437
1523
|
|
|
438
1524
|
const plistPath = launchdPlistPath();
|
|
439
1525
|
if (!existsSync(plistPath)) return "not-installed";
|
|
440
1526
|
try {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1527
|
+
// A LaunchDaemon lives in launchd's system domain, which an unprivileged
|
|
1528
|
+
// `launchctl list` cannot see — unlike systemd, reading the state needs
|
|
1529
|
+
// the same elevation installing it did.
|
|
1530
|
+
const root = typeof process.getuid === "function" && process.getuid() === 0;
|
|
1531
|
+
const args = ["print", `system/${launchdLabel()}`];
|
|
1532
|
+
const out = root
|
|
1533
|
+
? execFileSync("launchctl", args, {
|
|
1534
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1535
|
+
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
1536
|
+
}).toString()
|
|
1537
|
+
: execFileSync("sudo", ["-n", "launchctl", ...args], {
|
|
1538
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1539
|
+
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
1540
|
+
}).toString();
|
|
1541
|
+
// `state = running` is launchd's own wording; a loaded-but-dead job
|
|
1542
|
+
// prints `state = not running` and must not read as healthy.
|
|
1543
|
+
return /state\s*=\s*running/.test(out) ? "running" : "stopped";
|
|
446
1544
|
} catch {
|
|
447
1545
|
return "stopped";
|
|
448
1546
|
}
|