failproofai 0.0.15 → 1.0.0-beta.1

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 (135) 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 +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/run/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 +9 -9
  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/chunks/[root-of-the-server]__0id_xf7._.js +3 -0
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  66. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0fopait._.js → [root-of-the-server]__1ig795d._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__1yo1gyz._.js} +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0hs_5mh.js → node_modules_html-to-image_es_index_14q3-7b.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  84. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  85. package/.next/standalone/.next/server/pages/404.html +1 -1
  86. package/.next/standalone/.next/server/pages/500.html +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.json +11 -11
  89. package/.next/standalone/.next/static/chunks/{3m8wmvlhsy1mx.js → 07ix3lb4gib0x.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0fdx7ikab08-g.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 191njgoik03fm.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1b3a1scouf40o.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 1d71o1_tav721.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 24gs8ip6_spcb.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2ovmv4esqalpb.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 30is-6r00sbd7.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 35584kn409ad1.js} +1 -1
  98. package/.next/standalone/Cargo.lock +428 -0
  99. package/.next/standalone/Cargo.toml +9 -0
  100. package/.next/standalone/crates/PROTOCOL.md +122 -0
  101. package/.next/standalone/crates/failproofaid/Cargo.toml +18 -0
  102. package/.next/standalone/crates/failproofaid/src/lock.rs +81 -0
  103. package/.next/standalone/crates/failproofaid/src/main.rs +83 -0
  104. package/.next/standalone/crates/failproofaid/src/paths.rs +172 -0
  105. package/.next/standalone/crates/failproofaid/src/server.rs +502 -0
  106. package/.next/standalone/crates/failproofaid/src/worker.rs +400 -0
  107. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +241 -0
  108. package/.next/standalone/crates/fpai-ipc/Cargo.toml +16 -0
  109. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +175 -0
  110. package/.next/standalone/crates/fpai-ipc/src/framing.rs +177 -0
  111. package/.next/standalone/crates/fpai-ipc/src/lib.rs +10 -0
  112. package/.next/standalone/crates/fpai-ipc/src/peer.rs +80 -0
  113. package/.next/standalone/package.json +5 -3
  114. package/.next/standalone/rust-toolchain.toml +4 -0
  115. package/.next/standalone/server.js +1 -1
  116. package/bin/failproofai-worker.mjs +51 -0
  117. package/bin/failproofai.mjs +68 -1
  118. package/bin/failproofaid-shim.mjs +67 -0
  119. package/dist/cli.mjs +1385 -707
  120. package/dist/worker.mjs +8441 -0
  121. package/package.json +5 -3
  122. package/src/hooks/builtin-policies.ts +37 -13
  123. package/src/hooks/configure-wizard.ts +77 -1
  124. package/src/hooks/daemon-client.ts +199 -0
  125. package/src/hooks/daemon-download.ts +152 -0
  126. package/src/hooks/daemon-service.ts +650 -0
  127. package/src/hooks/handler.ts +231 -195
  128. package/src/hooks/normalize-cli-payload.ts +61 -0
  129. package/src/hooks/policy-types.ts +15 -0
  130. package/src/hooks/read-stdin.ts +48 -0
  131. package/src/hooks/worker-server.ts +154 -0
  132. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1is3glr._.js +0 -3
  133. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_buildManifest.js +0 -0
  134. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_clientMiddlewareManifest.js +0 -0
  135. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_ssgManifest.js +0 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Installs/uninstalls/checks failproofaid as a real OS-level user service
