failproofai 1.0.0-beta.6 → 1.0.0-beta.8
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 +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +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/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- 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.nft.json +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.nft.json +1 -1
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +3 -3
- 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]__1e7-pa7._.js → [root-of-the-server]__0ommekk._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1_a_gwn._.js → [root-of-the-server]__1tkyag1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tyf0tc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_04mekdy._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0igtyj5._.js → _0i4l3_6._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0r-dsre._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0se484m._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0uz7o9y._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1fahg9e._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1w8sola._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1w_6p9g._.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/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
- package/.next/standalone/.next/static/chunks/02vvywmww22zz.js +1 -0
- package/.next/standalone/.next/static/chunks/{34fa65k0cldkj.js → 0_-focvo4cuiv.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3f_939wi3kfnx.js → 1fwh-2un_l-eo.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3p1vpm2_-tfwm.js → 1zrdsxe91qq6i.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0_2n13i94rnw5.js → 2047vah_v4emu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{35v07j9gc1pvr.js → 23hl78dzffun4.js} +1 -1
- package/.next/standalone/.next/static/chunks/2bw0stk-s-1dd.js +1 -0
- package/.next/standalone/.next/static/chunks/{3rbpw_waieafm.js → 2r_j5wrkbu5wm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{23557z4e6dtsr.js → 3m8onpqrjxzu5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{28zkwzu50puki.js → 3yn1y6ckypqwp.js} +1 -1
- package/.next/standalone/package.json +5 -5
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +143 -27
- package/dist/cli.mjs +1288 -118
- package/dist/worker.mjs +77 -6
- package/package.json +5 -5
- package/scripts/repro-npm-install.sh +155 -0
- package/src/hooks/builtin-policies.ts +154 -2
- package/src/hooks/configure-wizard.ts +70 -6
- package/src/hooks/daemon-client.ts +35 -0
- package/src/hooks/daemon-service.ts +237 -24
- package/src/hooks/first-run-gate.ts +9 -1
- package/src/hooks/fp-home.ts +13 -0
- package/src/hooks/fp-reset.ts +25 -0
- package/src/hooks/onboarding-attempt.ts +179 -0
- package/src/hooks/uninstall-cli.ts +345 -0
- package/src/hooks/worker-server.ts +12 -2
- package/.next/standalone/.next/static/chunks/3081gykdubv8b.js +0 -1
- package/.next/standalone/.next/static/chunks/40ffnfpkeyvb0.js +0 -1
- /package/.next/standalone/.next/static/{Ptn8aBQl_cnNgOd0ebyv0 → gOR8i03AeQm1VnIayDHVV}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{Ptn8aBQl_cnNgOd0ebyv0 → gOR8i03AeQm1VnIayDHVV}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{Ptn8aBQl_cnNgOd0ebyv0 → gOR8i03AeQm1VnIayDHVV}/_ssgManifest.js +0 -0
|
@@ -94,7 +94,25 @@ export function setDaemonConfigured(value: boolean, installedVersion?: string):
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export type DaemonServiceStatus =
|
|
97
|
+
export type DaemonServiceStatus =
|
|
98
|
+
| "running"
|
|
99
|
+
| "stopped"
|
|
100
|
+
/**
|
|
101
|
+
* Installed, but systemd evaluated its `ConditionPathExists=` and refused to
|
|
102
|
+
* start it — the daemon binary or the worker script is gone.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately its own state rather than folded into "stopped". "Stopped" is
|
|
105
|
+
* ambiguous on purpose (a restart in flight looks identical), which is why
|
|
106
|
+
* nothing is allowed to act destructively on it. A failed condition carries
|
|
107
|
+
* no such ambiguity: systemd has already decided this unit will not run, and
|
|
108
|
+
* will keep deciding that at every boot until the missing path returns. That
|
|
109
|
+
* is provable enough to clear `daemonConfigured` on, and leaving it
|
|
110
|
+
* indistinguishable from a transient stop is what kept a machine denying
|
|
111
|
+
* every tool call with no explanation.
|
|
112
|
+
*/
|
|
113
|
+
| "condition-failed"
|
|
114
|
+
| "not-installed"
|
|
115
|
+
| "unsupported-platform";
|
|
98
116
|
|
|
99
117
|
/** Linux + macOS only, per the plan's platform scope — full stop. */
|
|
100
118
|
export function isDaemonSupportedPlatform(): boolean {
|
|
@@ -261,6 +279,28 @@ function shellQuote(value: string): string {
|
|
|
261
279
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
262
280
|
}
|
|
263
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The raw, unquoted path to the worker script the service will run — the same
|
|
284
|
+
* file `resolveWorkerCommand` builds its shell command around.
|
|
285
|
+
*
|
|
286
|
+
* It exists separately because the unit needs the path as a PATH (for
|
|
287
|
+
* `ConditionPathExists=`), not as a shell word, and recovering one from the
|
|
288
|
+
* other means unparsing POSIX quoting for a value we already had.
|
|
289
|
+
*
|
|
290
|
+
* Returns null when `FAILPROOFAI_WORKER_CMD` is set: that value is an arbitrary
|
|
291
|
+
* shell command — a wrapper script, an interpreter with flags, `exec`ing
|
|
292
|
+
* something else entirely — and guessing which token in it is "the file that
|
|
293
|
+
* must exist" would gate the service on a path nobody promised. An absent
|
|
294
|
+
* condition is the correct answer to a question we cannot answer.
|
|
295
|
+
*/
|
|
296
|
+
export function workerScriptPath(): string | null {
|
|
297
|
+
if (process.env.FAILPROOFAI_WORKER_CMD) return null;
|
|
298
|
+
const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
299
|
+
if (!packageRoot) return null;
|
|
300
|
+
const workerScript = resolve(packageRoot, "dist", "worker.mjs");
|
|
301
|
+
return existsSync(workerScript) ? workerScript : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
264
304
|
/**
|
|
265
305
|
* The environment the service definition carries, built once so the systemd
|
|
266
306
|
* and launchd renderers cannot drift apart — a variable added to one and not
|
|
@@ -450,8 +490,16 @@ export function primeElevation(): boolean {
|
|
|
450
490
|
}
|
|
451
491
|
}
|
|
452
492
|
|
|
453
|
-
/**
|
|
454
|
-
|
|
493
|
+
/**
|
|
494
|
+
* True when privileged commands can run without prompting for a password.
|
|
495
|
+
*
|
|
496
|
+
* Exported because onboarding re-checks it: `needs_root` is the most common
|
|
497
|
+
* reason setup aborts, and it is the one most likely to stop being true (the
|
|
498
|
+
* user gets sudo rights, or primes their timestamp in another terminal). One
|
|
499
|
+
* `sudo -n true`, no prompt, milliseconds — and never on the hook path, which
|
|
500
|
+
* does not reach the first-run gate at all.
|
|
501
|
+
*/
|
|
502
|
+
export function canElevate(): boolean {
|
|
455
503
|
if (typeof process.getuid === "function" && process.getuid() === 0) return true;
|
|
456
504
|
try {
|
|
457
505
|
execFileSync("sudo", ["-n", "true"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
|
|
@@ -568,10 +616,58 @@ export function systemdUnitContents(
|
|
|
568
616
|
const user = assertUnitSafe(serviceUser(), "User");
|
|
569
617
|
assertUnitSafe(binaryPath, "ExecStart");
|
|
570
618
|
assertUnitSafe(homedir(), "HOME");
|
|
619
|
+
|
|
620
|
+
// Gate the unit on the two files it cannot run without, so that an install
|
|
621
|
+
// which is no longer there STOPS rather than thrashes.
|
|
622
|
+
//
|
|
623
|
+
// `npm rm -g failproofai` is the case this is for, and npm runs no uninstall
|
|
624
|
+
// script — see the note on `failproofai uninstall`. It deletes the package,
|
|
625
|
+
// which takes `dist/worker.mjs` with it, while the daemon binary under
|
|
626
|
+
// ~/.failproofai survives. Without a condition systemd keeps a daemon alive
|
|
627
|
+
// whose worker cannot spawn; with a deleted BINARY it is worse, because
|
|
628
|
+
// ExecStart fails 203/EXEC under `Restart=on-failure` and cycles until it
|
|
629
|
+
// trips the start-limit and latches into "start request repeated too
|
|
630
|
+
// quickly" — a state that then refuses a legitimate restart later.
|
|
631
|
+
//
|
|
632
|
+
// A failed condition is not a failure: systemd SKIPS the job, leaves the unit
|
|
633
|
+
// inactive, and `systemctl status` names the exact path that was missing.
|
|
634
|
+
// That turns an unexplained crash-loop into a one-line diagnosis, and
|
|
635
|
+
// `daemonServiceStatus()` reads it back as `condition-failed` so the next CLI
|
|
636
|
+
// command can clear `daemonConfigured` instead of leaving the machine denying
|
|
637
|
+
// every tool call.
|
|
638
|
+
//
|
|
639
|
+
// This deliberately does NOT soften the hook path's fail-closed deny. A
|
|
640
|
+
// machine that was configured to require the daemon still denies while the
|
|
641
|
+
// daemon is absent — being skipped by systemd is not consent to stop
|
|
642
|
+
// enforcing. It shortens how long that lasts and explains why.
|
|
643
|
+
// Only paths that EXIST right now are gated on, and that filter is load-
|
|
644
|
+
// bearing rather than belt-and-braces.
|
|
645
|
+
//
|
|
646
|
+
// `binaryPath` is an ExecStart value, not necessarily a bare path: systemd
|
|
647
|
+
// accepts arguments there, and `FAILPROOFAI_DAEMON_BINARY` is documented as
|
|
648
|
+
// "someone named a binary explicitly" — this repo's own systemd tests set it
|
|
649
|
+
// to `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so
|
|
650
|
+
// gating on that string looks for a file literally named "sleep infinity",
|
|
651
|
+
// never finds it, and skips a unit that would have run perfectly. Splitting
|
|
652
|
+
// on whitespace to recover the binary is worse, because a path may legally
|
|
653
|
+
// contain spaces and there is no way to tell the two apart from here.
|
|
654
|
+
//
|
|
655
|
+
// Existence at render time answers it without guessing: a real binary is on
|
|
656
|
+
// disk when its unit is written (installDaemonService just put it there), and
|
|
657
|
+
// a command-with-arguments is not. Same rule the worker script already
|
|
658
|
+
// follows — never gate on a path that is not there, or the freshly installed
|
|
659
|
+
// unit skips on its very first start.
|
|
660
|
+
const conditionPaths = [binaryPath, workerScriptPath()].filter(
|
|
661
|
+
(p): p is string => typeof p === "string" && existsSync(p),
|
|
662
|
+
);
|
|
663
|
+
const conditionLines = conditionPaths
|
|
664
|
+
.map((p) => `ConditionPathExists=${assertUnitSafe(p, "ConditionPathExists")}\n`)
|
|
665
|
+
.join("");
|
|
666
|
+
|
|
571
667
|
return `[Unit]
|
|
572
668
|
Description=failproofai background daemon (failproofaid) for ${user}
|
|
573
669
|
After=network.target
|
|
574
|
-
|
|
670
|
+
${conditionLines}
|
|
575
671
|
[Service]
|
|
576
672
|
Type=simple
|
|
577
673
|
User=${user}
|
|
@@ -790,6 +886,15 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
|
|
|
790
886
|
*/
|
|
791
887
|
const DAEMON_PROBE_TIMEOUT_MS = 5_000;
|
|
792
888
|
|
|
889
|
+
/**
|
|
890
|
+
* How long the probe keeps waiting for the socket to come up before calling the
|
|
891
|
+
* daemon unreachable. Generous on purpose: this runs once, from an interactive
|
|
892
|
+
* setup command, and the cost of being impatient is aborting setup at a machine
|
|
893
|
+
* whose daemon is merely still starting.
|
|
894
|
+
*/
|
|
895
|
+
const DAEMON_PROBE_READY_TIMEOUT_MS = 10_000;
|
|
896
|
+
const DAEMON_PROBE_RETRY_MS = 150;
|
|
897
|
+
|
|
793
898
|
/**
|
|
794
899
|
* Ask the daemon to evaluate a real hook, end to end.
|
|
795
900
|
*
|
|
@@ -810,26 +915,81 @@ const DAEMON_PROBE_TIMEOUT_MS = 5_000;
|
|
|
810
915
|
* policy would deny and records no tool decision, so probing cannot itself
|
|
811
916
|
* change what the machine does.
|
|
812
917
|
*/
|
|
813
|
-
export
|
|
918
|
+
export type DaemonProbe =
|
|
919
|
+
| { ok: true }
|
|
920
|
+
| {
|
|
921
|
+
ok: false;
|
|
922
|
+
/**
|
|
923
|
+
* `unreachable` — nothing ever accepted a connection on the socket.
|
|
924
|
+
* `worker` — the daemon accepted a connection but could not answer a
|
|
925
|
+
* hook, which is the worker failing to run.
|
|
926
|
+
*
|
|
927
|
+
* Kept apart because the remedies are different, and because reporting
|
|
928
|
+
* "your worker will not start" at someone whose worker is fine sends them
|
|
929
|
+
* to inspect a healthy process. `DaemonFailure` cannot make this
|
|
930
|
+
* distinction: it reports `unreachable` for a refused connection AND for
|
|
931
|
+
* a request that was accepted and never answered.
|
|
932
|
+
*/
|
|
933
|
+
reason: "unreachable" | "worker";
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Ask the daemon to evaluate a real hook, end to end.
|
|
938
|
+
*
|
|
939
|
+
* The thing this catches that nothing else did: a unit that is *running* while
|
|
940
|
+
* the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
|
|
941
|
+
* and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
|
|
942
|
+
* systemd reports as perfectly active and a worker that dies on every spawn.
|
|
943
|
+
*
|
|
944
|
+
* **Why this retries.** It is called moments after `systemctl enable --now`, and
|
|
945
|
+
* a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before
|
|
946
|
+
* the daemon has bound its socket. A single attempt therefore raced the bind
|
|
947
|
+
* and, because the hook path's connect budget is deliberately 150ms, lost that
|
|
948
|
+
* race on any loaded machine. Setup then aborted with "its worker process could
|
|
949
|
+
* not be run" at a daemon that was seconds away from serving happily — the
|
|
950
|
+
* worker had already logged that it was listening. Waiting for the socket is
|
|
951
|
+
* the fix; relaxing the 150ms is NOT, because that budget is what keeps a dead
|
|
952
|
+
* daemon from adding latency to every tool call on the hook path.
|
|
953
|
+
*/
|
|
954
|
+
export async function probeDaemon(): Promise<DaemonProbe> {
|
|
814
955
|
try {
|
|
815
|
-
const { attemptDaemonHook } = await import("./daemon-client");
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
956
|
+
const { attemptDaemonHook, daemonAcceptsConnections } = await import("./daemon-client");
|
|
957
|
+
const deadline = Date.now() + DAEMON_PROBE_READY_TIMEOUT_MS;
|
|
958
|
+
let everConnected = false;
|
|
959
|
+
|
|
960
|
+
for (;;) {
|
|
961
|
+
if (await daemonAcceptsConnections()) {
|
|
962
|
+
everConnected = true;
|
|
963
|
+
const attempt = await attemptDaemonHook(
|
|
964
|
+
{
|
|
965
|
+
hookEvent: "SessionStart",
|
|
966
|
+
cli: "claude",
|
|
967
|
+
stdin: JSON.stringify({
|
|
968
|
+
hook_event_name: "SessionStart",
|
|
969
|
+
source: "failproofai-health-probe",
|
|
970
|
+
}),
|
|
971
|
+
},
|
|
972
|
+
{ responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
|
|
973
|
+
);
|
|
974
|
+
// A protocol mismatch is a REACHABLE daemon of the wrong vintage, which
|
|
975
|
+
// is a version problem rather than the lockout this probe exists to
|
|
976
|
+
// find. It is reported, and acted on, elsewhere.
|
|
977
|
+
if (attempt.ok || attempt.failure === "protocol-mismatch") return { ok: true };
|
|
978
|
+
}
|
|
979
|
+
if (Date.now() >= deadline) break;
|
|
980
|
+
await new Promise((r) => setTimeout(r, DAEMON_PROBE_RETRY_MS));
|
|
981
|
+
}
|
|
982
|
+
return { ok: false, reason: everConnected ? "worker" : "unreachable" };
|
|
828
983
|
} catch {
|
|
829
|
-
return false;
|
|
984
|
+
return { ok: false, reason: "unreachable" };
|
|
830
985
|
}
|
|
831
986
|
}
|
|
832
987
|
|
|
988
|
+
/** Boolean form, for callers that only branch on healthy/not. */
|
|
989
|
+
export async function probeDaemonEndToEnd(): Promise<boolean> {
|
|
990
|
+
return (await probeDaemon()).ok;
|
|
991
|
+
}
|
|
992
|
+
|
|
833
993
|
/**
|
|
834
994
|
* Waits for the service to report running, then re-checks after a settle
|
|
835
995
|
* window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
|
|
@@ -1283,6 +1443,59 @@ export function daemonVersionSkew(): { installed: string; expected: string } | n
|
|
|
1283
1443
|
return recorded === version ? null : { installed: recorded, expected: version };
|
|
1284
1444
|
}
|
|
1285
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Why a Linux unit that exists is not active: skipped on a condition, or
|
|
1448
|
+
* merely stopped.
|
|
1449
|
+
*
|
|
1450
|
+
* Only reached once `is-active` has already said "not active", so it costs a
|
|
1451
|
+
* second `systemctl` call on the unhealthy path and nothing at all on the
|
|
1452
|
+
* healthy one.
|
|
1453
|
+
*
|
|
1454
|
+
* `ConditionResult` is systemd's own record of the last condition evaluation
|
|
1455
|
+
* and is `no` only after it actually ran them and one failed. A unit that has
|
|
1456
|
+
* never been started since boot reports `yes` (the field's default), so this
|
|
1457
|
+
* cannot invent a `condition-failed` for a unit systemd has not judged — it
|
|
1458
|
+
* under-reports rather than over-reports, which is the safe direction for a
|
|
1459
|
+
* signal that clears `daemonConfigured`.
|
|
1460
|
+
*/
|
|
1461
|
+
function inactiveLinuxStatus(): DaemonServiceStatus {
|
|
1462
|
+
try {
|
|
1463
|
+
return interpretConditionResult(
|
|
1464
|
+
execFileSync(
|
|
1465
|
+
"systemctl",
|
|
1466
|
+
["show", systemdUnitName(), "--property=ConditionResult", "--value"],
|
|
1467
|
+
{ stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
|
|
1468
|
+
).toString(),
|
|
1469
|
+
);
|
|
1470
|
+
} catch {
|
|
1471
|
+
// No systemd, no systemctl, or a version without `--value`. The unit file
|
|
1472
|
+
// exists, so "stopped" remains the honest answer — and it is the
|
|
1473
|
+
// conservative one, because nothing acts destructively on it.
|
|
1474
|
+
return "stopped";
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* `systemctl show --property=ConditionResult --value` → a status.
|
|
1480
|
+
*
|
|
1481
|
+
* Split out from the subprocess so the interpretation can be tested without
|
|
1482
|
+
* `/etc/systemd/system`, which this code reads at a fixed path and no test may
|
|
1483
|
+
* write. The live behaviour — that systemd actually skips a unit whose
|
|
1484
|
+
* `ConditionPathExists=` fails, and records `no` when it does — is proven
|
|
1485
|
+
* against a real systemd in the container test rather than asserted here.
|
|
1486
|
+
*
|
|
1487
|
+
* Only a literal `no` means condition-failed. Anything else — `yes`, an empty
|
|
1488
|
+
* string from a unit systemd has not evaluated since boot, an unfamiliar word
|
|
1489
|
+
* from a future version — is "stopped", which is the state nothing acts
|
|
1490
|
+
* destructively on. This under-reports rather than over-reports on purpose: the
|
|
1491
|
+
* cost of a missed `condition-failed` is a flag cleared one command later, and
|
|
1492
|
+
* the cost of a false one is a healthy machine silently dropped to the
|
|
1493
|
+
* in-process path.
|
|
1494
|
+
*/
|
|
1495
|
+
export function interpretConditionResult(raw: string): DaemonServiceStatus {
|
|
1496
|
+
return raw.trim() === "no" ? "condition-failed" : "stopped";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1286
1499
|
export function daemonServiceStatus(): DaemonServiceStatus {
|
|
1287
1500
|
if (!isDaemonSupportedPlatform()) return "unsupported-platform";
|
|
1288
1501
|
|
|
@@ -1297,14 +1510,14 @@ export function daemonServiceStatus(): DaemonServiceStatus {
|
|
|
1297
1510
|
})
|
|
1298
1511
|
.toString()
|
|
1299
1512
|
.trim();
|
|
1300
|
-
return out === "active" ? "running" :
|
|
1513
|
+
return out === "active" ? "running" : inactiveLinuxStatus();
|
|
1301
1514
|
} catch {
|
|
1302
1515
|
// `systemctl is-active` exits non-zero (and execFileSync throws) for
|
|
1303
1516
|
// every non-"active" state — inactive, failed, or the command not
|
|
1304
|
-
// working at all (no systemd user session, systemctl missing).
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
return
|
|
1517
|
+
// working at all (no systemd user session, systemctl missing). The unit
|
|
1518
|
+
// file existing already ruled out "not-installed", so the only question
|
|
1519
|
+
// left is whether systemd skipped it on a condition.
|
|
1520
|
+
return inactiveLinuxStatus();
|
|
1308
1521
|
}
|
|
1309
1522
|
}
|
|
1310
1523
|
|
|
@@ -15,15 +15,23 @@
|
|
|
15
15
|
/**
|
|
16
16
|
* Subcommands that must never be interrupted by onboarding.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* The first three are configuration actions in their own right: `config` IS the
|
|
19
19
|
* wizard, and `policies` / `policy` are the non-interactive way to do setup.
|
|
20
20
|
* Putting a wizard in front of them would override an intent the user just
|
|
21
21
|
* stated, and would hang any script that calls them.
|
|
22
|
+
*
|
|
23
|
+
* `uninstall` is here for the sharper version of the same reason: it states the
|
|
24
|
+
* exact OPPOSITE intent. Offering to set this machine up, on the way to tearing
|
|
25
|
+
* it down, would install hooks and a root-owned systemd unit seconds before the
|
|
26
|
+
* command removes them — and on a machine that had drifted to unconfigured
|
|
27
|
+
* (a cleared flag, a reset home), that is the difference between a clean
|
|
28
|
+
* uninstall and one that leaves behind more than it found.
|
|
22
29
|
*/
|
|
23
30
|
export const FIRST_RUN_EXEMPT_SUBCOMMANDS: readonly string[] = [
|
|
24
31
|
"config",
|
|
25
32
|
"policies",
|
|
26
33
|
"policy",
|
|
34
|
+
"uninstall",
|
|
27
35
|
];
|
|
28
36
|
|
|
29
37
|
export function shouldOfferFirstRun(args: readonly string[]): boolean {
|
package/src/hooks/fp-home.ts
CHANGED
|
@@ -233,6 +233,19 @@ export const auditScheduleFile = (home?: string) => resolve(stateDir(home), "aud
|
|
|
233
233
|
*/
|
|
234
234
|
export const telemetryIdFile = (home?: string) => resolve(stateDir(home), "telemetry-id");
|
|
235
235
|
export const launcherMarker = (home?: string) => resolve(stateDir(home), "launcher-configured");
|
|
236
|
+
/**
|
|
237
|
+
* The last first-run setup attempt that ABORTED, and why.
|
|
238
|
+
*
|
|
239
|
+
* Deliberately NOT one of `isConfigured()`'s signals: a failed attempt must
|
|
240
|
+
* never make a machine read as set up. It exists only so the next command can
|
|
241
|
+
* tell "never tried" from "tried and could not finish" — the wizard writes
|
|
242
|
+
* nothing on an abort, so without this the two are identical and setup
|
|
243
|
+
* relaunches on every single command forever.
|
|
244
|
+
*
|
|
245
|
+
* Under `state/` with the other derived markers a human never opens.
|
|
246
|
+
*/
|
|
247
|
+
export const onboardingAttemptFile = (home?: string) =>
|
|
248
|
+
resolve(stateDir(home), "onboarding-attempt.json");
|
|
236
249
|
/**
|
|
237
250
|
* The version that last reported an install, so an upgrade is reported once.
|
|
238
251
|
*
|
package/src/hooks/fp-reset.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { detectLayout, readConfig, updateConfig, writeVersionFile, type LayoutState } from "./fp-config";
|
|
42
42
|
import {
|
|
43
43
|
daemonServiceStatus,
|
|
44
|
+
daemonStatusCommand,
|
|
44
45
|
daemonVersionSkew,
|
|
45
46
|
isDaemonSupportedPlatform,
|
|
46
47
|
probeDaemonEndToEnd,
|
|
@@ -253,6 +254,30 @@ async function healDaemonFlag(): Promise<string[]> {
|
|
|
253
254
|
];
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
// Installed, and systemd has refused to start it: one of the paths the unit
|
|
258
|
+
// is gated on is gone. This is what `npm rm -g failproofai` leaves behind —
|
|
259
|
+
// npm runs no uninstall script, so the unit survives the package that
|
|
260
|
+
// supplies its worker, and every tool call on the machine then denies with
|
|
261
|
+
// nothing to point at.
|
|
262
|
+
//
|
|
263
|
+
// Treated like "not-installed" rather than like "stopped" because systemd
|
|
264
|
+
// has already made the call and will keep making it at every boot. That is
|
|
265
|
+
// the distinction `condition-failed` exists to carry; see its definition.
|
|
266
|
+
if (status === "condition-failed") {
|
|
267
|
+
updateConfig({ daemon: { configured: false } });
|
|
268
|
+
return [
|
|
269
|
+
`failproofaid is installed but cannot start — a file its service requires is`,
|
|
270
|
+
`gone (most often because failproofai was removed with \`npm rm -g\`, which`,
|
|
271
|
+
`deletes the worker but leaves the service behind). This machine was`,
|
|
272
|
+
`configured to require the daemon, which denies every tool call, so that flag`,
|
|
273
|
+
`is cleared; policies now evaluate in-process.`,
|
|
274
|
+
``,
|
|
275
|
+
`Run \`failproofai uninstall\` to remove the leftover service, or`,
|
|
276
|
+
`\`failproofai config\` to rebuild it. \`${daemonStatusCommand()}\` names the missing path.`,
|
|
277
|
+
``,
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
|
|
256
281
|
// Installed and RUNNING is not the same as working, and the difference is
|
|
257
282
|
// a total lockout. `ExecStart` bakes in `process.execPath`, so an
|
|
258
283
|
// `nvm uninstall 20` months later leaves a unit systemd still calls active
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why the last first-run setup stopped, and whether it is worth offering again.
|
|
3
|
+
*
|
|
4
|
+
* # The problem this exists for
|
|
5
|
+
*
|
|
6
|
+
* Setup is deliberately all-or-nothing: every abort path writes NOTHING, so a
|
|
7
|
+
* machine that could not be configured is left exactly as it was found rather
|
|
8
|
+
* than carrying half a configuration (a `daemonConfigured` flag with no daemon
|
|
9
|
+
* behind it denies every tool call across all twelve CLIs). That invariant is
|
|
10
|
+
* right and is not changed here.
|
|
11
|
+
*
|
|
12
|
+
* But it left onboarding with no memory. `isConfigured()` reads three signals —
|
|
13
|
+
* a global policy config, live user-scope hooks, the legacy marker — and an
|
|
14
|
+
* abort sets none of them, so "never tried" and "tried twenty times and could
|
|
15
|
+
* not finish" are indistinguishable. On a machine without passwordless sudo,
|
|
16
|
+
* every single command relaunched the whole wizard, forever.
|
|
17
|
+
*
|
|
18
|
+
* # What this is NOT
|
|
19
|
+
*
|
|
20
|
+
* Not a fourth "configured" signal. Nothing here may make `isConfigured()` true.
|
|
21
|
+
* A machine that failed setup is unconfigured and must keep saying so — to the
|
|
22
|
+
* hook path, to `--status`, and to the wizard when it is asked for by name.
|
|
23
|
+
* This only changes whether the wizard is *offered unprompted*.
|
|
24
|
+
*
|
|
25
|
+
* # Re-offering
|
|
26
|
+
*
|
|
27
|
+
* A hint that never becomes an offer again is its own failure: the common case
|
|
28
|
+
* is someone who hits the sudo prompt, gives up, and comes back later able to
|
|
29
|
+
* elevate. So each reason carries a cheap local check for whether its blocker
|
|
30
|
+
* is gone, and the wizard offers itself again the moment one is:
|
|
31
|
+
*
|
|
32
|
+
* needs_root -> elevation is now possible without a prompt
|
|
33
|
+
* daemon_failed -> the service manager now reports something different
|
|
34
|
+
* cancelled -> the CLI version changed; an upgrade is a fair moment to
|
|
35
|
+
* ask again, and nothing else about a deliberate cancel
|
|
36
|
+
* should re-nag
|
|
37
|
+
*
|
|
38
|
+
* Every probe is local and takes milliseconds. None of them runs on the hook
|
|
39
|
+
* path — `--hook` never reaches the first-run gate at all.
|
|
40
|
+
*/
|
|
41
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { dirname } from "node:path";
|
|
43
|
+
import { onboardingAttemptFile } from "./fp-home";
|
|
44
|
+
import type { WizardAbort } from "./configure-wizard";
|
|
45
|
+
|
|
46
|
+
/** Bumped only for a deliberate shape change; an unreadable record is ignored. */
|
|
47
|
+
const SCHEMA_VERSION = 1;
|
|
48
|
+
|
|
49
|
+
export interface OnboardingAttempt {
|
|
50
|
+
schemaVersion: number;
|
|
51
|
+
/** Why the wizard stopped. */
|
|
52
|
+
reason: WizardAbort;
|
|
53
|
+
/** The CLI that made the attempt, so an upgrade can re-offer. */
|
|
54
|
+
cliVersion: string;
|
|
55
|
+
/**
|
|
56
|
+
* What the service manager said at the time. Compared, never trusted — the
|
|
57
|
+
* point is only whether it has CHANGED since.
|
|
58
|
+
*/
|
|
59
|
+
daemonStatus: string;
|
|
60
|
+
/** Epoch ms, for support and for nothing else. */
|
|
61
|
+
at: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readOnboardingAttempt(): OnboardingAttempt | null {
|
|
65
|
+
const path = onboardingAttemptFile();
|
|
66
|
+
if (!existsSync(path)) return null;
|
|
67
|
+
try {
|
|
68
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<OnboardingAttempt>;
|
|
69
|
+
if (raw.schemaVersion !== SCHEMA_VERSION) return null;
|
|
70
|
+
if (typeof raw.reason !== "string" || typeof raw.cliVersion !== "string") return null;
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: SCHEMA_VERSION,
|
|
73
|
+
reason: raw.reason as WizardAbort,
|
|
74
|
+
cliVersion: raw.cliVersion,
|
|
75
|
+
daemonStatus: typeof raw.daemonStatus === "string" ? raw.daemonStatus : "",
|
|
76
|
+
at: typeof raw.at === "number" ? raw.at : 0,
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
// A corrupt record reads as "no record", which degrades to today's
|
|
80
|
+
// behaviour — offering the wizard. Never to "configured".
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Best-effort: a machine that cannot record this simply keeps being offered. */
|
|
86
|
+
export function recordOnboardingAttempt(
|
|
87
|
+
reason: WizardAbort,
|
|
88
|
+
cliVersion: string,
|
|
89
|
+
daemonStatus: string,
|
|
90
|
+
now: number = Date.now(),
|
|
91
|
+
): void {
|
|
92
|
+
try {
|
|
93
|
+
const path = onboardingAttemptFile();
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
+
const record: OnboardingAttempt = {
|
|
96
|
+
schemaVersion: SCHEMA_VERSION,
|
|
97
|
+
reason,
|
|
98
|
+
cliVersion,
|
|
99
|
+
daemonStatus,
|
|
100
|
+
at: now,
|
|
101
|
+
};
|
|
102
|
+
writeFileSync(path, JSON.stringify(record, null, 2), "utf8");
|
|
103
|
+
} catch {
|
|
104
|
+
// best-effort
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Called on a completed apply. A stale record would keep suppressing the offer
|
|
110
|
+
* on a machine that has since been reconfigured by hand and un-configured again.
|
|
111
|
+
*/
|
|
112
|
+
export function clearOnboardingAttempt(): void {
|
|
113
|
+
try {
|
|
114
|
+
rmSync(onboardingAttemptFile(), { force: true });
|
|
115
|
+
} catch {
|
|
116
|
+
// best-effort
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RetryProbe {
|
|
121
|
+
/** Elevation is possible without prompting. */
|
|
122
|
+
canElevate: () => boolean;
|
|
123
|
+
/** What the service manager says right now. */
|
|
124
|
+
daemonStatus: () => string;
|
|
125
|
+
/** This CLI's version. */
|
|
126
|
+
cliVersion: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether the thing that stopped setup last time has since changed.
|
|
131
|
+
*
|
|
132
|
+
* `true` means offer the wizard again; `false` means a one-line hint. An
|
|
133
|
+
* unrecognised reason returns `true` — an unknown blocker is one we cannot
|
|
134
|
+
* prove is still present, and the safe direction is offering setup rather than
|
|
135
|
+
* silently withholding it.
|
|
136
|
+
*/
|
|
137
|
+
export function blockerCleared(attempt: OnboardingAttempt, probe: RetryProbe): boolean {
|
|
138
|
+
switch (attempt.reason) {
|
|
139
|
+
case "needs_root":
|
|
140
|
+
return probe.canElevate();
|
|
141
|
+
case "daemon_failed":
|
|
142
|
+
// Any movement at all: not-installed -> stopped, stopped -> running, or a
|
|
143
|
+
// platform that has gained a service manager. Comparing rather than
|
|
144
|
+
// demanding "running" is what lets a partially-repaired machine be
|
|
145
|
+
// offered setup again instead of waiting for a state it cannot reach on
|
|
146
|
+
// its own.
|
|
147
|
+
return probe.daemonStatus() !== attempt.daemonStatus || probe.canElevate();
|
|
148
|
+
case "cancelled":
|
|
149
|
+
// A deliberate stop. Re-asking on the next command is exactly the nagging
|
|
150
|
+
// this module removes — but an upgrade is a new thing to say, so it is
|
|
151
|
+
// allowed to ask once more.
|
|
152
|
+
return probe.cliVersion !== attempt.cliVersion;
|
|
153
|
+
case "not_a_tty":
|
|
154
|
+
case "running_as_sudo":
|
|
155
|
+
// Both are properties of the invocation, not of the machine, so the next
|
|
156
|
+
// one may well be fine.
|
|
157
|
+
return true;
|
|
158
|
+
default:
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The one line shown instead of relaunching the wizard. */
|
|
164
|
+
export function attemptHintLines(attempt: OnboardingAttempt): string[] {
|
|
165
|
+
const why: Record<string, string> = {
|
|
166
|
+
needs_root: "it needs root to install the failproofaid service",
|
|
167
|
+
daemon_failed: "the failproofaid service could not be started",
|
|
168
|
+
cancelled: "it was cancelled",
|
|
169
|
+
not_a_tty: "there was no terminal to ask in",
|
|
170
|
+
running_as_sudo: "it was run under sudo",
|
|
171
|
+
};
|
|
172
|
+
const detail = why[attempt.reason] ?? "it did not finish";
|
|
173
|
+
return [
|
|
174
|
+
``,
|
|
175
|
+
`[failproofai] Setup is not finished — ${detail}.`,
|
|
176
|
+
` Run \`failproofai config\` when you are ready.`,
|
|
177
|
+
``,
|
|
178
|
+
];
|
|
179
|
+
}
|