failproofai 1.0.10 → 1.0.11-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/required-server-files.json +1 -1
- 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 +4 -4
- 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.meta +8 -0
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/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 +4 -4
- 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.meta +8 -0
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +4 -4
- 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 -4
- 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 -2
- 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 +4 -4
- 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 -3
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -3
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +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 +4 -4
- 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.meta +8 -0
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- 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 +4 -4
- 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 +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +8 -8
- 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 +5 -5
- 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 +5 -5
- 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 +5 -5
- 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/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/settings/page.js +9 -9
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__0qxhecv._.js → [externals]__0d7i9ea._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[externals]__0sg9-3p._.js → [externals]__0hjaw70._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__1_9ze9b._.js +3 -0
- package/.next/standalone/.next/server/chunks/[externals]__1oenfmn._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08dchfu._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ga9hs7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l3yhx4._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0moxohc._.js +22 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z2ll-a._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +11 -11
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1autwno._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1v25n-6._.js +3 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
- package/.next/standalone/.next/server/chunks/_1fz8m31._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1jrv3hg._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1nx8fim._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ogwigf._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_1q5i8mb._.js → _1q27azx._.js} +2 -2
- package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0el6rg8._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0ol5pa3._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1rqmk27._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1w7tiq4._.js} +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1-bm5zf._.js → [root-of-the-server]__0502lat._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0_gn5iz._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0f765xr._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01bmjsj._.js → [root-of-the-server]__0h38gzz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hoaz1g._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ouobn4._.js → [root-of-the-server]__0mxmwtx._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__19evfi8._.js → [root-of-the-server]__0uescqv._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1484zv2._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1mf3zp6._.js → [root-of-the-server]__19dutr7._.js} +4 -4
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0oqrl71._.js → [root-of-the-server]__1_qv8oh._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1u6sn69._.js → [root-of-the-server]__1ab4ifb._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1fbrk56._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1um4yxn._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1wb4yt2._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1y3n3cn._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_16pa9ht._.js → _0lnpwk8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_0yq5_3a._.js → _15y8myg._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_042cgl1._.js → _1o95rf0._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1zopuov._.js → _1thr5rf._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +12 -14
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- 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 +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_1hl81y4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0nkkbfv._.js +6 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/middleware.js +1 -1
- 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 +23 -23
- package/.next/standalone/.next/static/chunks/{03h3b-uomi7_d.js → 0pra9i676pym1.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2kj826h1w9r47.js → 1-lpi0m-hc1kb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3ob2161ap37ul.js → 17yvyyblr_eb5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3nkc2jlgyygda.js → 1mylcr99gccyz.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j92xul6y24_v.js → 1wozchtn2etvh.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3mjfvng11xnoo.js → 23thtelz1a-ur.js} +2 -2
- package/.next/standalone/.next/static/chunks/{1fqukzzowjflt.js → 2bc17tohgs4bs.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3u1s8dsqiean5.js → 2xq-ejo8kti18.js} +1 -1
- package/.next/standalone/.next/static/chunks/3-_pth_c9n938.js +1 -0
- package/.next/standalone/.next/static/chunks/{3i8rg2fv9nd6e.js → 3n46e45atic-m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{078dofaxvjgpd.js → 3q7sndqftqhw9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0542cuk_va8g1.js → 3sg07wnn969xq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-11dn2pegmg9_9.js → turbopack-1opxka4dtjbc5.js} +1 -1
- package/.next/standalone/app/actions/install-hooks-web.ts +10 -2
- package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/hash.js/sha256/256.js +1 -0
- package/.next/standalone/node_modules/next/dist/compiled/hash.js/sha256/package.json +1 -0
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +17 -17
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +18 -18
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +3 -3
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +13 -13
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/server/base-server.js +1 -2
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/image-optimizer.js +265 -53
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +177 -19
- package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/index.js +36 -17
- package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/shared-cache-controls.external.js +14 -6
- package/.next/standalone/node_modules/next/dist/server/lib/match-next-data-pathname.js +3 -1
- package/.next/standalone/node_modules/next/dist/server/lib/node-fs-methods.js +14 -0
- package/.next/standalone/node_modules/next/dist/server/lib/route-cache-key.js +77 -0
- package/.next/standalone/node_modules/next/dist/server/lib/router-utils/block-cross-site-dev.js +9 -11
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/mcp/get-mcp-middleware.js +4 -2
- package/.next/standalone/node_modules/next/dist/server/next-server.js +7 -3
- package/.next/standalone/node_modules/next/dist/server/response-cache/index.js +32 -16
- package/.next/standalone/node_modules/next/dist/server/route-matcher-providers/app-page-route-matcher-provider.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/route-modules/pages/pages-handler.js +6 -1
- package/.next/standalone/node_modules/next/dist/server/route-modules/route-module.js +9 -2
- package/.next/standalone/node_modules/next/dist/server/use-cache/use-cache-wrapper.js +30 -29
- package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/app-paths.js +18 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +10 -10
- package/.next/standalone/package.json +14 -11
- package/.next/standalone/sdk/python/README.md +41 -0
- package/.next/standalone/sdk/python/docs/README.md +1 -0
- package/.next/standalone/sdk/python/docs/otel/README.md +36 -0
- package/.next/standalone/sdk/python/docs/otel/examples/agent_trace.py +30 -0
- package/.next/standalone/sdk/python/docs/otel/examples/quickstart.py +22 -0
- package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +8 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +3 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/otel.py +189 -0
- package/.next/standalone/sdk/python/pyproject.toml +2 -1
- package/.next/standalone/sdk/python/tests/test_docs.py +16 -3
- package/.next/standalone/sdk/python/tests/test_integrations.py +1 -1
- package/.next/standalone/sdk/python/tests/test_otel.py +147 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +4 -1
- package/.next/standalone/sdk/python/uv.lock +258 -147
- package/.next/standalone/sdk/typescript/README.md +43 -0
- package/.next/standalone/sdk/typescript/integration/ai.test.ts +9 -2
- package/.next/standalone/sdk/typescript/package-lock.json +57 -5
- package/.next/standalone/sdk/typescript/package.json +2 -0
- package/.next/standalone/sdk/typescript/src/edge/index.ts +9 -0
- package/.next/standalone/sdk/typescript/src/index.ts +2 -0
- package/.next/standalone/sdk/typescript/src/integrations/index.ts +4 -1
- package/.next/standalone/sdk/typescript/src/integrations/otel.ts +204 -0
- package/.next/standalone/sdk/typescript/test/integrations.test.ts +1 -1
- package/.next/standalone/sdk/typescript/test/otel.test.ts +125 -0
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +653 -1077
- package/dist/cli.mjs +8781 -4050
- package/dist/worker.mjs +170 -62
- package/hermes-plugin/plugin.yaml +1 -1
- package/package.json +14 -11
- package/scripts/launch-facts.ts +157 -0
- package/scripts/launch-ready.ts +228 -0
- package/scripts/launch-screen.ts +202 -0
- package/scripts/launch.ts +106 -49
- package/scripts/skew-log-filter.ts +38 -0
- package/src/audit/cli-login.ts +39 -59
- package/src/audit/cli.ts +308 -228
- package/src/audit/index.ts +45 -2
- package/src/audit/schedule-cli.ts +83 -120
- package/src/audit/types.ts +31 -0
- package/src/hooks/agent-selection.ts +155 -0
- package/src/hooks/backfill-cli.ts +151 -137
- package/src/hooks/backfill-request.ts +149 -0
- package/src/hooks/cloud-connection.ts +1 -1
- package/src/hooks/cloud-enrollment-cli.ts +78 -28
- package/src/hooks/cloud-introspect.ts +3 -0
- package/src/hooks/collector-config.ts +10 -1
- package/src/hooks/config-status.ts +717 -0
- package/src/hooks/configure-wizard.ts +967 -988
- package/src/hooks/daemon-service.ts +16 -6
- package/src/hooks/did-you-mean.ts +185 -0
- package/src/hooks/flush-cli.ts +33 -7
- package/src/hooks/fp-config.ts +96 -0
- package/src/hooks/fp-home.ts +11 -0
- package/src/hooks/fp-reset.ts +102 -95
- package/src/hooks/handler.ts +4 -0
- package/src/hooks/harness-cli.ts +87 -138
- package/src/hooks/install-prompt.ts +38 -477
- package/src/hooks/integrations.ts +1 -1
- package/src/hooks/jev-cli.ts +183 -196
- package/src/hooks/manager.ts +554 -358
- package/src/hooks/onboarding-attempt.ts +11 -9
- package/src/hooks/otel-cli.ts +350 -0
- package/src/hooks/otel-output.ts +24 -0
- package/src/hooks/otel-writers.ts +215 -0
- package/src/hooks/pack-cli.ts +219 -204
- package/src/hooks/report-body.ts +50 -0
- package/src/hooks/safe-config-write.ts +4 -2
- package/src/hooks/session-pause-cli.ts +93 -73
- package/src/hooks/tui.ts +849 -389
- package/src/hooks/uninstall-cli.ts +136 -149
- package/src/hooks/untraced-note.ts +52 -0
- package/.next/standalone/.next/server/chunks/[externals]__19_pzeq._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__20kzpkf._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0igxt32._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zyc_nm._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +0 -22
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +0 -6
- package/.next/standalone/.next/static/chunks/1lyg_puttohbc.js +0 -1
- /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → xtcpqBNBdjQVerPMkI2HI}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → xtcpqBNBdjQVerPMkI2HI}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → xtcpqBNBdjQVerPMkI2HI}/_ssgManifest.js +0 -0
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `failproofai config` — the interactive setup launcher.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five steps, in the order the machine needs them:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 4.
|
|
11
|
-
* 5.
|
|
12
|
-
*
|
|
13
|
-
* Selections REPLACE the enabled set at the chosen scope (the picker pre-checks
|
|
14
|
-
* whatever is already enabled, so unticking removes). Reuses the tested
|
|
15
|
-
* install/uninstall manager and the existing searchable policy picker.
|
|
6
|
+
* 1. Daemon — REQUIRED. Asks for sudo first, on a clean terminal.
|
|
7
|
+
* 2. Connect — one masked API-key field; Tab for open source.
|
|
8
|
+
* 3. Agents — which agents failproofai traces: hooks, collection and
|
|
9
|
+
* backfill all follow this one saved list.
|
|
10
|
+
* 4. OTEL — optional, default No, on interactive Cloud setup only.
|
|
11
|
+
* 5. Done — applied after the last answer; there is no separate review.
|
|
12
|
+
* Esc at any step before that changes nothing.
|
|
16
13
|
*
|
|
17
14
|
* ## Two ordering rules that are not cosmetic
|
|
18
15
|
*
|
|
@@ -21,10 +18,11 @@
|
|
|
21
18
|
* fired from underneath a rendered screen the prompt is invisible and the typed
|
|
22
19
|
* password lands in a redrawn frame.
|
|
23
20
|
*
|
|
24
|
-
* **
|
|
25
|
-
* requires
|
|
26
|
-
* rather than half-configured. Writing hooks first and discovering the
|
|
27
|
-
* will not start afterwards is the one ordering that cannot be undone
|
|
21
|
+
* **Everything is written at apply, the daemon before anything else.** Setup
|
|
22
|
+
* requires the daemon, so a failure has to leave the machine exactly as it was
|
|
23
|
+
* found rather than half-configured. Writing hooks first and discovering the
|
|
24
|
+
* service will not start afterwards is the one ordering that cannot be undone
|
|
25
|
+
* cleanly.
|
|
28
26
|
*/
|
|
29
27
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
30
28
|
import { homedir } from "node:os";
|
|
@@ -34,26 +32,33 @@ import { CORE_SOURCE } from "./pack-store";
|
|
|
34
32
|
|
|
35
33
|
import {
|
|
36
34
|
selectOne,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
collapseAfter,
|
|
36
|
+
colorsEnabled,
|
|
37
|
+
multiSelect,
|
|
38
|
+
OPEN_SOURCE,
|
|
39
|
+
optsFor,
|
|
40
|
+
paint,
|
|
41
|
+
promptCloudKey,
|
|
42
|
+
screenKit,
|
|
43
|
+
type CloudKeyAnswer,
|
|
41
44
|
type MultiChoice,
|
|
45
|
+
type OpenSource,
|
|
42
46
|
type TTYIn,
|
|
43
47
|
type TTYOut,
|
|
44
48
|
} from "./tui";
|
|
45
49
|
import {
|
|
46
50
|
DEFAULT_INGEST_URL,
|
|
51
|
+
readIngestCredential,
|
|
47
52
|
validateIngestKey,
|
|
48
53
|
} from "./collector-config";
|
|
49
54
|
import {
|
|
50
55
|
detectInstalledClis,
|
|
51
56
|
getIntegration,
|
|
52
57
|
settingsPathsFor,
|
|
53
|
-
unhookedHermesProfiles,
|
|
54
58
|
} from "./integrations";
|
|
55
59
|
import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
|
|
56
|
-
import { installHooks } from "./manager";
|
|
60
|
+
import { enforcingPolicyCount, installHooks, notEnforcingReason } from "./manager";
|
|
61
|
+
import { readInstalledPacks } from "./pack-manifest";
|
|
57
62
|
import { getConfigPathForScope, readScopedHooksConfig } from "./hooks-config";
|
|
58
63
|
import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
|
|
59
64
|
import { trackHookEvent } from "./hook-telemetry";
|
|
@@ -62,7 +67,6 @@ import {
|
|
|
62
67
|
canElevate,
|
|
63
68
|
isDaemonSupportedPlatform,
|
|
64
69
|
installDaemonService,
|
|
65
|
-
daemonServiceFilePath,
|
|
66
70
|
daemonServiceStatus,
|
|
67
71
|
daemonServiceNeedsUpgrade,
|
|
68
72
|
daemonStatusCommand,
|
|
@@ -76,6 +80,7 @@ import {
|
|
|
76
80
|
} from "./daemon-service";
|
|
77
81
|
import { hookLogWarn } from "./hook-logger";
|
|
78
82
|
import {
|
|
83
|
+
maskToken,
|
|
79
84
|
readCloudCredentials,
|
|
80
85
|
resolveMachineId,
|
|
81
86
|
resolveMachineLabel,
|
|
@@ -85,12 +90,15 @@ import {
|
|
|
85
90
|
cloudBaseFor,
|
|
86
91
|
ingestUrlFor,
|
|
87
92
|
connectToCloud,
|
|
88
|
-
|
|
93
|
+
jevLines,
|
|
89
94
|
} from "./cloud-connection";
|
|
95
|
+
import { hasPermission, introspectKey, PERMISSION_EVENTS, PERMISSION_POLICIES } from "./cloud-introspect";
|
|
96
|
+
import { readConfig, readCredentials, updateConfig } from "./fp-config";
|
|
97
|
+
import { requestFreshStart } from "./backfill-request";
|
|
98
|
+
import { hookedAtUserScope } from "./agent-selection";
|
|
90
99
|
import {
|
|
91
100
|
detectSetupState,
|
|
92
101
|
isConfigured,
|
|
93
|
-
scopesFor,
|
|
94
102
|
type SetupTarget,
|
|
95
103
|
} from "./setup-state";
|
|
96
104
|
import { customPoliciesDir, launcherMarker } from "./fp-home";
|
|
@@ -134,6 +142,10 @@ export interface WizardAnswers {
|
|
|
134
142
|
machineLabel?: string;
|
|
135
143
|
/** Record decisions only, no session transcripts. */
|
|
136
144
|
noTranscripts?: boolean;
|
|
145
|
+
/** `--oss`: skip the connect step. A connection already here is left as it is. */
|
|
146
|
+
oss?: boolean;
|
|
147
|
+
/** `--agents`: the agents to trace, already validated. Skips the agents step. */
|
|
148
|
+
agents?: IntegrationType[];
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
/**
|
|
@@ -149,7 +161,9 @@ export type WizardAbort =
|
|
|
149
161
|
| "unsupported_platform"
|
|
150
162
|
| "running_as_sudo"
|
|
151
163
|
/** A cloud key was supplied and the server refused it. Unattended only. */
|
|
152
|
-
| "cloud_unverified"
|
|
164
|
+
| "cloud_unverified"
|
|
165
|
+
/** A key was supplied on the command line, and --url or FAILPROOFAI_CLOUD_URL cannot be used. */
|
|
166
|
+
| "bad_cloud_url";
|
|
153
167
|
|
|
154
168
|
export interface WizardResult {
|
|
155
169
|
applied: boolean;
|
|
@@ -160,7 +174,11 @@ export interface WizardResult {
|
|
|
160
174
|
clis?: IntegrationType[];
|
|
161
175
|
policies?: string[];
|
|
162
176
|
daemonInstalled?: boolean;
|
|
177
|
+
/** A connection was written by this run. */
|
|
163
178
|
connected?: boolean;
|
|
179
|
+
/** Whether the machine is connected once the run ends. */
|
|
180
|
+
mode?: "cloud" | "oss";
|
|
181
|
+
agents?: { selected: IntegrationType[]; added: IntegrationType[]; removed: IntegrationType[] };
|
|
164
182
|
}
|
|
165
183
|
|
|
166
184
|
async function emit(event: string, props: Record<string, unknown>): Promise<void> {
|
|
@@ -189,70 +207,221 @@ export function clisSupportingScope(scope: HookScope): IntegrationType[] {
|
|
|
189
207
|
return INTEGRATION_TYPES.filter((id) => getIntegration(id).scopes.includes(scope));
|
|
190
208
|
}
|
|
191
209
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Which agents a run starts with ticked, and what it compares the answer to.
|
|
212
|
+
*
|
|
213
|
+
* Three cases, told apart by what is on disk:
|
|
214
|
+
* - **rerun**: a saved selection exists. It is restored exactly, plus any
|
|
215
|
+
* agent detected now that no earlier setup saw — ticked, and tagged new.
|
|
216
|
+
* - **upgrade**: no saved selection, but hooks are installed (a machine set
|
|
217
|
+
* up before the selection existed). What is protected today is ticked, so
|
|
218
|
+
* finishing setup neither widens nor narrows it.
|
|
219
|
+
* - **first**: neither. The detected agents are ticked.
|
|
220
|
+
*
|
|
221
|
+
* Every agent is listed, detected or not (decision D1): an agent installed
|
|
222
|
+
* next week can be ticked today, and an empty machine is not stuck.
|
|
223
|
+
*/
|
|
224
|
+
export type AgentPlanMode = "first" | "rerun" | "upgrade";
|
|
225
|
+
|
|
226
|
+
export interface AgentPlan {
|
|
227
|
+
mode: AgentPlanMode;
|
|
228
|
+
/** Every agent: the detected ones first, then the rest. */
|
|
229
|
+
order: IntegrationType[];
|
|
230
|
+
detected: Set<IntegrationType>;
|
|
231
|
+
/** What the answer is compared to: the saved selection, or what is hooked today. */
|
|
232
|
+
baseline: Set<IntegrationType>;
|
|
233
|
+
/** Detected now and seen at no earlier setup. Re-runs only. */
|
|
234
|
+
fresh: Set<IntegrationType>;
|
|
235
|
+
/** Ticked when the step opens. */
|
|
236
|
+
initial: IntegrationType[];
|
|
237
|
+
/** The `seen` list to save: every agent detected at any setup so far. */
|
|
238
|
+
seen: IntegrationType[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function planAgents(input: {
|
|
242
|
+
detected: IntegrationType[];
|
|
243
|
+
saved?: { selected: string[]; seen: string[] };
|
|
244
|
+
hooked: IntegrationType[];
|
|
245
|
+
}): AgentPlan {
|
|
246
|
+
const known = (ids: string[]): IntegrationType[] =>
|
|
247
|
+
INTEGRATION_TYPES.filter((id) => ids.includes(id));
|
|
248
|
+
const detected = new Set(known(input.detected));
|
|
249
|
+
const order = [
|
|
196
250
|
...INTEGRATION_TYPES.filter((id) => detected.has(id)),
|
|
197
251
|
...INTEGRATION_TYPES.filter((id) => !detected.has(id)),
|
|
198
252
|
];
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
253
|
+
const ordered = (ids: Iterable<IntegrationType>): IntegrationType[] => {
|
|
254
|
+
const set = new Set(ids);
|
|
255
|
+
return order.filter((id) => set.has(id));
|
|
256
|
+
};
|
|
257
|
+
// Seen accumulates. Rebuilding it from this run's detection would let a PATH
|
|
258
|
+
// glitch forget an agent, and the next run would call it new — ticked — after
|
|
259
|
+
// somebody had deliberately unticked it.
|
|
260
|
+
const seen = ordered([...known(input.saved?.seen ?? []), ...detected]);
|
|
261
|
+
|
|
262
|
+
if (input.saved) {
|
|
263
|
+
const baseline = new Set(known(input.saved.selected));
|
|
264
|
+
const previouslySeen = new Set(known(input.saved.seen));
|
|
265
|
+
const fresh = new Set([...detected].filter((id) => !previouslySeen.has(id) && !baseline.has(id)));
|
|
266
|
+
return { mode: "rerun", order, detected, baseline, fresh, initial: ordered([...baseline, ...fresh]), seen };
|
|
267
|
+
}
|
|
268
|
+
const hooked = known(input.hooked);
|
|
269
|
+
if (hooked.length > 0) {
|
|
270
|
+
return { mode: "upgrade", order, detected, baseline: new Set(hooked), fresh: new Set(), initial: ordered(hooked), seen };
|
|
271
|
+
}
|
|
272
|
+
return { mode: "first", order, detected, baseline: new Set(), fresh: new Set(), initial: ordered(detected), seen };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The selection a run takes without asking: no terminal, or a `--token` run.
|
|
277
|
+
* The plan's starting ticks — and every agent when those are empty, which only
|
|
278
|
+
* happens on a machine where nothing is detected, hooked or saved. Hooking
|
|
279
|
+
* nothing there would leave setup finished and nothing guarded.
|
|
280
|
+
*/
|
|
281
|
+
export function defaultSelection(plan: AgentPlan): IntegrationType[] {
|
|
282
|
+
// A saved answer stands even when it is empty: removing every agent's hooks
|
|
283
|
+
// saves an empty selection, and an unattended re-run must not put them back.
|
|
284
|
+
if (plan.mode === "rerun") return plan.initial;
|
|
285
|
+
return plan.initial.length > 0 ? plan.initial : [...plan.order];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** What a chosen selection changes, against the plan's baseline. */
|
|
289
|
+
export function agentChanges(
|
|
290
|
+
plan: AgentPlan,
|
|
291
|
+
selected: IntegrationType[],
|
|
292
|
+
): { added: IntegrationType[]; removed: IntegrationType[] } {
|
|
293
|
+
const chosen = new Set(selected);
|
|
294
|
+
return {
|
|
295
|
+
// On a first run nothing was traced before, so nothing is "added" back.
|
|
296
|
+
added: plan.mode === "first" ? [] : plan.order.filter((id) => chosen.has(id) && !plan.baseline.has(id)),
|
|
297
|
+
removed: plan.order.filter((id) => !chosen.has(id) && plan.baseline.has(id)),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The picker's rows: one per agent, in the plan's order. */
|
|
302
|
+
export function agentChoices(plan: AgentPlan): MultiChoice<IntegrationType>[] {
|
|
303
|
+
return plan.order.map((id) => ({
|
|
304
|
+
label: getIntegration(id).displayName,
|
|
305
|
+
value: id,
|
|
306
|
+
checked: plan.initial.includes(id),
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The tag after a row, recomputed on every toggle: `new on this machine`,
|
|
312
|
+
* then what the toggle changes against the baseline, then `not installed`.
|
|
313
|
+
* No change tags on a first run — every tick would read "will be added".
|
|
314
|
+
*/
|
|
315
|
+
export function agentTag(
|
|
316
|
+
plan: AgentPlan,
|
|
317
|
+
c: ReturnType<typeof paint>,
|
|
318
|
+
): (id: IntegrationType, checked: boolean) => string | undefined {
|
|
319
|
+
return (id, checked) => {
|
|
320
|
+
if (plan.fresh.has(id)) return c.guide("new on this machine");
|
|
321
|
+
if (plan.mode !== "first") {
|
|
322
|
+
const before = plan.baseline.has(id);
|
|
323
|
+
if (checked && !before) return c.guide("will be added");
|
|
324
|
+
if (!checked && before) return c.warn("will be removed");
|
|
219
325
|
}
|
|
326
|
+
if (!plan.detected.has(id)) return c.ink3("not installed");
|
|
327
|
+
return undefined;
|
|
328
|
+
};
|
|
329
|
+
}
|
|
220
330
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
331
|
+
/** "A", "A and B", "A, B and C". */
|
|
332
|
+
function namesList(ids: IntegrationType[]): string {
|
|
333
|
+
const names = ids.map((id) => getIntegration(id).displayName);
|
|
334
|
+
return names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Wrap `text` after a 2-column glyph, continuing under it, within `width`. */
|
|
338
|
+
function wrapAfterGlyph(glyph: string, text: string, width: number): string[] {
|
|
339
|
+
const out: string[] = [];
|
|
340
|
+
let line = "";
|
|
341
|
+
for (const word of text.split(" ")) {
|
|
342
|
+
if (line && 2 + line.length + 1 + word.length > width) {
|
|
343
|
+
out.push(line);
|
|
344
|
+
line = word;
|
|
345
|
+
} else {
|
|
346
|
+
line = line ? `${line} ${word}` : word;
|
|
226
347
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
348
|
+
}
|
|
349
|
+
if (line) out.push(line);
|
|
350
|
+
return out.map((l, i) => (i === 0 ? `${glyph} ${l}` : ` ${l}`));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The AGENTS step, collapsed. Names are listed when nothing changed against
|
|
355
|
+
* the baseline; otherwise the count, then what will be added and removed. What a
|
|
356
|
+
* removal promises is only what the code does: no more hooks, and on a
|
|
357
|
+
* connected machine no more collection. Nothing already collected is deleted.
|
|
358
|
+
*/
|
|
359
|
+
export function agentsCollapsedLines(
|
|
360
|
+
kit: ReturnType<typeof screenKit>,
|
|
361
|
+
plan: AgentPlan,
|
|
362
|
+
selected: IntegrationType[],
|
|
363
|
+
collecting: boolean,
|
|
364
|
+
c: ReturnType<typeof paint>,
|
|
365
|
+
): string[] {
|
|
366
|
+
const { added, removed } = agentChanges(plan, selected);
|
|
367
|
+
const n = selected.length;
|
|
368
|
+
const tracing = `Tracing ${n} ${n === 1 ? "agent" : "agents"}`;
|
|
369
|
+
const lines = [kit.head("Agents")];
|
|
370
|
+
if (n === 0) {
|
|
371
|
+
lines.push(kit.caution("Tracing no agents.", "failproofai config"));
|
|
372
|
+
return lines;
|
|
373
|
+
}
|
|
374
|
+
if (added.length === 0 && removed.length === 0) {
|
|
375
|
+
const list = selected.map((id) => getIntegration(id).displayName).join(", ");
|
|
376
|
+
lines.push(...wrapAfterGlyph(c.guide("✓"), `${tracing}: ${list}`, kit.cols));
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
lines.push(kit.ok(tracing));
|
|
380
|
+
// The choice, not the result: this prints before apply, and an apply that
|
|
381
|
+
// stops at the daemon changes nothing (decision D3).
|
|
382
|
+
if (added.length > 0) lines.push(` Adding ${namesList(added)}.`);
|
|
383
|
+
if (removed.length > 0) {
|
|
384
|
+
const they = removed.length === 1 ? "it" : "they";
|
|
385
|
+
lines.push(
|
|
386
|
+
collecting
|
|
387
|
+
? ` Removing ${namesList(removed)}: ${they} will no longer be hooked or collected. Sessions already collected are kept.`
|
|
388
|
+
: ` Removing ${namesList(removed)}: ${they} will no longer be hooked.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return lines;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Lives with the selection it seeds; re-exported where setup always had it. */
|
|
395
|
+
export { hookedAtUserScope };
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Take failproofai's hooks out of these agents' USER settings, and nothing
|
|
399
|
+
* else. Not `removeHooks`: that is the full uninstall, which also strips
|
|
400
|
+
* project and local hooks under the working directory and resets the policy
|
|
401
|
+
* configuration of every scope. Unticking an agent in setup must leave every
|
|
402
|
+
* `policies-config.json` exactly as it was.
|
|
403
|
+
*/
|
|
404
|
+
export function removeUserScopeHooks(clis: IntegrationType[], cwd: string): number {
|
|
405
|
+
let removed = 0;
|
|
406
|
+
for (const id of clis) {
|
|
407
|
+
const integration = getIntegration(id);
|
|
408
|
+
if (!integration.scopes.includes("user")) continue;
|
|
409
|
+
for (const settingsPath of settingsPathsFor(integration, "user", cwd)) {
|
|
410
|
+
// Hermes is asked even without a config file, so it can clear a plugin
|
|
411
|
+
// directory left behind by an interrupted install.
|
|
412
|
+
if (id !== "hermes" && !existsSync(settingsPath)) continue;
|
|
232
413
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// Profile discovery is best-effort — never block the wizard.
|
|
414
|
+
removed += integration.removeHooksFromFile(settingsPath);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
hookLogWarn(
|
|
417
|
+
`could not remove failproofai hooks from ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
418
|
+
);
|
|
239
419
|
}
|
|
240
420
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
value: id,
|
|
244
|
-
checked: isDetected || installedHere,
|
|
245
|
-
section: isDetected ? "Detected" : "Not installed · set up ahead of time",
|
|
246
|
-
hint: installedHere
|
|
247
|
-
? "already configured"
|
|
248
|
-
: (partialHint ?? (isDetected ? undefined : "not on PATH")),
|
|
249
|
-
};
|
|
250
|
-
});
|
|
421
|
+
}
|
|
422
|
+
return removed;
|
|
251
423
|
}
|
|
252
424
|
|
|
253
|
-
|
|
254
|
-
const DIM_NOTE = "(auto-loaded)";
|
|
255
|
-
|
|
256
425
|
/**
|
|
257
426
|
* Persist the Custom checkbox into the scope's config, after installHooks has
|
|
258
427
|
* written it (installHooks copies the previous config forward, so writing
|
|
@@ -363,196 +532,6 @@ export function describeCustomPolicies(cwd: string): {
|
|
|
363
532
|
return { active, warnings, fileCount, scopes };
|
|
364
533
|
}
|
|
365
534
|
|
|
366
|
-
/**
|
|
367
|
-
* The wizard's one-line completion summary. Pure and exported so the widest
|
|
368
|
-
* real combination — every policy, every CLI, custom/daemon/reporting all
|
|
369
|
-
* present — can be pinned by a test without having to drive the whole wizard
|
|
370
|
-
* through a real chdir + on-disk custom-policy fixture.
|
|
371
|
-
*
|
|
372
|
-
* Kept inside a standard 80-column terminal: `writeLines` truncates with a
|
|
373
|
-
* hard cut and no ellipsis, so an over-long line doesn't just lose its tail
|
|
374
|
-
* — it reads as broken output. Naming all ten CLIs once took it to 182
|
|
375
|
-
* characters; the count alone carries the same information, and the user
|
|
376
|
-
* picked them two screens ago. A single grouped "· a, b, c" clause bounds the
|
|
377
|
-
* optional notes to one separator and short tags, rather than three
|
|
378
|
-
* independent " · " clauses stacking up.
|
|
379
|
-
*/
|
|
380
|
-
export function buildCompletionSummary(
|
|
381
|
-
policiesCount: number,
|
|
382
|
-
harnessesCount: number,
|
|
383
|
-
customEnabled: boolean | undefined,
|
|
384
|
-
daemonInstalled: boolean,
|
|
385
|
-
connected: boolean,
|
|
386
|
-
): string {
|
|
387
|
-
const extras: string[] = [];
|
|
388
|
-
if (customEnabled === true) extras.push("custom");
|
|
389
|
-
else if (customEnabled === false) extras.push("custom off");
|
|
390
|
-
if (daemonInstalled) extras.push("daemon");
|
|
391
|
-
if (connected) extras.push("reporting");
|
|
392
|
-
const extrasNote = extras.length > 0 ? ` · ${extras.join(", ")}` : "";
|
|
393
|
-
const harnesses = `${harnessesCount} harness${harnessesCount === 1 ? "" : "es"}`;
|
|
394
|
-
const line = (selection: string) =>
|
|
395
|
-
`Setup complete — ${selection} · ${harnesses}${extrasNote}`;
|
|
396
|
-
|
|
397
|
-
// Bound the WHOLE line, not just the names. `writeLines` truncates with a hard
|
|
398
|
-
// cut and no ellipsis, so 81 characters does not lose a tail — it reads as
|
|
399
|
-
// broken output. Naming is preferred and degrades to the count only when the
|
|
400
|
-
// full line will not fit, which is checked rather than guessed at: the extras
|
|
401
|
-
// clause grows too ("custom, daemon, reporting" is 25 characters), so a names
|
|
402
|
-
// budget alone was wrong for exactly the combinations that need it most.
|
|
403
|
-
return line(describeSelection(policiesCount));
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* The budget this line has to fit in.
|
|
408
|
-
*
|
|
409
|
-
* 80 columns minus the 3-column gutter the existing summary tests already assert
|
|
410
|
-
* (`message.length + GUTTER <= 80`) — matching that convention rather than
|
|
411
|
-
* inventing a second one, because two different width rules for the same line is
|
|
412
|
-
* how one of them ends up wrong.
|
|
413
|
-
*/
|
|
414
|
-
const MAX_SUMMARY_COLUMNS = 77;
|
|
415
|
-
|
|
416
|
-
/** The column this line aligns under, matching ` Policies : `. */
|
|
417
|
-
const POLICY_LIST_INDENT = " ".repeat(15);
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* A taste of the set, not the set: two names and a count of the rest.
|
|
421
|
-
*
|
|
422
|
-
* The same shape `describeSelection` uses for bundles, for the same reason —
|
|
423
|
-
* naming everything turned a four-line review screen into a fifteen-line one,
|
|
424
|
-
* and a screen nobody reads to the bottom is worse at conveying what is about
|
|
425
|
-
* to happen than a short one. Two names say what KIND of thing these are;
|
|
426
|
-
* `failproofai policies list` says the rest, for the person who wants it.
|
|
427
|
-
*
|
|
428
|
-
* The whole review body is rendered dim by the prompt (`tui.ts`), so this
|
|
429
|
-
* reads as a subtitle to the count above it rather than competing with it.
|
|
430
|
-
*
|
|
431
|
-
* Degrades by dropping names rather than by overflowing: `writeLines`
|
|
432
|
-
* truncates with a hard cut and no ellipsis, so a line that runs over ends
|
|
433
|
-
* mid-slug and reads as a policy name that does not exist. Two long names
|
|
434
|
-
* (`sanitize-private-key-content` is 28) plus the prefix is within budget, but
|
|
435
|
-
* the guard is here rather than argued about, because a future rename is
|
|
436
|
-
* exactly the kind of change nobody re-measures.
|
|
437
|
-
*/
|
|
438
|
-
export function policyNamesLine(names: string[]): string[] {
|
|
439
|
-
if (names.length === 0) return [];
|
|
440
|
-
const budget = MAX_SUMMARY_COLUMNS - POLICY_LIST_INDENT.length;
|
|
441
|
-
for (const take of [2, 1]) {
|
|
442
|
-
const shown = names.slice(0, take);
|
|
443
|
-
const rest = names.length - shown.length;
|
|
444
|
-
const text = rest > 0 ? `${shown.join(", ")} +${rest}` : shown.join(", ");
|
|
445
|
-
if (text.length <= budget) return [`${POLICY_LIST_INDENT}${text}`];
|
|
446
|
-
}
|
|
447
|
-
return [];
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** The Policies line's value. With policies no longer chosen during setup, this
|
|
451
|
-
* reports what is enabled and nothing about how it got that way. */
|
|
452
|
-
function describeSelection(policiesCount: number): string {
|
|
453
|
-
return `${policiesCount} ${policiesCount === 1 ? "policy" : "policies"}`;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
export function reviewLines(state: {
|
|
458
|
-
/** What the scope step resolved to. Expands to one or two real scopes. */
|
|
459
|
-
target: SetupTarget;
|
|
460
|
-
clis: IntegrationType[];
|
|
461
|
-
policies: string[];
|
|
462
|
-
cwd: string;
|
|
463
|
-
/** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
|
|
464
|
-
customEnabled?: boolean;
|
|
465
|
-
/**
|
|
466
|
-
* Whether a daemon will be installed by this run. False when one is already
|
|
467
|
-
* healthy (nothing to do) or the platform has no service manager — showing
|
|
468
|
-
* it any other way would promise work the apply will not perform.
|
|
469
|
-
*/
|
|
470
|
-
installDaemon?: boolean;
|
|
471
|
-
/** Whether an API key will be written and reporting turned on. */
|
|
472
|
-
connect?: boolean;
|
|
473
|
-
}): string[] {
|
|
474
|
-
const { target, clis, policies, cwd, customEnabled, installDaemon, connect } = state;
|
|
475
|
-
const scopes = scopesFor(target);
|
|
476
|
-
const where =
|
|
477
|
-
target === "both"
|
|
478
|
-
? `Global, plus this project (${homeify(cwd)})`
|
|
479
|
-
: target === "project"
|
|
480
|
-
? `This project (${homeify(cwd)})`
|
|
481
|
-
: "Everywhere (global)";
|
|
482
|
-
const lines: string[] = [];
|
|
483
|
-
const harnessNames = clis.map((c) => getIntegration(c).displayName);
|
|
484
|
-
lines.push(` Where : ${where}`);
|
|
485
|
-
lines.push(` Harnesses : ${harnessNames.length ? summarize(harnessNames, "harnesses") : "(none)"}`);
|
|
486
|
-
// Zero is a deliberate answer, not a failed step — say so, and say where to
|
|
487
|
-
// change it, so the review screen doesn't read like the wizard lost the
|
|
488
|
-
// selection. Hooks still install; only the builtin set is empty.
|
|
489
|
-
lines.push(
|
|
490
|
-
policies.length === 0
|
|
491
|
-
? " Policies : none enabled (add later: failproofai policies --install)"
|
|
492
|
-
: ` Policies : ${policies.length} enabled`,
|
|
493
|
-
);
|
|
494
|
-
// A taste of what they are, under the count. "15 enabled" alone is a number
|
|
495
|
-
// the user cannot check and, on the recommended path, did not choose; two
|
|
496
|
-
// names say what kind of thing it is without turning the review into a page.
|
|
497
|
-
if (policies.length > 0) {
|
|
498
|
-
lines.push(...policyNamesLine([...policies].sort()));
|
|
499
|
-
}
|
|
500
|
-
if (installDaemon && isDaemonSupportedPlatform()) {
|
|
501
|
-
lines.push(
|
|
502
|
-
` Daemon : failproofaid, installed as a system service running as you`,
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
lines.push(
|
|
506
|
-
connect
|
|
507
|
-
? " Reporting : on — policy decisions and session transcripts"
|
|
508
|
-
: " Reporting : off — nothing leaves this machine",
|
|
509
|
-
);
|
|
510
|
-
|
|
511
|
-
// Reflect the Custom decision, not just what is on disk. Reporting
|
|
512
|
-
// "1 file (project) (auto-loaded)" after the user had just unticked the row
|
|
513
|
-
// stated the opposite of what was about to happen.
|
|
514
|
-
const custom = describeCustomPolicies(cwd);
|
|
515
|
-
if (custom.active.length > 0) {
|
|
516
|
-
lines.push(
|
|
517
|
-
` Custom : ${custom.active.join(" · ")} ${
|
|
518
|
-
customEnabled === false ? "— DISABLED, will not load" : DIM_NOTE
|
|
519
|
-
}`,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
for (const warning of custom.warnings) lines.push(` ${warning}`);
|
|
523
|
-
|
|
524
|
-
lines.push("");
|
|
525
|
-
lines.push(" This will update:");
|
|
526
|
-
// Deduplicated across scopes: a CLI that supports only user scope resolves to
|
|
527
|
-
// the same settings file under both halves of "Both", and listing it twice
|
|
528
|
-
// reads as two separate writes.
|
|
529
|
-
const seen = new Set<string>();
|
|
530
|
-
for (const scope of scopes) {
|
|
531
|
-
for (const cli of clis) {
|
|
532
|
-
const integration = getIntegration(cli);
|
|
533
|
-
if (!integration.scopes.includes(scope)) continue;
|
|
534
|
-
// Usually one path; Hermes lists one per profile so the operator sees
|
|
535
|
-
// every home dir that is about to be written.
|
|
536
|
-
for (const p of settingsPathsFor(integration, scope, cwd)) {
|
|
537
|
-
if (seen.has(p)) continue;
|
|
538
|
-
seen.add(p);
|
|
539
|
-
lines.push(` ${homeify(p)} ${integration.displayName} hooks`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
for (const scope of scopes) {
|
|
544
|
-
const configPath = getConfigPathForScope(scope, cwd);
|
|
545
|
-
if (seen.has(configPath)) continue;
|
|
546
|
-
seen.add(configPath);
|
|
547
|
-
lines.push(` ${homeify(configPath)} ${policies.length} policies`);
|
|
548
|
-
}
|
|
549
|
-
if (installDaemon && isDaemonSupportedPlatform()) {
|
|
550
|
-
const servicePath = daemonServiceFilePath();
|
|
551
|
-
if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service (needs root)`);
|
|
552
|
-
}
|
|
553
|
-
return lines;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
535
|
// ── First-run redirect ───────────────────────────────────────────────────────
|
|
557
536
|
|
|
558
537
|
function firstRunMarkerPath(): string {
|
|
@@ -670,7 +649,7 @@ export async function maybeFirstRunConfigure(
|
|
|
670
649
|
// and it must stay a hint rather than a failure: the command the user
|
|
671
650
|
// actually typed still runs.
|
|
672
651
|
stdout.write(
|
|
673
|
-
`\n
|
|
652
|
+
`\n${screenKit(optsFor(stdout)).caution("failproofai is not set up yet.", "failproofai config")}\n\n`,
|
|
674
653
|
);
|
|
675
654
|
return false;
|
|
676
655
|
}
|
|
@@ -681,7 +660,7 @@ export async function maybeFirstRunConfigure(
|
|
|
681
660
|
const lock = acquireOnboardingLock();
|
|
682
661
|
if (!lock) {
|
|
683
662
|
stdout.write(
|
|
684
|
-
`\n
|
|
663
|
+
`\n${screenKit(optsFor(stdout)).caution("Setup is already running in another terminal, so this one leaves it to finish.")}\n\n`,
|
|
685
664
|
);
|
|
686
665
|
return false;
|
|
687
666
|
}
|
|
@@ -730,31 +709,22 @@ export async function runConfigureWizard(
|
|
|
730
709
|
const stdin: TTYIn = io.stdin ?? process.stdin;
|
|
731
710
|
const stdout: TTYOut = io.stdout ?? process.stdout;
|
|
732
711
|
const cwd = process.cwd();
|
|
712
|
+
const home = process.env.HOME || homedir();
|
|
713
|
+
const started = Date.now();
|
|
733
714
|
// No terminal means no questions — not a refusal.
|
|
734
715
|
//
|
|
735
716
|
// `failproofai config` IS the authorisation: somebody typed the command whose
|
|
736
|
-
// entire job is to configure this machine.
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
// Safe to do here because the IMPLICIT path is guarded separately:
|
|
742
|
-
// `maybeFirstRunConfigure` has its own TTY check and returns before ever
|
|
743
|
-
// reaching this function, so setup never runs off the back of some other
|
|
744
|
-
// command on a headless box.
|
|
717
|
+
// entire job is to configure this machine. Safe because the IMPLICIT path is
|
|
718
|
+
// guarded separately: `maybeFirstRunConfigure` has its own TTY check and
|
|
719
|
+
// returns before ever reaching this function, so setup never runs off the back
|
|
720
|
+
// of some other command on a headless box.
|
|
745
721
|
const unattended = !stdin.isTTY || !stdout.isTTY;
|
|
746
722
|
// A key on the command line settles the whole run, not just the question it
|
|
747
|
-
// literally answers.
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
// machine up, connect it, send its data. There is nothing left to confirm —
|
|
751
|
-
// so asking "Connect to Cloud?", then the key, then "Ready to apply?" made
|
|
752
|
-
// the flag look like it had not been read. The only thing still worth
|
|
753
|
-
// stopping for is the sudo password, which is a CREDENTIAL rather than a
|
|
754
|
-
// question, and no flag can supply it.
|
|
723
|
+
// literally answers: set this machine up, connect it, send its data. The only
|
|
724
|
+
// thing still worth stopping for is the sudo password, which is a CREDENTIAL
|
|
725
|
+
// rather than a question, and no flag can supply it.
|
|
755
726
|
const preAnswered = Boolean(answers.token);
|
|
756
727
|
|
|
757
|
-
|
|
758
728
|
// Running the wizard itself under sudo configures the WRONG ACCOUNT, and
|
|
759
729
|
// does it silently: homedir() becomes /root, so the hooks land in root's
|
|
760
730
|
// settings, `daemonConfigured` is set for root, the daemon binary downloads
|
|
@@ -770,9 +740,6 @@ export async function runConfigureWizard(
|
|
|
770
740
|
"The one step that needs root (installing the service) asks for your password\n" +
|
|
771
741
|
"on its own.\n",
|
|
772
742
|
);
|
|
773
|
-
// Same reason as above: this configured nothing, and it is not a
|
|
774
|
-
// cancellation. `running_as_sudo` has been in `WizardAbort` since it was
|
|
775
|
-
// written and was never once assigned.
|
|
776
743
|
return { applied: false, abort: "running_as_sudo" };
|
|
777
744
|
}
|
|
778
745
|
|
|
@@ -780,10 +747,10 @@ export async function runConfigureWizard(
|
|
|
780
747
|
void emit("configure_started", {});
|
|
781
748
|
|
|
782
749
|
// failproofaid — the only evaluator on a configured machine — only runs on
|
|
783
|
-
// Linux and macOS. Checked before
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
750
|
+
// Linux and macOS. Checked before a single prompt is asked: completing setup
|
|
751
|
+
// anyway used to leave e.g. a Windows machine reading as configured while
|
|
752
|
+
// enforcing in-process with no fail-closed guarantee, which is worse than not
|
|
753
|
+
// being set up at all.
|
|
787
754
|
if (!isDaemonSupportedPlatform()) {
|
|
788
755
|
stdout.write(
|
|
789
756
|
`failproofai requires failproofaid, its background policy daemon, which runs on\n` +
|
|
@@ -797,614 +764,440 @@ export async function runConfigureWizard(
|
|
|
797
764
|
return { applied: false, abort: "unsupported_platform" };
|
|
798
765
|
}
|
|
799
766
|
|
|
800
|
-
|
|
767
|
+
const c = paint(colorsEnabled(stdout));
|
|
768
|
+
const kit = screenKit({ ...optsFor(stdout), fit: Boolean(stdout.isTTY), version: cliVersion });
|
|
769
|
+
const write = (lines: string[]): void => {
|
|
770
|
+
stdout.write(lines.map((line) => `${line}\n`).join(""));
|
|
771
|
+
};
|
|
772
|
+
// A grey line saying what apply is doing right now, removed once it is done.
|
|
773
|
+
// Without a terminal it stays, as a log line.
|
|
774
|
+
const transient = async <T,>(text: string, run: () => Promise<T>): Promise<T> => {
|
|
775
|
+
if (!stdout.isTTY) {
|
|
776
|
+
write([text]);
|
|
777
|
+
return run();
|
|
778
|
+
}
|
|
779
|
+
write([c.ink3(text)]);
|
|
780
|
+
try {
|
|
781
|
+
return await run();
|
|
782
|
+
} finally {
|
|
783
|
+
stdout.write("\x1b[1A\x1b[2K");
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// The logomark opens a config run on a terminal — one of the two places it
|
|
788
|
+
// appears at all.
|
|
789
|
+
if (stdout.isTTY) write(["", ...kit.logo()]);
|
|
790
|
+
write(["", kit.header("Set up this machine"), ""]);
|
|
801
791
|
|
|
802
792
|
const cancel = (): WizardResult => {
|
|
803
|
-
|
|
793
|
+
write(["", c.ink3("Cancelled. Nothing was changed.")]);
|
|
804
794
|
// Distinguished from the abort reasons: pressing Esc is not a failure, and
|
|
805
795
|
// a caller picking an exit code must not treat it as one.
|
|
806
796
|
return { applied: false, abort: "cancelled" };
|
|
807
797
|
};
|
|
808
798
|
|
|
809
|
-
//
|
|
799
|
+
// ── DAEMON ──────────────────────────────────────────────────────────────────
|
|
810
800
|
//
|
|
811
801
|
// First because it is the only step that needs a password: asking here means
|
|
812
|
-
// sudo prompts on a clean terminal, before any question has drawn a screen
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
// apply time stays non-interactive.
|
|
817
|
-
//
|
|
818
|
-
// Machine-level, so it is deliberately NOT gated on the scope chosen in the
|
|
819
|
-
// next step: one daemon serves every project on this machine.
|
|
820
|
-
//
|
|
821
|
-
// Always true here — the guard near the top of this function already
|
|
822
|
-
// refused setup on anything else. Kept as a real read (not a literal
|
|
823
|
-
// `true`) so this block still fails safe if that guard is ever moved.
|
|
824
|
-
const daemonSupported = isDaemonSupportedPlatform();
|
|
825
|
-
// An already-healthy daemon needs no install and no password. Re-running
|
|
826
|
-
// setup on a configured machine must not demand sudo for work that is
|
|
827
|
-
// already done.
|
|
828
|
-
// "running" only. A unit that exists but is stopped or crash-looping is
|
|
829
|
-
// exactly the machine that needs this run to reinstall it, and treating
|
|
830
|
-
// "installed" as good enough would skip the repair and then set
|
|
831
|
-
// `daemonConfigured` against a service that is not answering — which fails
|
|
832
|
-
// closed on every tool call.
|
|
833
|
-
// Running is not enough — it must also be the version this CLI ships.
|
|
802
|
+
// sudo prompts on a clean terminal, before any question has drawn a screen.
|
|
803
|
+
// `sudo -v` caches the credential for the rest of the run, so the install at
|
|
804
|
+
// apply time stays non-interactive. Machine-level: one daemon serves every
|
|
805
|
+
// project on this machine.
|
|
834
806
|
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
// every tool call with no route back but hand-editing `config.json`. A real
|
|
848
|
-
// hook evaluation is the only check that distinguishes the two.
|
|
849
|
-
const daemonSkew = daemonSupported ? daemonVersionSkew() : null;
|
|
850
|
-
const daemonState = daemonSupported ? daemonServiceStatus() : "unsupported-platform";
|
|
851
|
-
// `unknown` is "the service state could not be READ", which only macOS
|
|
852
|
-
// produces: a LaunchDaemon's state lives in launchd's system domain and needs
|
|
853
|
-
// root to read, so a sudo cache older than five minutes says nothing about
|
|
854
|
-
// whether the daemon is up. Treating that as "stopped" made the wizard demand
|
|
855
|
-
// a password and unload/reload a perfectly healthy service — a real
|
|
856
|
-
// fail-closed window on a `daemonConfigured` machine, opened to fix nothing,
|
|
857
|
-
// and a direct breach of this file's own rule that setup must not demand sudo
|
|
858
|
-
// for work already done. So it is probed rather than assumed: the probe is a
|
|
859
|
-
// real hook evaluation over the socket, needs no privileges, and answers the
|
|
860
|
-
// question the status check was only standing in for.
|
|
861
|
-
const daemonMaybeUp =
|
|
862
|
-
daemonSupported && (daemonState === "running" || daemonState === "unknown") && daemonSkew === null;
|
|
807
|
+
// An already-healthy daemon needs no install and no password — but healthy
|
|
808
|
+
// means running, the version this CLI ships, AND able to answer. Running
|
|
809
|
+
// alone skipped the stale version during an upgrade; the right version alone
|
|
810
|
+
// waved through a unit whose worker dies on every spawn (`ExecStart` bakes in
|
|
811
|
+
// `process.execPath`, so an `nvm uninstall` breaks it). A real hook
|
|
812
|
+
// evaluation is the only check that tells those apart. `unknown` is macOS's
|
|
813
|
+
// "the state could not be READ" (launchd's system domain needs root), so it is
|
|
814
|
+
// probed rather than assumed down: reinstalling a healthy service opens a
|
|
815
|
+
// fail-closed window to fix nothing.
|
|
816
|
+
const daemonSkew = daemonVersionSkew();
|
|
817
|
+
const daemonState = daemonServiceStatus();
|
|
818
|
+
const daemonMaybeUp = (daemonState === "running" || daemonState === "unknown") && daemonSkew === null;
|
|
863
819
|
const daemonAnswers = daemonMaybeUp ? await probeDaemonEndToEnd() : false;
|
|
864
820
|
const daemonAlreadyRunning = daemonMaybeUp && daemonAnswers;
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
* Keyed on a DEFINITE `running` reading, not on `daemonMaybeUp`: this is the
|
|
869
|
-
* branch that tears the service down before rebuilding, and the justification
|
|
870
|
-
* for that is knowing a live process is holding the singleton flock. An
|
|
871
|
-
* unreadable state is not that knowledge, and the plain install path already
|
|
872
|
-
* unloads before it writes.
|
|
873
|
-
*/
|
|
821
|
+
// Installed and running, but its worker cannot evaluate anything. Keyed on a
|
|
822
|
+
// DEFINITE `running`: this is the branch that tears the service down before
|
|
823
|
+
// rebuilding, justified only by knowing a live process holds the flock.
|
|
874
824
|
const daemonBroken = daemonState === "running" && daemonSkew === null && !daemonAnswers;
|
|
875
|
-
|
|
876
|
-
// A healthy daemon can still
|
|
877
|
-
// FAILPROOFAI_CLI_CMD existed, and nothing else
|
|
878
|
-
//
|
|
879
|
-
// Re-running setup is the one moment a user asks for their configuration to
|
|
880
|
-
// be brought up to date, so it is the moment to do it.
|
|
825
|
+
const daemonWanted = !daemonAlreadyRunning;
|
|
826
|
+
// A healthy daemon can still run a service definition written before
|
|
827
|
+
// FAILPROOFAI_CLI_CMD existed, and nothing else ever rewrites it: upgrading
|
|
828
|
+
// the npm package does not touch /etc/systemd/system.
|
|
881
829
|
let daemonUnitStale = daemonAlreadyRunning && daemonServiceNeedsUpgrade();
|
|
882
830
|
|
|
831
|
+
// `primeElevation` runs `sudo -v`, which PROMPTS. An unattended run has
|
|
832
|
+
// nobody to type a password, so it goes straight to the `sudo -n` the install
|
|
833
|
+
// uses anyway, and a machine that cannot elevate gets the commands instead of
|
|
834
|
+
// a hung terminal.
|
|
835
|
+
const elevate = (ask: string | null): boolean => {
|
|
836
|
+
if (unattended) {
|
|
837
|
+
if (ask) write([ask]);
|
|
838
|
+
return canElevate();
|
|
839
|
+
}
|
|
840
|
+
// The prompt is erased afterwards, with the line asking for it, so the step
|
|
841
|
+
// collapses to the one line that says how it went.
|
|
842
|
+
const rooted = collapseAfter(stdout, 10, () => {
|
|
843
|
+
if (ask) stdout.write(`${ask}\n`);
|
|
844
|
+
return primeElevation();
|
|
845
|
+
});
|
|
846
|
+
// Erased on failure too, so it is said again where the failure is.
|
|
847
|
+
if (!rooted && ask) write([ask]);
|
|
848
|
+
return rooted;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
write([kit.head("Daemon")]);
|
|
883
852
|
if (daemonWanted) {
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
// This block explained the warm-worker architecture to somebody who is
|
|
887
|
-
// about to type a password — three lines of mechanism answering a question
|
|
888
|
-
// nobody had. The install is happening either way; what they need is what
|
|
889
|
-
// it is and that it costs one sudo.
|
|
890
|
-
//
|
|
891
|
-
// The BROKEN case keeps its consequence, and only that: "every tool call is
|
|
853
|
+
// The broken case keeps its consequence on screen: "every tool call is
|
|
892
854
|
// denied" is the difference between a thirty-second fix and a support
|
|
893
|
-
// thread, so it
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
// does — and a machine that cannot elevate gets the commands below instead
|
|
903
|
-
// of a hung terminal. This is the same answer for both: sudo cannot be
|
|
904
|
-
// conjured away, only asked for at a moment somebody can answer.
|
|
905
|
-
if (!(unattended ? canElevate() : primeElevation())) {
|
|
855
|
+
// thread, so it is printed outside the region the sudo prompt is erased
|
|
856
|
+
// from.
|
|
857
|
+
if (daemonBroken) write(["failproofaid can't evaluate — every tool call is denied. Rebuilding needs sudo."]);
|
|
858
|
+
const ask = daemonBroken
|
|
859
|
+
? null
|
|
860
|
+
: daemonSkew
|
|
861
|
+
? `Updating failproofaid from ${daemonSkew.installed} to ${daemonSkew.expected} needs root once.`
|
|
862
|
+
: "Installing failproofaid needs root once.";
|
|
863
|
+
if (!elevate(ask)) {
|
|
906
864
|
// Required means required: write nothing at all, so a machine that could
|
|
907
|
-
// not be set up is left exactly as it was found
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
` Check what it needs: ${daemonStatusCommand() ?? "n/a"}\n\n`,
|
|
914
|
-
);
|
|
865
|
+
// not be set up is left exactly as it was found.
|
|
866
|
+
write([
|
|
867
|
+
kit.fail("Couldn't get root, so setup stopped before changing anything."),
|
|
868
|
+
` Re-run once you can use sudo: ${kit.cmd("failproofai config")}`,
|
|
869
|
+
` Check what it needs: ${kit.cmd(daemonStatusCommand() ?? "n/a")}`,
|
|
870
|
+
]);
|
|
915
871
|
void emit("configure_aborted", { reason: "needs_root" });
|
|
916
|
-
outro("Nothing was changed.", { ok: false }, stdout);
|
|
917
872
|
return { applied: false, abort: "needs_root" };
|
|
918
873
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
874
|
+
write([
|
|
875
|
+
kit.ok(
|
|
876
|
+
daemonBroken
|
|
877
|
+
? "Root granted to rebuild failproofaid"
|
|
878
|
+
: daemonSkew
|
|
879
|
+
? `Root granted to update failproofaid to ${daemonSkew.expected}`
|
|
880
|
+
: "Root granted to install failproofaid",
|
|
881
|
+
),
|
|
882
|
+
]);
|
|
883
|
+
} else {
|
|
884
|
+
write([kit.ok(`failproofaid ${cliVersion} is already running`)]);
|
|
885
|
+
if (daemonUnitStale) {
|
|
886
|
+
if (elevate("Its service definition is from an older version. Refreshing it needs root once.")) {
|
|
887
|
+
write([kit.ok("Root granted to refresh its service definition")]);
|
|
888
|
+
} else {
|
|
889
|
+
// NOT an abort: hooks are enforcing through a working daemon; only the
|
|
890
|
+
// scheduled audit is out of reach. Stopping setup over that would make
|
|
891
|
+
// an upgrade the thing that locked someone out of `failproofai config`.
|
|
892
|
+
write([kit.caution("No root, so its service definition stays as it is. Scheduled audits stay off until it is refreshed.")]);
|
|
893
|
+
daemonUnitStale = false;
|
|
894
|
+
}
|
|
935
895
|
}
|
|
936
|
-
} else if (daemonAlreadyRunning) {
|
|
937
|
-
stdout.write("failproofaid is already installed and running — leaving it alone.\n\n");
|
|
938
|
-
}
|
|
939
|
-
if (daemonSkew) {
|
|
940
|
-
stdout.write(
|
|
941
|
-
`failproofaid is ${daemonSkew.installed} but this CLI ships ${daemonSkew.expected} — ` +
|
|
942
|
-
`reinstalling it.\n\n`,
|
|
943
|
-
);
|
|
944
896
|
}
|
|
897
|
+
write([""]);
|
|
945
898
|
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
// ONE linear flow, and no opening fork.
|
|
949
|
-
//
|
|
950
|
-
// Setup used to ask "Recommended or Customize?" before anything else, which
|
|
951
|
-
// is a question about the wizard rather than about the machine — you cannot
|
|
952
|
-
// answer it until you know what the alternatives are, and you only learn that
|
|
953
|
-
// by picking one. Recommended then silently took global scope, the detected
|
|
954
|
-
// CLIs, and fifteen policies nobody had seen.
|
|
899
|
+
// ── CONNECT TO CLOUD ────────────────────────────────────────────────────────
|
|
955
900
|
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
// among them: it is GLOBAL, always. A project-scoped install guards the one
|
|
960
|
-
// directory the command happened to be run from and silently leaves every
|
|
961
|
-
// other repo on the machine unguarded — `failproofai policies --install
|
|
962
|
-
// --scope project` is still there for someone who genuinely wants that, and
|
|
963
|
-
// knows they do.
|
|
964
|
-
const target: SetupTarget = "user";
|
|
965
|
-
const scopes = scopesFor(target);
|
|
966
|
-
const primaryScope: HookScope = scopes.includes("project") ? "project" : "user";
|
|
967
|
-
|
|
968
|
-
// 2 — Which harnesses?
|
|
901
|
+
// One masked field, with Tab as the way out. The key is checked here and
|
|
902
|
+
// written at apply, after the last question, so Esc anywhere before that
|
|
903
|
+
// still changes nothing (decision D3).
|
|
969
904
|
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
const
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const clis: IntegrationType[] = [...clisSupportingScope(primaryScope)];
|
|
1012
|
-
|
|
1013
|
-
// 4 — Connect this machine? Last, because by this point the user has decided
|
|
1014
|
-
// what to protect, so "would you like to see it in a dashboard?" follows
|
|
1015
|
-
// naturally — asking up front interrupts setup with a question about a
|
|
1016
|
-
// product they may not have.
|
|
1017
|
-
//
|
|
1018
|
-
// A pasted API key rather than an interactive sign-in: it is the only form
|
|
1019
|
-
// that works on a headless box, in a container or over SSH, and it is the
|
|
1020
|
-
// same credential `failproofai config --connect` takes, so a machine set up
|
|
1021
|
-
// by the wizard and one set up by hand end up byte-identical on disk.
|
|
1022
|
-
//
|
|
1023
|
-
// Connecting turns on BOTH streams — policy decisions and session
|
|
1024
|
-
// transcripts. That is a real disclosure, not a footnote, so it is stated in
|
|
1025
|
-
// the body of the question itself rather than buried in an option hint.
|
|
1026
|
-
let connect: { url: string; token: string; machineId: string; machineLabel: string } | null = null;
|
|
1027
|
-
|
|
1028
|
-
{
|
|
1029
|
-
// Supplying a key IS asking to connect — there is no other reason to pass
|
|
1030
|
-
// one — so an unattended run with a token skips straight past the question,
|
|
1031
|
-
// and one without a token stays local, which is the same "Not now" branch a
|
|
1032
|
-
// person picks. Neither needs a separate flag to say so.
|
|
1033
|
-
const choice = answers.token
|
|
1034
|
-
// A key on the command line IS the answer to this question. Gating it on
|
|
1035
|
-
// there being no terminal was wrong: `--token` means "use this key", not
|
|
1036
|
-
// "use this key only if nobody is watching", and a run that asked anyway
|
|
1037
|
-
// made the flag look broken to the person who had just typed it.
|
|
1038
|
-
? "key"
|
|
1039
|
-
: unattended
|
|
1040
|
-
? "local"
|
|
1041
|
-
: await selectOne<"key" | "local">({
|
|
1042
|
-
message: "Connect this machine to FailproofAI Cloud?",
|
|
1043
|
-
// The hint says what you GET; the body says what LEAVES.
|
|
1044
|
-
//
|
|
1045
|
-
// Both have to be here and they are different jobs. "sends decisions +
|
|
1046
|
-
// transcripts" described the plumbing, which reads like a data-collection
|
|
1047
|
-
// notice rather than a feature — the reason anyone connects is to see
|
|
1048
|
-
// what their agents did, so the option says that.
|
|
1049
|
-
//
|
|
1050
|
-
// The disclosure stays in the BODY rather than moving into the hint,
|
|
1051
|
-
// which was the original design and is right: this screen is the only
|
|
1052
|
-
// place it is ever made. `describeOutcome` prints "hook activity" after
|
|
1053
|
-
// connecting and never mentions transcripts, so a person who does not
|
|
1054
|
-
// read it here does not read it at all. One line, not three, and the
|
|
1055
|
-
// specific nouns survive — "sessions" alone would not tell anyone their
|
|
1056
|
-
// file contents leave the machine.
|
|
1057
|
-
//
|
|
1058
|
-
// The get-started page, not the dashboard host.
|
|
1059
|
-
//
|
|
1060
|
-
// `app.befailproof.ai` is only useful to somebody who already has an
|
|
1061
|
-
// account, and this line addresses the person who has none — new to the
|
|
1062
|
-
// product, so no org either, not merely missing a key. The get-started
|
|
1063
|
-
// route walks them through creating both, so it answers the question
|
|
1064
|
-
// actually being asked. Naming the product rather than the artefact is
|
|
1065
|
-
// what makes it self-selecting: "No key?" reads as an error state to
|
|
1066
|
-
// someone who simply has not signed up yet.
|
|
1067
|
-
//
|
|
1068
|
-
// A marketing route rather than a dashboard one is also the safer thing
|
|
1069
|
-
// to hard-code here — it is the URL the site is expected to keep stable,
|
|
1070
|
-
// where an in-app path can be reorganised without anyone thinking to
|
|
1071
|
-
// update a string compiled into a CLI.
|
|
1072
|
-
//
|
|
1073
|
-
// The scope is gone from this screen: it is enforced at paste time, where
|
|
1074
|
-
// a wrong key is actually caught.
|
|
1075
|
-
body: [
|
|
1076
|
-
" Sessions include prompts, file contents and command output.",
|
|
1077
|
-
" New to FailproofAI? Create a key at https://befailproof.ai/get-started/",
|
|
1078
|
-
],
|
|
1079
|
-
// Cloud first, and therefore preselected: connecting is what most people
|
|
1080
|
-
// running this wizard came to do, and the local path stays one keystroke
|
|
1081
|
-
// away. Reversing these two is the whole change — neither option's copy
|
|
1082
|
-
// moved, so "stay local" is still stated as plainly as it was.
|
|
1083
|
-
choices: [
|
|
1084
|
-
{
|
|
1085
|
-
// What the cloud gives that this machine does not ALREADY have.
|
|
1086
|
-
//
|
|
1087
|
-
// "see what your agents did" was wrong: the local dashboard already
|
|
1088
|
-
// shows that, so it described something the user gets either way and
|
|
1089
|
-
// made connecting look redundant. The cloud's two jobs are a fleet
|
|
1090
|
-
// seen in one place and policies deployed to it from there — which is
|
|
1091
|
-
// also exactly what the key's two scopes buy (`events:add`,
|
|
1092
|
-
// `policies:pull`).
|
|
1093
|
-
label: "Paste an API key",
|
|
1094
|
-
value: "key",
|
|
1095
|
-
hint: "central monitoring · deploy policies from the dashboard",
|
|
1096
|
-
},
|
|
1097
|
-
{
|
|
1098
|
-
// "re-run config", NOT "--connect". That flag exists for scripted,
|
|
1099
|
-
// non-interactive setup; pointing a person who just declined at a
|
|
1100
|
-
// flag they would have to look up is worse than naming the command
|
|
1101
|
-
// they already ran.
|
|
1102
|
-
label: "Not now — stay local",
|
|
1103
|
-
value: "local",
|
|
1104
|
-
hint: "nothing leaves this machine · re-run config to add",
|
|
1105
|
-
},
|
|
1106
|
-
],
|
|
1107
|
-
stdin,
|
|
1108
|
-
stdout,
|
|
905
|
+
// Tab never disconnects (decision D4): on a connected machine it leaves the
|
|
906
|
+
// connection as it is, and `failproofai config --disconnect` is the one way to
|
|
907
|
+
// remove it. Enter on an empty field keeps the saved key exactly as it is too
|
|
908
|
+
// — no `connectToCloud`, which would reset collector settings — after
|
|
909
|
+
// checking that it still works.
|
|
910
|
+
const saved = savedConnection();
|
|
911
|
+
const urlChoice = resolveCloudUrl(answers.url, saved?.url);
|
|
912
|
+
// An unusable --url or FAILPROOFAI_CLOUD_URL is refused loudly, never a silent
|
|
913
|
+
// fall-back to the hosted service: whoever set it wants THAT endpoint, and
|
|
914
|
+
// reporting the machine elsewhere is what they did not ask. But only where a
|
|
915
|
+
// key would be checked or sent. `--oss`, a keyless unattended run and Tab send
|
|
916
|
+
// nothing, so stopping them over an endpoint they never use set up nothing.
|
|
917
|
+
const url = urlChoice.ok ? urlChoice.url : undefined;
|
|
918
|
+
const badUrl = urlChoice.ok ? undefined : urlChoice.message;
|
|
919
|
+
const host = url ? hostOf(url) : "no usable endpoint";
|
|
920
|
+
const source = urlChoice.ok ? urlChoice.source : undefined;
|
|
921
|
+
const refuseUrl = (message: string): WizardResult => {
|
|
922
|
+
write([kit.head("Connect to cloud"), kit.fail(message)]);
|
|
923
|
+
void emit("configure_aborted", { reason: "bad_cloud_url" });
|
|
924
|
+
// A failure, not a cancel: a run that asked to connect and set up nothing
|
|
925
|
+
// must not exit 0.
|
|
926
|
+
return { applied: false, abort: "bad_cloud_url" };
|
|
927
|
+
};
|
|
928
|
+
const machineId = resolveMachineId(answers.machineId);
|
|
929
|
+
const machineLabel = answers.machineLabel ?? saved?.machineLabel ?? resolveMachineLabel();
|
|
930
|
+
|
|
931
|
+
let typedOrg: string | undefined;
|
|
932
|
+
const planFor = (answer: CloudKeyAnswer | OpenSource): CloudPlan =>
|
|
933
|
+
answer === OPEN_SOURCE
|
|
934
|
+
? saved
|
|
935
|
+
? { kind: "kept", how: "skip", verified: false }
|
|
936
|
+
: { kind: "oss" }
|
|
937
|
+
: answer.kind === "saved"
|
|
938
|
+
? { kind: "kept", how: "saved", verified: answer.verified }
|
|
939
|
+
: { kind: "connect", token: answer.key, org: typedOrg, note: answer.note };
|
|
940
|
+
const cloudLines = (plan: CloudPlan): string[] =>
|
|
941
|
+
cloudCollapsedLines(kit, plan, {
|
|
942
|
+
host,
|
|
943
|
+
savedHost: saved ? hostOf(saved.url) : host,
|
|
944
|
+
savedOrg: saved?.org,
|
|
945
|
+
source,
|
|
1109
946
|
});
|
|
1110
|
-
if (choice === null) return cancel();
|
|
1111
|
-
|
|
1112
|
-
if (choice === "key") {
|
|
1113
|
-
// An already-enrolled machine has a URL and token that usually work.
|
|
1114
|
-
// Asking again is the seam that made connecting feel like two products.
|
|
1115
|
-
const existing = readCloudCredentials();
|
|
1116
|
-
let url: string | null = null;
|
|
1117
|
-
let token: string | null = null;
|
|
1118
|
-
// Reuse the enrolled id if there is one, else mint a stable key — never
|
|
1119
|
-
// the hostname, so two hosts with the same name do not merge. The hostname
|
|
1120
|
-
// becomes the human label instead.
|
|
1121
|
-
let machineId = resolveMachineId();
|
|
1122
|
-
let machineLabel = existing?.machineLabel ?? resolveMachineLabel();
|
|
1123
|
-
|
|
1124
|
-
if (existing && unattended && !answers.url) {
|
|
1125
|
-
// Already enrolled, and this run did not name a different place to
|
|
1126
|
-
// enrol. Reusing is what the person picks here too, and re-verifying a
|
|
1127
|
-
// working credential is the one thing an unattended re-run must not
|
|
1128
|
-
// turn into a failure.
|
|
1129
|
-
url = existing.url;
|
|
1130
|
-
token = answers.token ?? existing.token;
|
|
1131
|
-
machineId = answers.machineId ?? existing.machineId;
|
|
1132
|
-
machineLabel = answers.machineLabel ?? existing.machineLabel ?? machineLabel;
|
|
1133
|
-
} else if (existing && !unattended) {
|
|
1134
|
-
const reuse = await selectOne<"reuse" | "other">({
|
|
1135
|
-
message: `Use this machine's existing connection to ${existing.url}?`,
|
|
1136
|
-
choices: [
|
|
1137
|
-
{
|
|
1138
|
-
label: "Yes — reuse it",
|
|
1139
|
-
value: "reuse",
|
|
1140
|
-
hint: `as ${existing.machineLabel ?? existing.machineId}, same token`,
|
|
1141
|
-
},
|
|
1142
|
-
{ label: "No — different endpoint or key", value: "other", hint: "" },
|
|
1143
|
-
],
|
|
1144
|
-
stdin,
|
|
1145
|
-
stdout,
|
|
1146
|
-
});
|
|
1147
|
-
if (reuse === null) return cancel();
|
|
1148
|
-
if (reuse === "reuse") {
|
|
1149
|
-
url = existing.url;
|
|
1150
|
-
token = existing.token;
|
|
1151
|
-
machineId = existing.machineId;
|
|
1152
|
-
machineLabel = existing.machineLabel ?? machineLabel;
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
947
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
// The env value goes through the SAME `validateCloudUrl` a typed one
|
|
1178
|
-
// did — it is not a trusted back door. http stays loopback-only, so a
|
|
1179
|
-
// bearer token still cannot be exported onto the wire in clear by
|
|
1180
|
-
// setting a variable.
|
|
1181
|
-
//
|
|
1182
|
-
// `--url` comes first. It was read into `answers.url` and then only
|
|
1183
|
-
// ever used as a CONDITION above, never as the URL, so
|
|
1184
|
-
// `config --token <key> --url <X>` connected to the env value or the
|
|
1185
|
-
// hosted default and ignored X — reporting a machine somewhere its
|
|
1186
|
-
// operator had explicitly said not to. The CLI fills `answers.url` from
|
|
1187
|
-
// `--url`, falling back to FAILPROOFAI_CLOUD_URL, so the source named
|
|
1188
|
-
// on screen is whichever of the two the value actually is.
|
|
1189
|
-
const envOverride = process.env.FAILPROOFAI_CLOUD_URL?.trim();
|
|
1190
|
-
const flagOverride = answers.url?.trim();
|
|
1191
|
-
const override = flagOverride || envOverride;
|
|
1192
|
-
const source = flagOverride && flagOverride !== envOverride ? "--url" : "FAILPROOFAI_CLOUD_URL";
|
|
1193
|
-
if (override) {
|
|
1194
|
-
const validated = validateCloudUrl(cloudBaseFor(override));
|
|
1195
|
-
if (!validated.ok) {
|
|
1196
|
-
// Loud, not silent-fallback-to-hosted: someone who exported this
|
|
1197
|
-
// wants THAT endpoint, and quietly reporting a machine to the
|
|
1198
|
-
// hosted service instead is the one outcome they did not ask for.
|
|
1199
|
-
stdout.write(
|
|
1200
|
-
`\n${source === "--url" ? "--url is" : "FAILPROOFAI_CLOUD_URL is set to"} "${override}", which cannot be used: ` +
|
|
1201
|
-
`${validated.reason}\n`,
|
|
1202
|
-
);
|
|
1203
|
-
return cancel();
|
|
948
|
+
const askCloud = (): Promise<CloudKeyAnswer | OpenSource | null> =>
|
|
949
|
+
promptCloudKey({
|
|
950
|
+
message: "Connect to cloud",
|
|
951
|
+
// Where the key goes, in the heading: the one place somebody can notice
|
|
952
|
+
// they are about to send a key somewhere they did not mean to.
|
|
953
|
+
meta: source ? `${host} · from ${source}` : host,
|
|
954
|
+
tabHint: saved ? "skip" : "use open source instead",
|
|
955
|
+
emptyError: saved ? "Paste a key, or press tab to skip." : "Paste a key, or press tab to use open source.",
|
|
956
|
+
saved: saved
|
|
957
|
+
? {
|
|
958
|
+
masked: maskToken(saved.token),
|
|
959
|
+
check: async () => {
|
|
960
|
+
const r = await checkCloudKey(saved.url, saved.token);
|
|
961
|
+
if (r.ok) {
|
|
962
|
+
const org = r.org ?? saved.org;
|
|
963
|
+
return { ok: true, line: `Your saved key works: ${maskToken(saved.token)}${org ? `, ${org}` : ""}` };
|
|
964
|
+
}
|
|
965
|
+
return r.refused
|
|
966
|
+
? { ok: false, line: `Your saved key no longer works: ${r.reason}` }
|
|
967
|
+
: { ok: false, usable: true, line: `Couldn't check your saved key: ${r.reason}` };
|
|
968
|
+
},
|
|
1204
969
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
970
|
+
: undefined,
|
|
971
|
+
check: async (key) => {
|
|
972
|
+
// Kept open, so Tab (or the saved key) still finishes setup.
|
|
973
|
+
if (!url) return { ok: false, line: badUrl ?? "No usable endpoint." };
|
|
974
|
+
const r = await checkCloudKey(url, key);
|
|
975
|
+
if (r.ok) {
|
|
976
|
+
typedOrg = r.org;
|
|
977
|
+
return { ok: true, line: r.note };
|
|
1213
978
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
if (token === null) {
|
|
1221
|
-
token = await promptText({
|
|
1222
|
-
// The destination is in the question now that it is no longer a
|
|
1223
|
-
// question of its own. It is the only remaining place a person can
|
|
1224
|
-
// notice they are about to send a key somewhere they did not mean.
|
|
1225
|
-
message: `API key for ${new URL(url).host}`,
|
|
1226
|
-
hint: "needs events:add · policies:pull enables managed policy too",
|
|
1227
|
-
// Masked: setup is routinely run while screen-sharing, and a pasted
|
|
1228
|
-
// key would otherwise sit in the scrollback of every recording.
|
|
1229
|
-
mask: true,
|
|
1230
|
-
validate: (v) => (v.length >= 8 ? null : "that looks too short to be a key"),
|
|
1231
|
-
stdin,
|
|
1232
|
-
stdout,
|
|
1233
|
-
});
|
|
1234
|
-
if (token === null) return cancel();
|
|
1235
|
-
}
|
|
979
|
+
return { ok: false, line: r.refused ? `That key was refused: ${r.reason}.` : `Couldn't check the key: ${r.reason}.` };
|
|
980
|
+
},
|
|
981
|
+
collapsed: (answer) => [...cloudLines(planFor(answer)), ""],
|
|
982
|
+
stdin,
|
|
983
|
+
stdout,
|
|
984
|
+
});
|
|
1236
985
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
} else {
|
|
1266
|
-
connect = { url, token, machineId, machineLabel };
|
|
1267
|
-
}
|
|
1268
|
-
} else {
|
|
1269
|
-
stdout.write("looks good.\n\n");
|
|
1270
|
-
connect = { url, token, machineId, machineLabel };
|
|
1271
|
-
}
|
|
986
|
+
let cloud: CloudPlan;
|
|
987
|
+
if (answers.oss) {
|
|
988
|
+
cloud = saved ? { kind: "kept", how: "skip", verified: false } : { kind: "oss" };
|
|
989
|
+
write([...cloudLines(cloud), ""]);
|
|
990
|
+
} else if (answers.token) {
|
|
991
|
+
// A key on the command line is a request to connect, and there is nowhere
|
|
992
|
+
// usable to send it.
|
|
993
|
+
if (!url) return refuseUrl(badUrl ?? "No usable endpoint.");
|
|
994
|
+
const r = await checkCloudKey(url, answers.token);
|
|
995
|
+
if (r.ok) {
|
|
996
|
+
cloud = { kind: "connect", token: answers.token, org: r.org, note: r.note };
|
|
997
|
+
write([...cloudLines(cloud), ""]);
|
|
998
|
+
} else if (unattended) {
|
|
999
|
+
// A key that does not work is a FAILED setup, not a prompt: a script that
|
|
1000
|
+
// exited 0 here would leave a fleet believing it was reporting.
|
|
1001
|
+
write([
|
|
1002
|
+
kit.head("Connect to cloud", host),
|
|
1003
|
+
kit.fail(r.refused ? `That key was refused: ${r.reason}.` : `Couldn't check the key: ${r.reason}.`),
|
|
1004
|
+
]);
|
|
1005
|
+
void emit("configure_aborted", { reason: "cloud_unverified" });
|
|
1006
|
+
return { applied: false, abort: "cloud_unverified" };
|
|
1007
|
+
} else {
|
|
1008
|
+
// Somebody is at the keyboard, so a refused --token is a question again
|
|
1009
|
+
// rather than the end of setup.
|
|
1010
|
+
write([kit.fail(r.refused ? `The --token key was refused: ${r.reason}.` : `Couldn't check the --token key: ${r.reason}.`)]);
|
|
1011
|
+
const answer = await askCloud();
|
|
1012
|
+
if (answer === null) return cancel();
|
|
1013
|
+
cloud = planFor(answer);
|
|
1272
1014
|
}
|
|
1015
|
+
} else if (unattended) {
|
|
1016
|
+
// No key and nobody to ask: nothing about the connection changes.
|
|
1017
|
+
cloud = saved ? { kind: "kept", how: "skip", verified: false } : { kind: "oss" };
|
|
1018
|
+
write([...cloudLines(cloud), ""]);
|
|
1019
|
+
} else {
|
|
1020
|
+
// Said above the field, which stays usable for Tab and a saved key.
|
|
1021
|
+
if (badUrl) write([kit.fail(badUrl)]);
|
|
1022
|
+
const answer = await askCloud();
|
|
1023
|
+
if (answer === null) return cancel();
|
|
1024
|
+
cloud = planFor(answer);
|
|
1273
1025
|
}
|
|
1274
|
-
|
|
1275
|
-
//
|
|
1276
|
-
// The last question, and the only one a headless run skips outright rather
|
|
1277
|
-
// than answering from a flag. There is nothing to confirm when nobody is
|
|
1278
|
-
// watching, and the command itself was the confirmation.
|
|
1026
|
+
|
|
1027
|
+
// ── AGENTS ──────────────────────────────────────────────────────────────────
|
|
1279
1028
|
//
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1029
|
+
// One saved list drives hooks, collection and backfill. See `planAgents` for
|
|
1030
|
+
// which ticks a run starts with.
|
|
1031
|
+
const fp = readConfig();
|
|
1032
|
+
const hookedNow = INTEGRATION_TYPES.filter((id) => hookedAtUserScope(id, cwd));
|
|
1033
|
+
const plan = planAgents({ detected: detectInstalledClis(), saved: fp.agents, hooked: hookedNow });
|
|
1034
|
+
// Whether the daemon will be collecting once this run ends: a key is already
|
|
1035
|
+
// on disk, or this run writes one. Removal copy and the backfill request both
|
|
1036
|
+
// depend on it.
|
|
1037
|
+
const collectingAfter = cloud.kind === "connect" || readIngestCredential() !== null;
|
|
1038
|
+
const sessionsOn = cloud.kind === "connect" ? answers.noTranscripts !== true : fp.collector.sessions === true;
|
|
1039
|
+
|
|
1040
|
+
let selected: IntegrationType[];
|
|
1041
|
+
if (answers.agents && answers.agents.length > 0) {
|
|
1042
|
+
const named = new Set(answers.agents);
|
|
1043
|
+
selected = plan.order.filter((id) => named.has(id));
|
|
1044
|
+
write([...agentsCollapsedLines(kit, plan, selected, collectingAfter, c), ""]);
|
|
1045
|
+
} else if (unattended || preAnswered) {
|
|
1046
|
+
selected = defaultSelection(plan);
|
|
1047
|
+
write([...agentsCollapsedLines(kit, plan, selected, collectingAfter, c), ""]);
|
|
1048
|
+
} else {
|
|
1049
|
+
// "from last setup", not the reference's "from your last setup": with all
|
|
1050
|
+
// twelve agents listed, the longer meta runs the heading to 81 columns and
|
|
1051
|
+
// the end of it is cut off.
|
|
1052
|
+
const suffix =
|
|
1053
|
+
plan.mode === "rerun" ? " · from last setup" : plan.mode === "upgrade" ? " · as protected today" : "";
|
|
1054
|
+
const picked = await multiSelect<IntegrationType>({
|
|
1055
|
+
message: "Which agents should failproofai trace?",
|
|
1056
|
+
choices: agentChoices(plan),
|
|
1057
|
+
minSelected: 1,
|
|
1058
|
+
minMessage: "Pick at least one agent.",
|
|
1059
|
+
meta: (checked) => `${checked.length} of ${plan.order.length} selected${suffix}`,
|
|
1060
|
+
tag: agentTag(plan, c),
|
|
1061
|
+
collapsed: (values) => [
|
|
1062
|
+
...agentsCollapsedLines(kit, plan, plan.order.filter((id) => values.includes(id)), collectingAfter, c),
|
|
1063
|
+
"",
|
|
1064
|
+
],
|
|
1065
|
+
stdin,
|
|
1066
|
+
stdout,
|
|
1067
|
+
});
|
|
1068
|
+
if (picked === null) return cancel();
|
|
1069
|
+
selected = plan.order.filter((id) => picked.includes(id));
|
|
1070
|
+
}
|
|
1071
|
+
// Optional OTEL choice is never implied by unattended setup or a pasted key.
|
|
1072
|
+
const { chooseWizardOtel, runOtelCommand } = await import("./otel-cli");
|
|
1073
|
+
let otelCancelled = false;
|
|
1074
|
+
const otelWanted = !unattended && !preAnswered && cloud.kind === "connect" && await chooseWizardOtel(
|
|
1075
|
+
async message => {
|
|
1076
|
+
const answer = await selectOne({
|
|
1077
|
+
message,
|
|
1078
|
+
choices: [
|
|
1079
|
+
{ label: "No", value: false, hint: "keep current telemetry" },
|
|
1080
|
+
{ label: "Yes", value: true, hint: "choose OTEL agents" },
|
|
1081
|
+
],
|
|
1082
|
+
collapsed: value => [kit.head("OpenTelemetry"), kit.ok(value ? "Opting in to OTEL." : "Keeping current telemetry."), ""],
|
|
1083
|
+
stdin, stdout,
|
|
1084
|
+
});
|
|
1085
|
+
otelCancelled = answer === null;
|
|
1086
|
+
return answer === true;
|
|
1087
|
+
},
|
|
1088
|
+
);
|
|
1089
|
+
if (otelCancelled) return cancel();
|
|
1090
|
+
let otelAgents: string[] = [];
|
|
1091
|
+
if (otelWanted) {
|
|
1092
|
+
const picked = await multiSelect({
|
|
1093
|
+
message: "Which agents should send OpenTelemetry?",
|
|
1094
|
+
choices: [
|
|
1095
|
+
{ label: "Gemini CLI", value: "gemini", checked: false },
|
|
1096
|
+
{ label: "Copilot (VS Code)", value: "copilot", checked: false },
|
|
1097
|
+
{ label: "Claude Code", value: "claude", checked: false },
|
|
1098
|
+
{ label: "Codex", value: "codex", checked: false },
|
|
1099
|
+
],
|
|
1100
|
+
stdin, stdout,
|
|
1101
|
+
});
|
|
1102
|
+
if (picked === null) return cancel();
|
|
1103
|
+
otelAgents = picked;
|
|
1104
|
+
}
|
|
1105
|
+
const { added, removed } = agentChanges(plan, selected);
|
|
1306
1106
|
|
|
1307
1107
|
// ── Apply ─────────────────────────────────────────────────────────────────
|
|
1308
1108
|
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1109
|
+
// The agents step (or the optional OTEL choice) was the last decision.
|
|
1110
|
+
// ORDER MATTERS here:
|
|
1111
|
+
//
|
|
1112
|
+
// 1. The daemon first, because setup requires it: if it cannot be installed
|
|
1113
|
+
// this run must leave the machine exactly as it found it. Writing hooks
|
|
1114
|
+
// first and discovering the service will not start is the one ordering
|
|
1115
|
+
// whose failure cannot be undone cleanly.
|
|
1116
|
+
// 2. Hooks for the chosen agents, and off for the rest.
|
|
1117
|
+
// 3. The backfill request for agents added back, BEFORE the selection that
|
|
1118
|
+
// ticks them: the daemon handles a pending request ahead of its config
|
|
1119
|
+
// compare, so one landing a tick later would let an added agent resume
|
|
1120
|
+
// stale cursors first.
|
|
1121
|
+
// 4. The selection, BEFORE any new ingest key, or the first collection runs
|
|
1122
|
+
// every agent.
|
|
1123
|
+
// 5. The connection last.
|
|
1124
|
+
const done: string[] = [];
|
|
1315
1125
|
let daemonInstalled = daemonAlreadyRunning;
|
|
1316
1126
|
if (daemonWanted) {
|
|
1317
1127
|
// A unit that is running but cannot answer is torn down before it is
|
|
1318
|
-
// rebuilt
|
|
1319
|
-
//
|
|
1320
|
-
// the replacement needs — install over the top and the new unit starts,
|
|
1321
|
-
// loses the lock race, and the machine stays exactly as broken. This is
|
|
1322
|
-
// the production path for `uninstallDaemonService`, which until now had
|
|
1323
|
-
// none: it was defined, tested, documented in CLAUDE.md as called from
|
|
1324
|
-
// here, and referenced by nothing.
|
|
1128
|
+
// rebuilt: it holds the singleton flock the replacement needs, so
|
|
1129
|
+
// installing over it starts a unit that loses the lock race.
|
|
1325
1130
|
if (daemonBroken) {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1131
|
+
await transient("Removing the failproofaid service that can't start…", async () => {
|
|
1132
|
+
try {
|
|
1133
|
+
await uninstallDaemonService();
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
// Non-fatal: the install below reports the outcome that decides.
|
|
1136
|
+
hookLogWarn(
|
|
1137
|
+
`could not remove the broken failproofaid service: ${err instanceof Error ? err.message : String(err)}`,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1336
1141
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1142
|
+
const daemonResult = await transient(daemonBroken ? "Reinstalling failproofaid…" : "Installing failproofaid…", () =>
|
|
1143
|
+
installDaemonService(),
|
|
1144
|
+
);
|
|
1339
1145
|
void emit("configure_daemon_install", {
|
|
1340
1146
|
installed: daemonResult.installed,
|
|
1341
|
-
// A bounded classification, never the raw reason:
|
|
1342
|
-
//
|
|
1343
|
-
// against a homedir()-derived path, so it routinely carries the OS
|
|
1344
|
-
// username and the local filesystem layout. The full text stays local
|
|
1345
|
-
// via the hookLogWarn below.
|
|
1147
|
+
// A bounded classification, never the raw reason: that string routinely
|
|
1148
|
+
// carries the OS username. The full text stays local via hookLogWarn.
|
|
1346
1149
|
reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
|
|
1347
1150
|
platform: process.platform,
|
|
1348
1151
|
});
|
|
1349
|
-
|
|
1350
1152
|
if (!daemonResult.installed) {
|
|
1351
1153
|
hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
|
|
1352
1154
|
// Nothing user-facing has been written yet, so there is nothing to roll
|
|
1353
1155
|
// back — which is the entire reason this runs first.
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
);
|
|
1156
|
+
write([
|
|
1157
|
+
kit.fail("failproofaid could not be installed, so setup stopped before changing anything."),
|
|
1158
|
+
` ${daemonResult.reason ?? "unknown error"}`,
|
|
1159
|
+
` Once that is fixed, re-run: ${kit.cmd("failproofai config")}`,
|
|
1160
|
+
]);
|
|
1359
1161
|
void emit("configure_aborted", { reason: "daemon_failed" });
|
|
1360
|
-
outro("Nothing was changed.", { ok: false }, stdout);
|
|
1361
1162
|
return { applied: false, abort: "daemon_failed" };
|
|
1362
1163
|
}
|
|
1363
|
-
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
|
|
1368
|
-
// machine through: `waitForDaemonRunning()` asks systemd, `Ping` is
|
|
1369
|
-
// answered in `server.rs` without touching the worker, a null
|
|
1370
|
-
// `resolveWorkerCommand()` is best-effort, and `Worker::warm()` swallows
|
|
1371
|
-
// its own failure. Setting `daemonConfigured` against it denies every tool
|
|
1372
|
-
// call across all twelve CLIs, `UserPromptSubmit` included, so the user
|
|
1373
|
-
// cannot even ask their agent why.
|
|
1374
|
-
//
|
|
1375
|
-
// Checked HERE rather than inside `installDaemonService`, which can only
|
|
1376
|
-
// honestly report on the service: keeping them apart is also what lets the
|
|
1377
|
-
// install mechanics be tested against a stub binary.
|
|
1378
|
-
const probe = await probeDaemon();
|
|
1164
|
+
// Installed and running is the service manager's opinion. Setting
|
|
1165
|
+
// `daemonConfigured` against a daemon whose worker cannot run denies every
|
|
1166
|
+
// tool call across all twelve CLIs, `UserPromptSubmit` included, so a real
|
|
1167
|
+
// evaluation is asked for before anything else is written.
|
|
1168
|
+
const probe = await transient("Checking that failproofai answers…", () => probeDaemon());
|
|
1379
1169
|
if (!probe.ok) {
|
|
1380
1170
|
hookLogWarn(
|
|
1381
1171
|
`failproofaid was installed and running but did not answer a policy evaluation (${probe.reason})`,
|
|
1382
1172
|
);
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
" Then re-run: failproofai config\n\n",
|
|
1395
|
-
);
|
|
1173
|
+
write([
|
|
1174
|
+
kit.fail(
|
|
1175
|
+
probe.reason === "worker"
|
|
1176
|
+
? "failproofaid started but can't evaluate policies: its worker process could not be run."
|
|
1177
|
+
: "failproofaid started but could not be reached on its socket.",
|
|
1178
|
+
),
|
|
1179
|
+
" Setup stopped before changing anything: a machine that requires a daemon",
|
|
1180
|
+
" that cannot answer denies every tool call.",
|
|
1181
|
+
` Check it with: ${kit.cmd(daemonStatusCommand() ?? "systemctl status failproofaid")}`,
|
|
1182
|
+
` Then re-run: ${kit.cmd("failproofai config")}`,
|
|
1183
|
+
]);
|
|
1396
1184
|
void emit("configure_aborted", { reason: `daemon_not_answering_${probe.reason}` });
|
|
1397
|
-
outro("Nothing was changed.", { ok: false }, stdout);
|
|
1398
1185
|
return { applied: false, abort: "daemon_failed" };
|
|
1399
1186
|
}
|
|
1400
1187
|
daemonInstalled = true;
|
|
1188
|
+
done.push(
|
|
1189
|
+
kit.ok(
|
|
1190
|
+
daemonBroken
|
|
1191
|
+
? "failproofaid rebuilt and running"
|
|
1192
|
+
: daemonSkew
|
|
1193
|
+
? `failproofaid updated to ${cliVersion} and running`
|
|
1194
|
+
: "failproofaid installed and running",
|
|
1195
|
+
),
|
|
1196
|
+
);
|
|
1401
1197
|
} else if (daemonUnitStale) {
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1405
|
-
// scheduled audit, nothing else.
|
|
1406
|
-
stdout.write("Refreshing the failproofaid service definition…\n");
|
|
1407
|
-
const upgrade = await ensureDaemonServiceCurrent();
|
|
1198
|
+
// Never instead of the install, and never able to abort a setup that is
|
|
1199
|
+
// otherwise fine: a failure here costs the scheduled audit, nothing else.
|
|
1200
|
+
const upgrade = await transient("Refreshing failproofaid's service definition…", () => ensureDaemonServiceCurrent());
|
|
1408
1201
|
void emit("configure_daemon_unit_refresh", {
|
|
1409
1202
|
outcome: upgrade.outcome,
|
|
1410
1203
|
daemon_running: upgrade.daemonRunning ?? true,
|
|
@@ -1412,151 +1205,139 @@ export async function runConfigureWizard(
|
|
|
1412
1205
|
});
|
|
1413
1206
|
if (upgrade.outcome === "failed") {
|
|
1414
1207
|
hookLogWarn(`failproofaid service definition could not be refreshed: ${upgrade.reason}`);
|
|
1208
|
+
done.push(kit.caution(`failproofaid's service definition could not be refreshed: ${upgrade.reason ?? "unknown error"}`));
|
|
1415
1209
|
if (upgrade.daemonRunning === false) {
|
|
1416
|
-
// The refresh stopped a daemon it could not start again
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
// not drag a healthy machine down this branch.
|
|
1420
|
-
//
|
|
1421
|
-
// Leaving `daemonConfigured` set here is not "the audit stays off", it
|
|
1422
|
-
// is every tool call across all 12 CLIs denied against a socket
|
|
1423
|
-
// nothing is listening on, recoverable only by hand-editing
|
|
1424
|
-
// policies-config.json. So the machine goes back to in-process
|
|
1425
|
-
// evaluation — the same trade uninstallDaemonService makes, and for
|
|
1426
|
-
// the same reason.
|
|
1210
|
+
// The refresh stopped a daemon it could not start again. Leaving
|
|
1211
|
+
// `daemonConfigured` set would deny every tool call against a socket
|
|
1212
|
+
// nothing listens on, so the machine goes back to in-process evaluation.
|
|
1427
1213
|
daemonInstalled = false;
|
|
1428
1214
|
setDaemonConfigured(false);
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
"in-process evaluation rather than left denying every tool call. Hooks keep\n" +
|
|
1433
|
-
"enforcing. Re-run `failproofai config` to reinstall the service.\n\n",
|
|
1215
|
+
done.push(
|
|
1216
|
+
" It is no longer running, so this machine is back on in-process evaluation, and",
|
|
1217
|
+
` hooks keep enforcing. Reinstall it with ${kit.cmd("failproofai config")}`,
|
|
1434
1218
|
);
|
|
1435
1219
|
} else {
|
|
1436
|
-
|
|
1437
|
-
`\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
|
|
1438
|
-
"Hooks keep enforcing; scheduled audits stay off until it is.\n\n",
|
|
1439
|
-
);
|
|
1220
|
+
done.push(" Hooks keep enforcing; scheduled audits stay off until it is refreshed.");
|
|
1440
1221
|
}
|
|
1222
|
+
} else {
|
|
1223
|
+
done.push(kit.ok("failproofaid's service definition is up to date"));
|
|
1441
1224
|
}
|
|
1442
1225
|
}
|
|
1443
1226
|
|
|
1444
|
-
// The flag that makes hooks route through the daemon — and
|
|
1445
|
-
//
|
|
1446
|
-
// verified-running service, never on intent.
|
|
1447
|
-
// The version is recorded HERE, from the CLI that installed it — which is by
|
|
1448
|
-
// construction the version that was installed, since the download URL and the
|
|
1449
|
-
// binary filename are both derived from it.
|
|
1227
|
+
// The flag that makes hooks route through the daemon — and fail closed when
|
|
1228
|
+
// it is unreachable. Only ever set after a verified-running service.
|
|
1450
1229
|
if (daemonInstalled) {
|
|
1451
1230
|
setDaemonConfigured(true, cliVersion);
|
|
1452
|
-
//
|
|
1453
|
-
// longer referenced by anything. Keeps the previous version for an
|
|
1454
|
-
// offline rollback.
|
|
1231
|
+
// The unit points at the new binary now, so older ones are unreferenced.
|
|
1455
1232
|
pruneOldDaemonBinaries();
|
|
1456
1233
|
}
|
|
1457
1234
|
|
|
1458
|
-
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1235
|
+
const connectedAfterPlan = cloud.kind === "connect" || cloud.kind === "kept";
|
|
1236
|
+
// Telemetry runs concurrently with the rest of apply (never rejects,
|
|
1237
|
+
// 5s-bounded) and is awaited before returning. `cli` carries the selected
|
|
1238
|
+
// agent ids, as it always has (and as hooks_installed does): a fixed list of
|
|
1239
|
+
// product ids, never user content. The agents_* fields add counts only.
|
|
1461
1240
|
const applied = emit("configure_applied", {
|
|
1462
|
-
target,
|
|
1463
|
-
scopes,
|
|
1464
|
-
cli:
|
|
1465
|
-
cli_count:
|
|
1466
|
-
policy_count:
|
|
1467
|
-
connected:
|
|
1241
|
+
target: "user",
|
|
1242
|
+
scopes: ["user"],
|
|
1243
|
+
cli: selected,
|
|
1244
|
+
cli_count: selected.length,
|
|
1245
|
+
policy_count: (readScopedHooksConfig("user", cwd).enabledPolicies ?? []).length,
|
|
1246
|
+
connected: cloud.kind === "connect",
|
|
1247
|
+
mode: connectedAfterPlan ? "cloud" : "oss",
|
|
1248
|
+
agents_selected: selected.length,
|
|
1249
|
+
agents_detected: plan.detected.size,
|
|
1250
|
+
agents_changed: added.length + removed.length > 0,
|
|
1468
1251
|
});
|
|
1469
1252
|
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
"configure-wizard",
|
|
1497
|
-
/* customPoliciesPath */ undefined,
|
|
1498
|
-
/* removeCustomHooks */ false,
|
|
1499
|
-
clisForScope,
|
|
1500
|
-
{ replace: true, quiet: true },
|
|
1501
|
-
);
|
|
1502
|
-
}
|
|
1503
|
-
setCustomPoliciesEnabled(scope, cwd, customEnabled);
|
|
1504
|
-
}
|
|
1253
|
+
// Hooks, at user scope, for the chosen agents. Whatever policies are enabled
|
|
1254
|
+
// there are carried through untouched: `replace: true` would otherwise switch
|
|
1255
|
+
// off policies the user had turned on, and setup must never reduce
|
|
1256
|
+
// protection. Setup chooses no policies of its own any more.
|
|
1257
|
+
const policies = readScopedHooksConfig("user", cwd).enabledPolicies ?? [];
|
|
1258
|
+
// Nothing to hook is a real answer (a saved empty selection), and handing
|
|
1259
|
+
// installHooks an empty list is not a way to say it.
|
|
1260
|
+
if (selected.length > 0) await transient("Installing hooks…", () =>
|
|
1261
|
+
installHooks(
|
|
1262
|
+
policies,
|
|
1263
|
+
"user",
|
|
1264
|
+
cwd,
|
|
1265
|
+
/* includeBeta */ false,
|
|
1266
|
+
"configure-wizard",
|
|
1267
|
+
/* customPoliciesPath */ undefined,
|
|
1268
|
+
/* removeCustomHooks */ false,
|
|
1269
|
+
selected,
|
|
1270
|
+
{ replace: true, quiet: true },
|
|
1271
|
+
),
|
|
1272
|
+
);
|
|
1273
|
+
// And off for every agent not chosen that has them: the ones unticked now,
|
|
1274
|
+
// and any hooked since the last setup without being part of it.
|
|
1275
|
+
removeUserScopeHooks(
|
|
1276
|
+
INTEGRATION_TYPES.filter((id) => !selected.includes(id) && (plan.baseline.has(id) || hookedNow.includes(id))),
|
|
1277
|
+
cwd,
|
|
1278
|
+
);
|
|
1505
1279
|
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1512
|
-
|
|
1280
|
+
// Agents traced again start fresh (see `requestFreshStart`). Only on a
|
|
1281
|
+
// rerun: its baseline is the selection the daemon actually filtered on. With
|
|
1282
|
+
// no saved list (upgrade, first) the daemon was collecting every agent, so an
|
|
1283
|
+
// agent "added" here was never untraced, has no stale cursors, and a request
|
|
1284
|
+
// would only make the daemon re-read everything it already sent.
|
|
1285
|
+
requestFreshStart(plan.mode === "rerun" ? added : [], collectingAfter && sessionsOn);
|
|
1286
|
+
|
|
1287
|
+
updateConfig({ agents: { selected, seen: plan.seen } });
|
|
1288
|
+
|
|
1289
|
+
// The connection, written after the daemon exists — the daemon runs the
|
|
1290
|
+
// collector, so a credential written for a service that is not there would be
|
|
1291
|
+
// a key on disk doing nothing. `connectToCloud` re-verifies each capability
|
|
1292
|
+
// and writes only what works.
|
|
1513
1293
|
let connected = false;
|
|
1514
|
-
if (connect) {
|
|
1294
|
+
if (cloud.kind === "connect" && url) {
|
|
1295
|
+
const token = cloud.token;
|
|
1515
1296
|
try {
|
|
1516
|
-
const outcome = await
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
// output. The separate `--connect` path always honoured it.
|
|
1527
|
-
sessions: answers.noTranscripts !== true,
|
|
1528
|
-
});
|
|
1297
|
+
const outcome = await transient("Connecting…", () =>
|
|
1298
|
+
connectToCloud({
|
|
1299
|
+
url,
|
|
1300
|
+
token,
|
|
1301
|
+
machineId,
|
|
1302
|
+
machineLabel,
|
|
1303
|
+
// Both streams unless the run said `--no-transcripts`.
|
|
1304
|
+
sessions: answers.noTranscripts !== true,
|
|
1305
|
+
}),
|
|
1306
|
+
);
|
|
1529
1307
|
connected = outcome.anyConfigured;
|
|
1530
|
-
|
|
1531
|
-
const shownAs =
|
|
1532
|
-
connect.machineLabel === connect.machineId
|
|
1533
|
-
? connect.machineId
|
|
1534
|
-
: `${connect.machineLabel} (${connect.machineId})`;
|
|
1535
|
-
for (const line of describeOutcome(outcome, shownAs, connect.url)) {
|
|
1536
|
-
stdout.write(`${line}\n`);
|
|
1537
|
-
}
|
|
1538
|
-
// Said when it took effect, as `--connect` says it: the default carries
|
|
1539
|
-
// prompts and file contents, so the opt-out is worth confirming.
|
|
1308
|
+
done.push(...connectDoneLines(kit, outcome, host, cloud.org));
|
|
1540
1309
|
if (outcome.ingest.ok && answers.noTranscripts === true) {
|
|
1541
|
-
|
|
1310
|
+
done.push(" Decisions only: session transcripts are not sent (--no-transcripts).");
|
|
1542
1311
|
}
|
|
1543
|
-
|
|
1544
|
-
// capabilities the server actually granted.
|
|
1545
|
-
void emit("configure_connect", {
|
|
1546
|
-
policy_ok: outcome.policy.ok,
|
|
1547
|
-
ingest_ok: outcome.ingest.ok,
|
|
1548
|
-
});
|
|
1312
|
+
void emit("configure_connect", { policy_ok: outcome.policy.ok, ingest_ok: outcome.ingest.ok });
|
|
1549
1313
|
} catch (err) {
|
|
1550
|
-
// Non-fatal, unlike the daemon: enforcement does not depend on the
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
hookLogWarn(
|
|
1554
|
-
`cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
1555
|
-
);
|
|
1556
|
-
stdout.write("\nCould not connect — everything else applied. Retry with `failproofai config --connect`.\n");
|
|
1314
|
+
// Non-fatal, unlike the daemon: enforcement does not depend on the cloud.
|
|
1315
|
+
hookLogWarn(`cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`);
|
|
1316
|
+
done.push(kit.caution("Couldn't connect; everything else was applied.", "failproofai config"));
|
|
1557
1317
|
}
|
|
1318
|
+
} else if (cloud.kind === "kept" && cloud.how === "saved" && fp.mode !== "cloud") {
|
|
1319
|
+
// A home migrated from layout 1 carries keys but no mode, and would read
|
|
1320
|
+
// as open source everywhere that asks.
|
|
1321
|
+
updateConfig({ mode: "cloud" });
|
|
1558
1322
|
}
|
|
1559
1323
|
|
|
1324
|
+
if (otelWanted && connected) {
|
|
1325
|
+
for (const agent of otelAgents) {
|
|
1326
|
+
const result = await runOtelCommand(["enable", agent], {
|
|
1327
|
+
home,
|
|
1328
|
+
onEvent: emit,
|
|
1329
|
+
confirm: async message => await selectOne({
|
|
1330
|
+
message,
|
|
1331
|
+
choices: [
|
|
1332
|
+
{ label: "No", value: false, hint: "keep transcript upload" },
|
|
1333
|
+
{ label: "Yes", value: true, hint: "switch to OTEL" },
|
|
1334
|
+
],
|
|
1335
|
+
stdin, stdout,
|
|
1336
|
+
}) === true,
|
|
1337
|
+
});
|
|
1338
|
+
done.push(...result.lines.map(line => result.exitCode === 0 ? kit.ok(line) : kit.fail(line)));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1560
1341
|
await applied;
|
|
1561
1342
|
// Only now — a completed apply — is the launcher considered "seen", so
|
|
1562
1343
|
// first-run onboarding stops offering itself on every command.
|
|
@@ -1564,40 +1345,238 @@ export async function runConfigureWizard(
|
|
|
1564
1345
|
// And any record of an earlier failure is now false: this machine got set up.
|
|
1565
1346
|
clearOnboardingAttempt();
|
|
1566
1347
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
// are no longer occasional additions — see buildCompletionSummary's own doc
|
|
1570
|
-
// comment for why the widest combination still fits in 80 columns.
|
|
1571
|
-
outro(
|
|
1572
|
-
buildCompletionSummary(
|
|
1573
|
-
policies.length,
|
|
1574
|
-
clis.length,
|
|
1575
|
-
customEnabled,
|
|
1576
|
-
daemonInstalled,
|
|
1577
|
-
connected,
|
|
1578
|
-
),
|
|
1579
|
-
{ ok: true },
|
|
1580
|
-
stdout,
|
|
1581
|
-
);
|
|
1582
|
-
// Setup wires the hooks and deliberately chooses NO policies, so a machine
|
|
1583
|
-
// that has just finished it enforces almost nothing — and the summary line
|
|
1584
|
-
// says "0 policies" without saying what to do about it. Anyone else's pack is
|
|
1585
|
-
// typed the same way, which is the point: ours is named in full rather than
|
|
1586
|
-
// by a short name only we could use.
|
|
1587
|
-
if (policies.length === 0) {
|
|
1588
|
-
stdout.write(
|
|
1589
|
-
`\n Nothing is enforcing yet. Take ours, or anyone's:\n` +
|
|
1590
|
-
` failproofai policies add ${CORE_SOURCE}\n` +
|
|
1591
|
-
` failproofai policies show <owner>/<repo> (look first)\n\n`,
|
|
1592
|
-
);
|
|
1593
|
-
}
|
|
1348
|
+
write(doneLines(kit, { elapsedMs: Date.now() - started, done, cwd, agents: selected.length, connected: connected || cloud.kind === "kept" }));
|
|
1349
|
+
|
|
1594
1350
|
return {
|
|
1595
1351
|
applied: true,
|
|
1596
|
-
target,
|
|
1597
|
-
scopes,
|
|
1598
|
-
clis,
|
|
1352
|
+
target: "user",
|
|
1353
|
+
scopes: ["user"],
|
|
1354
|
+
clis: selected,
|
|
1599
1355
|
policies,
|
|
1600
1356
|
daemonInstalled,
|
|
1601
1357
|
connected,
|
|
1358
|
+
mode: connected || cloud.kind === "kept" ? "cloud" : "oss",
|
|
1359
|
+
agents: { selected, added, removed },
|
|
1602
1360
|
};
|
|
1603
1361
|
}
|
|
1362
|
+
|
|
1363
|
+
// ── Cloud helpers ────────────────────────────────────────────────────────────
|
|
1364
|
+
|
|
1365
|
+
/** What the CONNECT step decided. Nothing is written until apply. */
|
|
1366
|
+
type CloudPlan =
|
|
1367
|
+
| { kind: "oss" }
|
|
1368
|
+
| { kind: "kept"; how: "skip" | "saved"; verified: boolean }
|
|
1369
|
+
| { kind: "connect"; token: string; org?: string; note?: string };
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* The connection already on this machine, from either credential: the policy
|
|
1373
|
+
* one and the collector's ingest key. Reading only the first, as setup used to,
|
|
1374
|
+
* showed a collector-only machine as unconnected.
|
|
1375
|
+
*/
|
|
1376
|
+
function savedConnection(): {
|
|
1377
|
+
url: string;
|
|
1378
|
+
token: string;
|
|
1379
|
+
org?: string;
|
|
1380
|
+
machineLabel?: string;
|
|
1381
|
+
} | null {
|
|
1382
|
+
const cloud = readCloudCredentials();
|
|
1383
|
+
const ingest = readIngestCredential();
|
|
1384
|
+
const token = cloud?.token ?? ingest?.key;
|
|
1385
|
+
if (!token) return null;
|
|
1386
|
+
const url = cloud?.url ?? cloudBaseFor(ingest?.url ?? DEFAULT_INGEST_URL);
|
|
1387
|
+
let org: string | undefined;
|
|
1388
|
+
try {
|
|
1389
|
+
const stored = readCredentials().org;
|
|
1390
|
+
org = stored?.name ?? stored?.slug;
|
|
1391
|
+
} catch {
|
|
1392
|
+
org = undefined;
|
|
1393
|
+
}
|
|
1394
|
+
return { url, token, org, machineLabel: cloud?.machineLabel };
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Where a key is sent: `--url`, else FAILPROOFAI_CLOUD_URL, else this machine's
|
|
1399
|
+
* existing connection, else the hosted service.
|
|
1400
|
+
*
|
|
1401
|
+
* Never asked for interactively: everybody on the hosted product has one right
|
|
1402
|
+
* answer, and asking made it look like a decision — which is how keys ended up
|
|
1403
|
+
* pasted into the URL field. The env value goes through the SAME validation a
|
|
1404
|
+
* typed one did, so http stays loopback-only. The source is named on screen,
|
|
1405
|
+
* because an env var is invisible at exactly the moment it matters.
|
|
1406
|
+
*/
|
|
1407
|
+
export function resolveCloudUrl(
|
|
1408
|
+
flag: string | undefined,
|
|
1409
|
+
existing: string | undefined,
|
|
1410
|
+
): { ok: true; url: string; source?: string } | { ok: false; message: string } {
|
|
1411
|
+
const env = process.env.FAILPROOFAI_CLOUD_URL?.trim();
|
|
1412
|
+
const fromFlag = flag?.trim();
|
|
1413
|
+
const override = fromFlag || env;
|
|
1414
|
+
if (!override) return { ok: true, url: existing ?? cloudBaseFor(DEFAULT_INGEST_URL) };
|
|
1415
|
+
const source = fromFlag && fromFlag !== env ? "--url" : "FAILPROOFAI_CLOUD_URL";
|
|
1416
|
+
const validated = validateCloudUrl(cloudBaseFor(override));
|
|
1417
|
+
if (!validated.ok) {
|
|
1418
|
+
return {
|
|
1419
|
+
ok: false,
|
|
1420
|
+
message: `${source === "--url" ? "--url is" : "FAILPROOFAI_CLOUD_URL is set to"} "${override}", which cannot be used: ${validated.reason}`,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
return { ok: true, url: validated.url, source };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function hostOf(url: string): string {
|
|
1427
|
+
try {
|
|
1428
|
+
return new URL(url).host;
|
|
1429
|
+
} catch {
|
|
1430
|
+
return url;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Check a key without writing anything or registering anything server-side.
|
|
1436
|
+
*
|
|
1437
|
+
* Introspect first: it says which organisation the key belongs to and what it
|
|
1438
|
+
* may do, with no side effect. A key that can neither send events nor pull
|
|
1439
|
+
* policies is refused; one that can do one of them is accepted with a note
|
|
1440
|
+
* saying which half is missing. A server too old for introspect is probed the
|
|
1441
|
+
* way setup always did, with an empty batch to the ingest endpoint.
|
|
1442
|
+
*
|
|
1443
|
+
* `refused: false` is "could not ask" (offline, a timeout, a 5xx) — a saved key
|
|
1444
|
+
* that fails that way may still be fine, so it is not treated as broken.
|
|
1445
|
+
*/
|
|
1446
|
+
export async function checkCloudKey(
|
|
1447
|
+
url: string,
|
|
1448
|
+
key: string,
|
|
1449
|
+
): Promise<{ ok: true; org?: string; note?: string } | { ok: false; refused: boolean; reason: string }> {
|
|
1450
|
+
const identity = await introspectKey(url, key);
|
|
1451
|
+
if (identity.kind === "rejected") return { ok: false, refused: true, reason: "the server did not accept it" };
|
|
1452
|
+
if (identity.kind === "unreachable") return { ok: false, refused: false, reason: identity.reason };
|
|
1453
|
+
if (identity.kind === "ok") {
|
|
1454
|
+
const events = hasPermission(identity.identity, PERMISSION_EVENTS);
|
|
1455
|
+
const policies = hasPermission(identity.identity, PERMISSION_POLICIES);
|
|
1456
|
+
const org = identity.identity.orgName ?? identity.identity.orgSlug;
|
|
1457
|
+
if (!events && !policies) {
|
|
1458
|
+
return { ok: false, refused: true, reason: `it is missing the ${PERMISSION_EVENTS} permission` };
|
|
1459
|
+
}
|
|
1460
|
+
const note = !events
|
|
1461
|
+
? `This key can't send events (${PERMISSION_EVENTS}), so nothing will be collected.`
|
|
1462
|
+
: !policies
|
|
1463
|
+
? `This key can't pull policies (${PERMISSION_POLICIES}), so cloud-managed policies won't arrive.`
|
|
1464
|
+
: undefined;
|
|
1465
|
+
return { ok: true, org, note };
|
|
1466
|
+
}
|
|
1467
|
+
const probe = await validateIngestKey({ url: ingestUrlFor(url), key });
|
|
1468
|
+
if (probe.ok) return { ok: true };
|
|
1469
|
+
const offline = /^could not reach|answered 5\d\d/.test(probe.reason);
|
|
1470
|
+
return { ok: false, refused: !offline, reason: probe.reason };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** The CONNECT step, collapsed: what was checked, never yet what was written. */
|
|
1474
|
+
export function cloudCollapsedLines(
|
|
1475
|
+
kit: ReturnType<typeof screenKit>,
|
|
1476
|
+
plan: CloudPlan,
|
|
1477
|
+
at: { host: string; savedHost: string; savedOrg?: string; source?: string },
|
|
1478
|
+
): string[] {
|
|
1479
|
+
// An override is named where the key went: an env var is invisible at the
|
|
1480
|
+
// moment it matters, and a machine reporting somewhere unexpected is exactly
|
|
1481
|
+
// what nobody notices until they look for data that is not there.
|
|
1482
|
+
const head = kit.head("Connect to cloud", at.source ? `${at.host} · from ${at.source}` : undefined);
|
|
1483
|
+
switch (plan.kind) {
|
|
1484
|
+
case "oss":
|
|
1485
|
+
return [head, kit.ok("Using open source.")];
|
|
1486
|
+
case "kept":
|
|
1487
|
+
if (plan.how === "skip") return [head, kit.ok(`Not connecting. The connection to ${at.savedHost} is unchanged.`)];
|
|
1488
|
+
return plan.verified
|
|
1489
|
+
? [head, kit.ok(at.savedOrg ? `Connected as ${at.savedOrg} to ${at.savedHost}` : `Connected to ${at.savedHost}`)]
|
|
1490
|
+
: [head, kit.caution(`Keeping the connection to ${at.savedHost}. Its key couldn't be checked just now.`)];
|
|
1491
|
+
case "connect":
|
|
1492
|
+
return [
|
|
1493
|
+
head,
|
|
1494
|
+
kit.ok(plan.org ? `Key accepted for ${plan.org} on ${at.host}` : `Key accepted by ${at.host}`),
|
|
1495
|
+
...(plan.note ? [kit.caution(plan.note)] : []),
|
|
1496
|
+
];
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** What connecting wrote, for the DONE section. */
|
|
1501
|
+
function connectDoneLines(
|
|
1502
|
+
kit: ReturnType<typeof screenKit>,
|
|
1503
|
+
outcome: Awaited<ReturnType<typeof connectToCloud>>,
|
|
1504
|
+
host: string,
|
|
1505
|
+
org: string | undefined,
|
|
1506
|
+
): string[] {
|
|
1507
|
+
const name = outcome.org?.name ?? outcome.org?.slug ?? org;
|
|
1508
|
+
const as = name ? `Connected as ${name} to ${host}` : `Connected to ${host}`;
|
|
1509
|
+
// Jev's state rides along on every branch that connected: whether it is on,
|
|
1510
|
+
// available but off, or was cleared is decided by this connect and said
|
|
1511
|
+
// nowhere else.
|
|
1512
|
+
if (outcome.policy.ok && outcome.ingest.ok) return [kit.ok(as), ...jevLines(outcome)];
|
|
1513
|
+
if (outcome.ingest.ok) {
|
|
1514
|
+
return [kit.ok(as), kit.caution(`Cloud-managed policies won't arrive: ${outcome.policy.reason}`), ...jevLines(outcome)];
|
|
1515
|
+
}
|
|
1516
|
+
if (outcome.policy.ok) return [kit.ok(as), kit.caution(`Nothing is collected: ${outcome.ingest.reason}`), ...jevLines(outcome)];
|
|
1517
|
+
return [kit.caution(`Not connected: ${outcome.ingest.reason ?? outcome.policy.reason ?? "unknown error"}`, "failproofai config")];
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Packs that were declared and refused, and deny for it — as the policies listing counts them. */
|
|
1521
|
+
function failClosedPacks(): Array<{ id: string | null }> {
|
|
1522
|
+
try {
|
|
1523
|
+
return readInstalledPacks().errors.filter((err) => err.effect !== "observe" && !err.semanticOnly);
|
|
1524
|
+
} catch {
|
|
1525
|
+
return [];
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* The DONE section: what apply wrote, then whether anything is enforcing — the
|
|
1531
|
+
* one generic warning when it is not (decision D18) — and the custom-policy
|
|
1532
|
+
* files that will not load, which the removed review step used to show.
|
|
1533
|
+
*/
|
|
1534
|
+
export function doneLines(
|
|
1535
|
+
kit: ReturnType<typeof screenKit>,
|
|
1536
|
+
state: { elapsedMs: number; done: string[]; cwd: string; agents: number; connected: boolean },
|
|
1537
|
+
): string[] {
|
|
1538
|
+
const lines = [kit.head("Done", `in ${(state.elapsedMs / 1000).toFixed(1)}s`), ...state.done];
|
|
1539
|
+
const on = `${state.agents} ${state.agents === 1 ? "agent" : "agents"}`;
|
|
1540
|
+
if (notEnforcingReason(state.cwd)) {
|
|
1541
|
+
lines.push(
|
|
1542
|
+
kit.caution("Policies are not enforcing yet."),
|
|
1543
|
+
` Turn on ours: ${kit.cmd(`failproofai policies add ${CORE_SOURCE}`)}`,
|
|
1544
|
+
);
|
|
1545
|
+
} else {
|
|
1546
|
+
const { count, custom } = enforcingPolicyCount(state.cwd);
|
|
1547
|
+
if (count > 0 || custom) {
|
|
1548
|
+
lines.push(
|
|
1549
|
+
kit.ok(
|
|
1550
|
+
count === 0
|
|
1551
|
+
? `Your custom policies are enforcing on ${on}.`
|
|
1552
|
+
: custom
|
|
1553
|
+
? `${count} ${count === 1 ? "policy" : "policies"} and your custom policies are enforcing on ${on}.`
|
|
1554
|
+
: `${count} ${count === 1 ? "policy is" : "policies are"} enforcing on ${on}.`,
|
|
1555
|
+
),
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
// A pack that failed to load fails closed, which is why the machine reads
|
|
1559
|
+
// as enforcing at all when nothing above counted. Said in the words the
|
|
1560
|
+
// policies listing uses, never as custom policies that may not exist.
|
|
1561
|
+
const failed = failClosedPacks();
|
|
1562
|
+
if (failed.length > 0) {
|
|
1563
|
+
const [first] = failed;
|
|
1564
|
+
const many = failed.length > 1;
|
|
1565
|
+
lines.push(
|
|
1566
|
+
kit.fail(
|
|
1567
|
+
`${first.id ?? "A pack"}${many ? ` and ${failed.length - 1} more` : ""} failed to load, ` +
|
|
1568
|
+
`so the calls ${many ? "they cover" : "it covers"} are denied.`,
|
|
1569
|
+
first.id ? `failproofai policies remove ${first.id}` : "failproofai policies",
|
|
1570
|
+
),
|
|
1571
|
+
);
|
|
1572
|
+
} else if (count === 0 && !custom) {
|
|
1573
|
+
lines.push(kit.ok(`Policies are enforcing on ${on}.`));
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (!state.connected) lines.push(` Connect to cloud any time: ${kit.cmd("failproofai config")}`);
|
|
1577
|
+
for (const warning of describeCustomPolicies(state.cwd).warnings) {
|
|
1578
|
+
lines.push(kit.caution(warning.replace(/^! /, "")));
|
|
1579
|
+
}
|
|
1580
|
+
lines.push("");
|
|
1581
|
+
return lines;
|
|
1582
|
+
}
|