failproofai 1.0.0-beta.7 → 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.
Files changed (124) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/index.html +1 -1
  39. package/.next/standalone/.next/server/app/index.rsc +14 -14
  40. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  42. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  43. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  44. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  46. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  49. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  62. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +3 -3
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ommekk._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0c_qcgp._.js → [root-of-the-server]__1tkyag1._.js} +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tyf0tc._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_04mekdy._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_194yh9u._.js → _0i4l3_6._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_0r-dsre._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_0se484m._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/_0uz7o9y._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/_1fahg9e._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/_1w8sola._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/_1w_6p9g._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  89. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  90. package/.next/standalone/.next/server/pages/404.html +1 -1
  91. package/.next/standalone/.next/server/pages/500.html +1 -1
  92. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  93. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  94. package/.next/standalone/.next/static/chunks/{2aej0vi1fm87m.js → 02vvywmww22zz.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{3t1wr_kx6brwc.js → 0_-focvo4cuiv.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{2bxld6-rym3ah.js → 1fwh-2un_l-eo.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2p2kz4aj1tooj.js → 1zrdsxe91qq6i.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{0m-m7wgtxdoav.js → 2047vah_v4emu.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/23hl78dzffun4.js +6 -0
  100. package/.next/standalone/.next/static/chunks/{11l6oh9cyb35a.js → 2bw0stk-s-1dd.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{0gr76ps8_zm26.js → 2r_j5wrkbu5wm.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/3m8onpqrjxzu5.js +1 -0
  103. package/.next/standalone/.next/static/chunks/{36pc3_8fpb6ic.js → 3yn1y6ckypqwp.js} +1 -1
  104. package/.next/standalone/package.json +5 -5
  105. package/.next/standalone/server.js +1 -1
  106. package/bin/failproofai.mjs +118 -1
  107. package/dist/cli.mjs +1166 -101
  108. package/dist/worker.mjs +77 -6
  109. package/package.json +5 -5
  110. package/scripts/repro-npm-install.sh +155 -0
  111. package/src/hooks/builtin-policies.ts +154 -2
  112. package/src/hooks/configure-wizard.ts +16 -6
  113. package/src/hooks/daemon-client.ts +35 -0
  114. package/src/hooks/daemon-service.ts +227 -22
  115. package/src/hooks/first-run-gate.ts +9 -1
  116. package/src/hooks/fp-reset.ts +25 -0
  117. package/src/hooks/uninstall-cli.ts +345 -0
  118. package/src/hooks/worker-server.ts +12 -2
  119. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kbc9bs._.js +0 -3
  120. package/.next/standalone/.next/static/chunks/0945ybz3fsvba.js +0 -1
  121. package/.next/standalone/.next/static/chunks/166f8qnh03jqc.js +0 -6
  122. /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → gOR8i03AeQm1VnIayDHVV}/_buildManifest.js +0 -0
  123. /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → gOR8i03AeQm1VnIayDHVV}/_clientMiddlewareManifest.js +0 -0
  124. /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → 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 = "running" | "stopped" | "not-installed" | "unsupported-platform";
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
@@ -576,10 +616,58 @@ export function systemdUnitContents(
576
616
  const user = assertUnitSafe(serviceUser(), "User");
577
617
  assertUnitSafe(binaryPath, "ExecStart");
578
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
+
579
667
  return `[Unit]
580
668
  Description=failproofai background daemon (failproofaid) for ${user}
581
669
  After=network.target
582
-
670
+ ${conditionLines}
583
671
  [Service]
584
672
  Type=simple
585
673
  User=${user}
@@ -798,6 +886,15 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
798
886
  */
799
887
  const DAEMON_PROBE_TIMEOUT_MS = 5_000;
800
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
+
801
898
  /**
802
899
  * Ask the daemon to evaluate a real hook, end to end.
803
900
  *
@@ -818,26 +915,81 @@ const DAEMON_PROBE_TIMEOUT_MS = 5_000;
818
915
  * policy would deny and records no tool decision, so probing cannot itself
819
916
  * change what the machine does.
820
917
  */
821
- export async function probeDaemonEndToEnd(): Promise<boolean> {
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> {
822
955
  try {
823
- const { attemptDaemonHook } = await import("./daemon-client");
824
- const attempt = await attemptDaemonHook(
825
- {
826
- hookEvent: "SessionStart",
827
- cli: "claude",
828
- stdin: JSON.stringify({ hook_event_name: "SessionStart", source: "failproofai-health-probe" }),
829
- },
830
- { responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
831
- );
832
- // A protocol mismatch is a REACHABLE daemon of the wrong vintage. The hook
833
- // path already falls back rather than denying for that case, so it is not
834
- // the lockout this probe exists to find.
835
- return attempt.ok || attempt.failure === "protocol-mismatch";
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" };
836
983
  } catch {
837
- return false;
984
+ return { ok: false, reason: "unreachable" };
838
985
  }
839
986
  }
840
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
+
841
993
  /**
842
994
  * Waits for the service to report running, then re-checks after a settle
843
995
  * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
@@ -1291,6 +1443,59 @@ export function daemonVersionSkew(): { installed: string; expected: string } | n
1291
1443
  return recorded === version ? null : { installed: recorded, expected: version };
1292
1444
  }
1293
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
+
1294
1499
  export function daemonServiceStatus(): DaemonServiceStatus {
1295
1500
  if (!isDaemonSupportedPlatform()) return "unsupported-platform";
1296
1501
 
@@ -1305,14 +1510,14 @@ export function daemonServiceStatus(): DaemonServiceStatus {
1305
1510
  })
1306
1511
  .toString()
1307
1512
  .trim();
1308
- return out === "active" ? "running" : "stopped";
1513
+ return out === "active" ? "running" : inactiveLinuxStatus();
1309
1514
  } catch {
1310
1515
  // `systemctl is-active` exits non-zero (and execFileSync throws) for
1311
1516
  // every non-"active" state — inactive, failed, or the command not
1312
- // working at all (no systemd user session, systemctl missing). All
1313
- // of those are indistinguishable from "stopped" from here, and the
1314
- // unit file existing is what already ruled out "not-installed".
1315
- return "stopped";
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();
1316
1521
  }
1317
1522
  }
1318
1523
 
@@ -15,15 +15,23 @@
15
15
  /**
16
16
  * Subcommands that must never be interrupted by onboarding.
17
17
  *
18
- * All three are configuration actions in their own right: `config` IS the
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 {
@@ -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