3
+ * (systemd `--user` on Linux, launchd `LaunchAgent` on macOS) so it's
4
+ * "constant" — starts at login, restarts on crash — without ever needing
5
+ * elevation. User-scope only, matching the daemon itself.
6
+ *
7
+ * No public `failproofai daemon install`-style subcommand exists —
8
+ * `configure-wizard.ts` calls the functions here directly, the same
9
+ * relationship it already has with `manager.ts`'s `installHooks()`.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
12
+ import { homedir, tmpdir, userInfo } from "node:os";
13
+ import { resolve, dirname } from "node:path";
14
+ import { execFileSync } from "node:child_process";
15
+ import { hookLogWarn } from "./hook-logger";
16
+ import { getConfigPathForScope } from "./hooks-config";
17
+ import { downloadFailproofaidBinary, installedBinaryPath } from "./daemon-download";
18
+
19
+ /**
20
+ * Every `systemctl --user` / `launchctl` call is bounded. Both talk to a
21
+ * per-user session bus or to launchd, and a wedged session makes an
22
+ * unbounded `execFileSync` block forever — inside the interactive wizard
23
+ * that reads as a hang with no output at all (`stdio: "ignore"`), right
24
+ * after the user pressed "apply". A timeout throws instead, which the
25
+ * existing `catch` already turns into a clean `{ installed: false, reason }`.
26
+ */
27
+ const SERVICE_CMD_TIMEOUT_MS = 10_000;
28
+
29
+ /**
30
+ * How long to wait for the service manager to actually get the daemon into
31
+ * a running state after `enable --now` / `load -w`. Both commands return as
32
+ * soon as the job is accepted, which is well before the process has proven
33
+ * it can stay up.
34
+ */
35
+ const SERVICE_START_TIMEOUT_MS = 5_000;
36
+ const SERVICE_START_POLL_MS = 100;
37
+ /**
38
+ * How long a unit has to still be running after it first reports running.
39
+ * `systemctl --user is-active` calls a `Type=simple` unit active the moment
40
+ * it forks, so a daemon that dies immediately still reports active once —
41
+ * a single check would wave through exactly the crash-at-startup case this
42
+ * is here to catch. Comfortably longer than the unit's `RestartSec=2`
43
+ * head start, so a daemon that already died reads as
44
+ * `activating (auto-restart)` by the time of the re-check.
45
+ */
46
+ const SERVICE_SETTLE_MS = 750;
47
+
48
+ /**
49
+ * Writes (or clears) the machine-wide `daemonConfigured` marker in
50
+ * `~/.failproofai/policies-config.json`.
51
+ *
52
+ * This flag is what makes `bin/failproofai.mjs` fail *closed* — deny every
53
+ * hook event rather than fall back to in-process evaluation — so it has to
54
+ * track "this machine has a daemon", not "a service manager once accepted a
55
+ * job". Install only sets it after the daemon is verified running; uninstall
56
+ * clears it, so removing the service restores the in-process path instead of
57
+ * leaving the machine denying every tool call across all 11 CLIs against a
58
+ * socket that no longer exists.
59
+ *
60
+ * Global scope only: whether *this machine* runs a daemon is not a
61
+ * per-project setting.
62
+ */
63
+ export function setDaemonConfigured(value: boolean): void {
64
+ const path = getConfigPathForScope("user");
65
+ let config: Record<string, unknown> = {};
66
+ try {
67
+ if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
68
+ } catch {
69
+ return; // a malformed global config is the install path's problem, not ours
70
+ }
71
+ if (value) config.daemonConfigured = true;
72
+ else delete config.daemonConfigured;
73
+ try {
74
+ mkdirSync(dirname(path), { recursive: true });
75
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
76
+ } catch {
77
+ /* best-effort: never fail a completed setup (or uninstall) over this flag */
78
+ }
79
+ }
80
+
81
+ export type DaemonServiceStatus = "running" | "stopped" | "not-installed" | "unsupported-platform";
82
+
83
+ /** Linux + macOS only, per the plan's platform scope — full stop. */
84
+ export function isDaemonSupportedPlatform(): boolean {
85
+ return process.platform === "linux" || process.platform === "darwin";
86
+ }
87
+
88
+ export type PlatformKey = "linux-x64" | "linux-arm64" | "darwin-x64" | "darwin-arm64";
89
+
90
+ function platformKey(): PlatformKey | null {
91
+ const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : null;
92
+ const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
93
+ if (!os || !arch) return null;
94
+ return `${os}-${arch}` as PlatformKey;
95
+ }
96
+
97
+ /**
98
+ * Locates the real, compiled `failproofaid` binary — never the JS bin
99
+ * shim (`bin/failproofaid-shim.mjs`), which only exists so a user can run
100
+ * `failproofaid` by hand. A service manager needs a direct path to the
101
+ * actual native binary, not a wrapper it would have to keep alive itself.
102
+ *
103
+ * Resolution order: an explicit test/dev override, the binary downloaded
104
+ * from this version's GitHub Release (see `daemon-download.ts`), then a
105
+ * locally-built dev binary under `target/{release,debug}/failproofaid`
106
+ * relative to the package root — so this works when driving the wizard from
107
+ * a source checkout (`bun run daemon:dev`-style flows) with nothing
108
+ * downloaded at all.
109
+ *
110
+ * Read-only by design: it reports what is already on disk and never fetches.
111
+ * `ensureFailproofaidBinary` is the one that may reach the network, so the
112
+ * hook path — which calls this — can never block on a download.
113
+ */
114
+ export function resolveFailproofaidBinaryPath(): string | null {
115
+ if (process.env.FAILPROOFAI_DAEMON_BINARY) return process.env.FAILPROOFAI_DAEMON_BINARY;
116
+
117
+ const downloaded = installedBinaryPath();
118
+ if (existsSync(downloaded)) return downloaded;
119
+
120
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
121
+ if (packageRoot) {
122
+ for (const profile of ["release", "debug"]) {
123
+ const candidate = resolve(packageRoot, "target", profile, "failproofaid");
124
+ if (existsSync(candidate)) return candidate;
125
+ }
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Resolves the binary, downloading it from this version's release if it is
133
+ * not already on disk.
134
+ *
135
+ * Only the install path calls this. The npm package ships no binary — it is
136
+ * one CLI tarball for every platform — so `failproofai config` choosing the
137
+ * global scope is the moment a machine that opted into a daemon actually
138
+ * acquires one.
139
+ */
140
+ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> {
141
+ const existing = resolveFailproofaidBinaryPath();
142
+ if (existing) return { path: existing };
143
+
144
+ const key = platformKey();
145
+ if (!key) {
146
+ return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` };
147
+ }
148
+
149
+ const result = await downloadFailproofaidBinary(key);
150
+ if (result.path) return { path: result.path };
151
+ return { reason: result.error ?? "failproofaid binary could not be downloaded" };
152
+ }
153
+
154
+ /**
155
+ * Resolves the command the daemon should use to spawn its warm worker,
156
+ * passed through as `FAILPROOFAI_WORKER_CMD` in the service's own
157
+ * environment.
158
+ *
159
+ * This is NOT optional the way it might look — `crates/failproofaid`'s
160
+ * built-in fallback (`node dist/worker.mjs`, used only when
161
+ * `FAILPROOFAI_WORKER_CMD` is unset) is a *relative* path, correct only
162
+ * when the daemon happens to be spawned with the npm package's own
163
+ * directory as its cwd. A service manager spawns processes from an
164
+ * arbitrary cwd (typically `/` or the user's home), so that fallback
165
+ * would silently fail to find the bundled worker in every real service
166
+ * install — caught by actually starting the installed service in a clean
167
+ * container rather than by reasoning about it. Resolving an absolute path
168
+ * here, once, at install time, and handing it to the daemon via the
169
+ * environment closes that gap entirely.
170
+ */
171
+ export function resolveWorkerCommand(): string | null {
172
+ if (process.env.FAILPROOFAI_WORKER_CMD) return process.env.FAILPROOFAI_WORKER_CMD;
173
+
174
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
175
+ if (!packageRoot) return null;
176
+ const workerScript = resolve(packageRoot, "dist", "worker.mjs");
177
+ if (!existsSync(workerScript)) return null;
178
+ // `process.execPath`, not a bare `node`. A system-scope service does not
179
+ // inherit a login environment, so its PATH is the system default — and the
180
+ // single most common way to install Node is nvm, which puts it under
181
+ // ~/.nvm/versions/node/*/bin and nowhere on that PATH. A bare `node` would
182
+ // resolve fine when the wizard runs it and then fail inside the service,
183
+ // silently, on exactly the machines least likely to notice. execPath is
184
+ // whatever runtime is executing this CLI right now (node for the published
185
+ // bin, bun in a source checkout), absolute either way.
186
+ return `${process.execPath} ${workerScript}`;
187
+ }
188
+
189
+ /**
190
+ * The account the daemon runs as. The service is root-*installed* but never
191
+ * root-*run*: everything it touches (the socket, the lock, the policy config)
192
+ * lives in one user's home and is peer-checked against that user's uid.
193
+ */
194
+ function serviceUser(): string {
195
+ return userInfo().username;
196
+ }
197
+
198
+ /**
199
+ * `/etc/systemd/system/failproofaid@<user>.service`.
200
+ *
201
+ * The `@<user>` suffix is systemd's convention for a per-user instance, but
202
+ * this is a concrete unit file rather than an instance of a template: every
203
+ * field that matters is user-specific (the ExecStart path is under the user's
204
+ * own ~/.failproofai/bin, so is HOME, so is the worker command), so a shared
205
+ * template would need a per-instance drop-in for all of them and buy nothing.
206
+ * Naming it per-user is what keeps a second user's install from silently
207
+ * stealing the first's unit — a single `failproofaid.service` would.
208
+ */
209
+ function systemdUnitName(user: string = serviceUser()): string {
210
+ return `failproofaid@${user}.service`;
211
+ }
212
+
213
+ function systemdUnitPath(user: string = serviceUser()): string {
214
+ return resolve("/etc/systemd/system", systemdUnitName(user));
215
+ }
216
+
217
+ /**
218
+ * The pre-1.0.0-beta.1 user-scope unit. Still removed on install and
219
+ * uninstall: it holds the same flock the new service needs, so leaving one
220
+ * behind means the system unit starts, loses the singleton race, and the
221
+ * machine sits fail-closed against a daemon that never came up.
222
+ */
223
+ function legacySystemdUserUnitPath(): string {
224
+ return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service");
225
+ }
226
+
227
+ const LAUNCHD_LABEL = "ai.failproof.failproofaid";
228
+
229
+ function launchdPlistPath(): string {
230
+ return `/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist`;
231
+ }
232
+
233
+ /** The pre-1.0.0-beta.1 LaunchAgent — same migration problem as above. */
234
+ function legacyLaunchAgentPlistPath(): string {
235
+ return resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
236
+ }
237
+
238
+ /**
239
+ * The service-definition file `installDaemonService` would write on this
240
+ * platform (the systemd unit or the launchd plist) — exposed so the config
241
+ * wizard's review screen can show it alongside every other file it's about
242
+ * to change, `null` on an unsupported platform.
243
+ */
244
+ export function daemonServiceFilePath(): string | null {
245
+ if (!isDaemonSupportedPlatform()) return null;
246
+ return process.platform === "linux" ? systemdUnitPath() : launchdPlistPath();
247
+ }
248
+
249
+ /**
250
+ * The command a user runs to inspect their own daemon — surfaced so the
251
+ * wizard can print it instead of leaving people to discover which service
252
+ * manager, and which scope, is involved.
253
+ */
254
+ export function daemonStatusCommand(): string | null {
255
+ if (!isDaemonSupportedPlatform()) return null;
256
+ return process.platform === "linux"
257
+ ? `systemctl status ${systemdUnitName()}`
258
+ : `sudo launchctl print system/${LAUNCHD_LABEL}`;
259
+ }
260
+
261
+ /** True when privileged commands can run without prompting for a password. */
262
+ function canElevate(): boolean {
263
+ if (typeof process.getuid === "function" && process.getuid() === 0) return true;
264
+ try {
265
+ execFileSync("sudo", ["-n", "true"], { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
266
+ return true;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Runs one privileged command, non-interactively.
274
+ *
275
+ * `sudo -n` on purpose: the wizard owns the terminal, and a sudo password
276
+ * prompt fired from underneath a TUI is unreadable at best. A machine that
277
+ * cannot elevate silently gets a clear reason and the manual commands
278
+ * instead — it keeps working exactly as it did, on the in-process path.
279
+ */
280
+ function runPrivileged(command: string, args: string[]): void {
281
+ const root = typeof process.getuid === "function" && process.getuid() === 0;
282
+ const [cmd, argv] = root ? [command, args] : ["sudo", ["-n", command, ...args]];
283
+ execFileSync(cmd, argv, { stdio: "ignore", timeout: SERVICE_CMD_TIMEOUT_MS });
284
+ }
285
+
286
+ /**
287
+ * Installs a file into a root-owned location via a temp file, because the
288
+ * caller is not root and cannot write there directly. `install -m` sets the
289
+ * mode in the same step, so the file is never briefly world-writable.
290
+ */
291
+ function writePrivilegedFile(destination: string, contents: string, mode = "0644"): void {
292
+ const staging = resolve(tmpdir(), `failproofaid-${process.pid}-${Date.now()}.tmp`);
293
+ try {
294
+ writeFileSync(staging, contents, "utf8");
295
+ runPrivileged("install", ["-m", mode, staging, destination]);
296
+ } finally {
297
+ rmSync(staging, { force: true });
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Removes a daemon installed by an earlier version into the user's own
303
+ * session scope. Best-effort and never privileged — these paths are all
304
+ * inside the user's home.
305
+ */
306
+ function removeLegacyUserService(): void {
307
+ try {
308
+ if (process.platform === "linux") {
309
+ const legacy = legacySystemdUserUnitPath();
310
+ if (!existsSync(legacy)) return;
311
+ try {
312
+ execFileSync("systemctl", ["--user", "disable", "--now", "failproofaid.service"], {
313
+ stdio: "ignore",
314
+ timeout: SERVICE_CMD_TIMEOUT_MS,
315
+ });
316
+ } catch {
317
+ // Not loaded — removing the file is still the point.
318
+ }
319
+ unlinkSync(legacy);
320
+ try {
321
+ execFileSync("systemctl", ["--user", "daemon-reload"], {
322
+ stdio: "ignore",
323
+ timeout: SERVICE_CMD_TIMEOUT_MS,
324
+ });
325
+ } catch {
326
+ /* best-effort */
327
+ }
328
+ } else {
329
+ const legacy = legacyLaunchAgentPlistPath();
330
+ if (!existsSync(legacy)) return;
331
+ try {
332
+ execFileSync("launchctl", ["unload", "-w", legacy], {
333
+ stdio: "ignore",
334
+ timeout: SERVICE_CMD_TIMEOUT_MS,
335
+ });
336
+ } catch {
337
+ /* not loaded */
338
+ }
339
+ unlinkSync(legacy);
340
+ }
341
+ } catch (err) {
342
+ hookLogWarn(`could not remove the legacy user-scope daemon: ${err instanceof Error ? err.message : String(err)}`);
343
+ }
344
+ }
345
+
346
+ export function systemdUnitContents(binaryPath: string, workerCmd: string | null): string {
347
+ // Quoted because both values contain a space or a path — systemd's
348
+ // Environment= requires quoting whenever the value does.
349
+ const envLine = workerCmd ? `Environment="FAILPROOFAI_WORKER_CMD=${workerCmd}"\n` : "";
350
+ const user = serviceUser();
351
+ return `[Unit]
352
+ Description=failproofai background daemon (failproofaid) for ${user}
353
+ After=network.target
354
+
355
+ [Service]
356
+ Type=simple
357
+ User=${user}
358
+ # Set explicitly rather than relying on systemd deriving it from User=:
359
+ # failproofaid is user-scope by construction and refuses to start without
360
+ # HOME ("HOME is not set; failproofaid is user-scope only"), so the one
361
+ # variable it cannot do without is not left to a version-dependent default.
362
+ Environment="HOME=${homedir()}"
363
+ ${envLine}ExecStart=${binaryPath}
364
+ Restart=on-failure
365
+ RestartSec=2
366
+
367
+ [Install]
368
+ # multi-user.target, not default.target: this is the whole point of a
369
+ # system unit — it starts at boot, with no login and no lingering, and
370
+ # keeps running after the installing user logs out.
371
+ WantedBy=multi-user.target
372
+ `;
373
+ }
374
+
375
+ function escapeXml(s: string): string {
376
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
377
+ }
378
+
379
+ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: string | null): string {
380
+ const envBlock = workerCmd
381
+ ? ` <key>EnvironmentVariables</key>
382
+ <dict>
383
+ <key>FAILPROOFAI_WORKER_CMD</key>
384
+ <string>${escapeXml(workerCmd)}</string>
385
+ </dict>
386
+ `
387
+ : "";
388
+ return `<?xml version="1.0" encoding="UTF-8"?>
389
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
390
+ <plist version="1.0">
391
+ <dict>
392
+ <key>Label</key>
393
+ <string>${LAUNCHD_LABEL}</string>
394
+ <key>ProgramArguments</key>
395
+ <array>
396
+ <string>${escapeXml(binaryPath)}</string>
397
+ </array>
398
+ <key>RunAtLoad</key>
399
+ <true/>
400
+ <key>KeepAlive</key>
401
+ <true/>
402
+ <!-- A LaunchDaemon runs as root unless told otherwise. This is the
403
+ launchd half of the systemd unit's User=: loaded at boot by the
404
+ system, executed as the one user whose home, socket and lock it
405
+ is allowed to touch. -->
406
+ <key>UserName</key>
407
+ <string>${escapeXml(serviceUser())}</string>
408
+ ${envBlock} <key>StandardOutPath</key>
409
+ <string>${escapeXml(resolve(logDir, "failproofaid.log"))}</string>
410
+ <key>StandardErrorPath</key>
411
+ <string>${escapeXml(resolve(logDir, "failproofaid.err.log"))}</string>
412
+ </dict>
413
+ </plist>
414
+ `;
415
+ }
416
+
417
+ export interface DaemonInstallResult {
418
+ installed: boolean;
419
+ reason?: string;
420
+ }
421
+
422
+ /**
423
+ * The privileged commands an install performs — returned verbatim so a
424
+ * machine that cannot elevate can be told exactly what to run rather than
425
+ * just that something failed.
426
+ */
427
+ function daemonInstallCommands(binaryPath: string, workerCmd: string | null): string[] {
428
+ if (process.platform === "linux") {
429
+ return [
430
+ `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd)}EOF`,
431
+ "sudo systemctl daemon-reload",
432
+ `sudo systemctl enable --now ${systemdUnitName()}`,
433
+ ];
434
+ }
435
+ return [
436
+ `sudo tee ${launchdPlistPath()} < the plist failproofai config would write`,
437
+ `sudo launchctl load -w ${launchdPlistPath()}`,
438
+ ];
439
+ }
440
+
441
+ /**
442
+ * Writes and enables the service unit, starting it immediately. Safe to
443
+ * call repeatedly — re-running replaces the unit file (picking up a
444
+ * changed binary path after an upgrade) and re-enables it.
445
+ */
446
+ export async function installDaemonService(): Promise<DaemonInstallResult> {
447
+ if (!isDaemonSupportedPlatform()) {
448
+ return { installed: false, reason: `failproofaid is not supported on ${process.platform} yet` };
449
+ }
450
+
451
+ // May reach the network: the npm package carries no binary, so this is
452
+ // where a machine opting into the daemon fetches the one built for its
453
+ // platform from this version's release.
454
+ const { path: binaryPath, reason: binaryReason } = await ensureFailproofaidBinary();
455
+ if (!binaryPath) {
456
+ return { installed: false, reason: binaryReason ?? "failproofaid binary not found for this platform" };
457
+ }
458
+ // Best-effort, not required: a null workerCmd just leaves the daemon to
459
+ // its own built-in (relative-path) fallback, which only works when the
460
+ // daemon happens to be started from the npm package's own directory.
461
+ // Resolving it here — where FAILPROOFAI_PACKAGE_ROOT is reliably set —
462
+ // and threading it through as an absolute path in the service's
463
+ // environment is what makes a *service-managed* daemon actually find its
464
+ // worker regardless of what cwd the service manager starts it from.
465
+ const workerCmd = resolveWorkerCommand();
466
+
467
+ // The service is installed system-wide, which needs root. Check before
468
+ // writing anything, so a machine that cannot elevate gets the exact
469
+ // commands to run instead of a half-installed service.
470
+ if (!canElevate()) {
471
+ return {
472
+ installed: false,
473
+ reason:
474
+ "root privileges are required to install the failproofaid system service, and sudo is not available without a password. " +
475
+ `Run: sudo failproofai config (or install manually: ${daemonInstallCommands(binaryPath, workerCmd).join(" && ")})`,
476
+ };
477
+ }
478
+
479
+ // A daemon left over from a pre-1.0.0-beta.1 install holds the same
480
+ // singleton lock the new one needs, so the system unit would start, lose
481
+ // the flock race, and leave the machine fail-closed against a daemon that
482
+ // never came up. Clear it first, every time.
483
+ removeLegacyUserService();
484
+
485
+ try {
486
+ if (process.platform === "linux") {
487
+ writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd));
488
+ runPrivileged("systemctl", ["daemon-reload"]);
489
+ runPrivileged("systemctl", ["enable", "--now", systemdUnitName()]);
490
+ } else {
491
+ const plistPath = launchdPlistPath();
492
+ const logDir = resolve(homedir(), ".failproofai", "logs");
493
+ mkdirSync(logDir, { recursive: true });
494
+ // Unload any previously-loaded copy first — reloading with a changed
495
+ // binary path (e.g. after an upgrade) is a no-op under plain `load`
496
+ // if launchd thinks the label is already loaded.
497
+ try {
498
+ runPrivileged("launchctl", ["unload", plistPath]);
499
+ } catch {
500
+ // Wasn't loaded — fine, this is the common case on a fresh install.
501
+ }
502
+ writePrivilegedFile(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd));
503
+ runPrivileged("launchctl", ["load", "-w", plistPath]);
504
+ }
505
+ } catch (err) {
506
+ const msg = err instanceof Error ? err.message : String(err);
507
+ hookLogWarn(`daemon service install failed: ${msg}`);
508
+ return { installed: false, reason: msg };
509
+ }
510
+
511
+ // `enable --now` / `load -w` returning 0 only means the job was accepted.
512
+ // A daemon that dies at startup (missing shared library, a crash-looping
513
+ // worker, a binary the service manager can't execute) still gets a clean
514
+ // exit status here — and the caller would then set `daemonConfigured`,
515
+ // which makes every hook event on this machine fail closed against a
516
+ // daemon that isn't there. Confirm it actually reached a running state
517
+ // before reporting success.
518
+ if (!(await waitForDaemonRunning())) {
519
+ const reason = `failproofaid was installed but did not reach a running state within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`;
520
+ hookLogWarn(`daemon service install failed: ${reason}`);
521
+ return { installed: false, reason };
522
+ }
523
+ return { installed: true };
524
+ }
525
+
526
+ /**
527
+ * Waits for the service to report running, then re-checks after a settle
528
+ * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
529
+ * pass on the strength of one optimistic reading.
530
+ */
531
+ async function waitForDaemonRunning(): Promise<boolean> {
532
+ const deadline = Date.now() + SERVICE_START_TIMEOUT_MS;
533
+ for (;;) {
534
+ if (daemonServiceStatus() === "running") break;
535
+ if (Date.now() >= deadline) return false;
536
+ await new Promise((r) => setTimeout(r, SERVICE_START_POLL_MS));
537
+ }
538
+ await new Promise((r) => setTimeout(r, SERVICE_SETTLE_MS));
539
+ return daemonServiceStatus() === "running";
540
+ }
541
+
542
+ /**
543
+ * Stops and removes the service, and clears the `daemonConfigured` marker
544
+ * so this machine goes back to in-process evaluation. Best-effort: never
545
+ * throws.
546
+ *
547
+ * The flag is cleared **first and unconditionally**: leaving it set with no
548
+ * daemon to reach is strictly worse than any failure this function can hit,
549
+ * because `bin/failproofai.mjs` fails closed on it and would deny every hook
550
+ * event on the machine with no recovery short of hand-editing
551
+ * `~/.failproofai/policies-config.json`.
552
+ */
553
+ export async function uninstallDaemonService(): Promise<void> {
554
+ setDaemonConfigured(false);
555
+ if (!isDaemonSupportedPlatform()) return;
556
+
557
+ // Always attempted, and never privileged: a legacy user-scope daemon is
558
+ // the one thing this can still clean up on a machine that cannot elevate.
559
+ removeLegacyUserService();
560
+
561
+ try {
562
+ if (process.platform === "linux") {
563
+ const unitPath = systemdUnitPath();
564
+ if (!existsSync(unitPath)) return;
565
+ try {
566
+ runPrivileged("systemctl", ["disable", "--now", systemdUnitName()]);
567
+ } catch {
568
+ // Already stopped/not enabled — removing the unit is still the point.
569
+ }
570
+ runPrivileged("rm", ["-f", unitPath]);
571
+ try {
572
+ runPrivileged("systemctl", ["daemon-reload"]);
573
+ } catch {
574
+ // Best-effort.
575
+ }
576
+ } else {
577
+ const plistPath = launchdPlistPath();
578
+ if (!existsSync(plistPath)) return;
579
+ try {
580
+ runPrivileged("launchctl", ["unload", "-w", plistPath]);
581
+ } catch {
582
+ // Already unloaded — fine.
583
+ }
584
+ runPrivileged("rm", ["-f", plistPath]);
585
+ }
586
+ } catch (err) {
587
+ // `daemonConfigured` is already cleared above, so a machine that cannot
588
+ // elevate is back on the in-process path even though the unit file
589
+ // survives — it fails open, not closed.
590
+ hookLogWarn(
591
+ `daemon service uninstall failed (the service may need removing by hand: ${daemonStatusCommand()}): ` +
592
+ `${err instanceof Error ? err.message : String(err)}`,
593
+ );
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Reports the service's actual current state, not just whether the unit
599
+ * file exists — a unit can be installed but crash-looped into a stopped
600
+ * state.
601
+ */
602
+ export function daemonServiceStatus(): DaemonServiceStatus {
603
+ if (!isDaemonSupportedPlatform()) return "unsupported-platform";
604
+
605
+ if (process.platform === "linux") {
606
+ if (!existsSync(systemdUnitPath())) return "not-installed";
607
+ try {
608
+ // Unprivileged on purpose: reading a system unit's state needs no
609
+ // root, so status works for the owning user with no sudo at all.
610
+ const out = execFileSync("systemctl", ["is-active", systemdUnitName()], {
611
+ stdio: ["ignore", "pipe", "ignore"],
612
+ timeout: SERVICE_CMD_TIMEOUT_MS,
613
+ })
614
+ .toString()
615
+ .trim();
616
+ return out === "active" ? "running" : "stopped";
617
+ } catch {
618
+ // `systemctl is-active` exits non-zero (and execFileSync throws) for
619
+ // every non-"active" state — inactive, failed, or the command not
620
+ // working at all (no systemd user session, systemctl missing). All
621
+ // of those are indistinguishable from "stopped" from here, and the
622
+ // unit file existing is what already ruled out "not-installed".
623
+ return "stopped";
624
+ }
625
+ }
626
+
627
+ const plistPath = launchdPlistPath();
628
+ if (!existsSync(plistPath)) return "not-installed";
629
+ try {
630
+ // A LaunchDaemon lives in launchd's system domain, which an unprivileged
631
+ // `launchctl list` cannot see — unlike systemd, reading the state needs
632
+ // the same elevation installing it did.
633
+ const root = typeof process.getuid === "function" && process.getuid() === 0;
634
+ const args = ["print", `system/${LAUNCHD_LABEL}`];
635
+ const out = root
636
+ ? execFileSync("launchctl", args, {
637
+ stdio: ["ignore", "pipe", "ignore"],
638
+ timeout: SERVICE_CMD_TIMEOUT_MS,
639
+ }).toString()
640
+ : execFileSync("sudo", ["-n", "launchctl", ...args], {
641
+ stdio: ["ignore", "pipe", "ignore"],
642
+ timeout: SERVICE_CMD_TIMEOUT_MS,
643
+ }).toString();
644
+ // `state = running` is launchd's own wording; a loaded-but-dead job
645
+ // prints `state = not running` and must not read as healthy.
646
+ return /state\s*=\s*running/.test(out) ? "running" : "stopped";
647
+ } catch {
648
+ return "stopped";
649
+ }
650
+ }