failproofai 0.0.15 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) 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]__06b4trb._.js → [root-of-the-server]__0259h38._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zj-87g._.js +3 -0
  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_1-upb14.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/{1_v8b8mxhk25s.js → 0ip1n8xmgxarv.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0u9hc6oxx-gpj.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 1d6ncspjagm53.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1grd7y94-igz9.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{3m8wmvlhsy1mx.js → 1s-_swlc80y43.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 29k6ef49zppxj.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 29ydr-8q0zis6.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2vr7aazw6k2-n.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 3wrgbih-516uj.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 +1202 -624
  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 +73 -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 +449 -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/server/chunks/ssr/[root-of-the-server]__0fopait._.js +0 -3
  134. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_buildManifest.js +0 -0
  135. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_clientMiddlewareManifest.js +0 -0
  136. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_ssgManifest.js +0 -0
@@ -0,0 +1,449 @@
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 } from "node:fs";
12
+ import { homedir } 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
+ 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
+ return `node ${workerScript}`;
179
+ }
180
+
181
+ function systemdUnitPath(): string {
182
+ return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service");
183
+ }
184
+
185
+ function launchdPlistPath(): string {
186
+ return resolve(homedir(), "Library", "LaunchAgents", "ai.failproof.failproofaid.plist");
187
+ }
188
+
189
+ /**
190
+ * The service-definition file `installDaemonService` would write on this
191
+ * platform (the systemd unit or the launchd plist) — exposed so the config
192
+ * wizard's review screen can show it alongside every other file it's about
193
+ * to change, `null` on an unsupported platform.
194
+ */
195
+ export function daemonServiceFilePath(): string | null {
196
+ if (!isDaemonSupportedPlatform()) return null;
197
+ return process.platform === "linux" ? systemdUnitPath() : launchdPlistPath();
198
+ }
199
+
200
+ function systemdUnitContents(binaryPath: string, workerCmd: string | null): string {
201
+ // Quoted because FAILPROOFAI_WORKER_CMD's value ("node /abs/path/worker.mjs")
202
+ // contains a space — systemd's Environment= requires quoting whenever the
203
+ // value does.
204
+ const envLine = workerCmd ? `Environment="FAILPROOFAI_WORKER_CMD=${workerCmd}"\n` : "";
205
+ return `[Unit]
206
+ Description=failproofai background daemon (failproofaid)
207
+ After=network.target
208
+
209
+ [Service]
210
+ Type=simple
211
+ ${envLine}ExecStart=${binaryPath}
212
+ Restart=on-failure
213
+ RestartSec=2
214
+
215
+ [Install]
216
+ WantedBy=default.target
217
+ `;
218
+ }
219
+
220
+ function escapeXml(s: string): string {
221
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
222
+ }
223
+
224
+ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: string | null): string {
225
+ const envBlock = workerCmd
226
+ ? ` <key>EnvironmentVariables</key>
227
+ <dict>
228
+ <key>FAILPROOFAI_WORKER_CMD</key>
229
+ <string>${escapeXml(workerCmd)}</string>
230
+ </dict>
231
+ `
232
+ : "";
233
+ return `<?xml version="1.0" encoding="UTF-8"?>
234
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
235
+ <plist version="1.0">
236
+ <dict>
237
+ <key>Label</key>
238
+ <string>ai.failproof.failproofaid</string>
239
+ <key>ProgramArguments</key>
240
+ <array>
241
+ <string>${escapeXml(binaryPath)}</string>
242
+ </array>
243
+ <key>RunAtLoad</key>
244
+ <true/>
245
+ <key>KeepAlive</key>
246
+ <true/>
247
+ ${envBlock} <key>StandardOutPath</key>
248
+ <string>${escapeXml(resolve(logDir, "failproofaid.log"))}</string>
249
+ <key>StandardErrorPath</key>
250
+ <string>${escapeXml(resolve(logDir, "failproofaid.err.log"))}</string>
251
+ </dict>
252
+ </plist>
253
+ `;
254
+ }
255
+
256
+ export interface DaemonInstallResult {
257
+ installed: boolean;
258
+ reason?: string;
259
+ }
260
+
261
+ /**
262
+ * Writes and enables the service unit, starting it immediately. Safe to
263
+ * call repeatedly — re-running replaces the unit file (picking up a
264
+ * changed binary path after an upgrade) and re-enables it.
265
+ */
266
+ export async function installDaemonService(): Promise<DaemonInstallResult> {
267
+ if (!isDaemonSupportedPlatform()) {
268
+ return { installed: false, reason: `failproofaid is not supported on ${process.platform} yet` };
269
+ }
270
+
271
+ // May reach the network: the npm package carries no binary, so this is
272
+ // where a machine opting into the daemon fetches the one built for its
273
+ // platform from this version's release.
274
+ const { path: binaryPath, reason: binaryReason } = await ensureFailproofaidBinary();
275
+ if (!binaryPath) {
276
+ return { installed: false, reason: binaryReason ?? "failproofaid binary not found for this platform" };
277
+ }
278
+ // Best-effort, not required: a null workerCmd just leaves the daemon to
279
+ // its own built-in (relative-path) fallback, which only works when the
280
+ // daemon happens to be started from the npm package's own directory.
281
+ // Resolving it here — where FAILPROOFAI_PACKAGE_ROOT is reliably set —
282
+ // and threading it through as an absolute path in the service's
283
+ // environment is what makes a *service-managed* daemon actually find its
284
+ // worker regardless of what cwd the service manager starts it from.
285
+ const workerCmd = resolveWorkerCommand();
286
+
287
+ try {
288
+ if (process.platform === "linux") {
289
+ const unitPath = systemdUnitPath();
290
+ mkdirSync(dirname(unitPath), { recursive: true });
291
+ writeFileSync(unitPath, systemdUnitContents(binaryPath, workerCmd), "utf8");
292
+ execFileSync("systemctl", ["--user", "daemon-reload"], {
293
+ stdio: "ignore",
294
+ timeout: SERVICE_CMD_TIMEOUT_MS,
295
+ });
296
+ execFileSync("systemctl", ["--user", "enable", "--now", "failproofaid.service"], {
297
+ stdio: "ignore",
298
+ timeout: SERVICE_CMD_TIMEOUT_MS,
299
+ });
300
+ } else {
301
+ const plistPath = launchdPlistPath();
302
+ const logDir = resolve(homedir(), ".failproofai", "logs");
303
+ mkdirSync(dirname(plistPath), { recursive: true });
304
+ mkdirSync(logDir, { recursive: true });
305
+ writeFileSync(plistPath, launchdPlistContents(binaryPath, logDir, workerCmd), "utf8");
306
+ // Unload any previously-loaded copy first — reloading with a changed
307
+ // binary path (e.g. after an upgrade) is a no-op under plain `load`
308
+ // if launchd thinks the label is already loaded.
309
+ try {
310
+ execFileSync("launchctl", ["unload", plistPath], {
311
+ stdio: "ignore",
312
+ timeout: SERVICE_CMD_TIMEOUT_MS,
313
+ });
314
+ } catch {
315
+ // Wasn't loaded — fine, this is the common case on a fresh install.
316
+ }
317
+ execFileSync("launchctl", ["load", "-w", plistPath], {
318
+ stdio: "ignore",
319
+ timeout: SERVICE_CMD_TIMEOUT_MS,
320
+ });
321
+ }
322
+ } catch (err) {
323
+ const msg = err instanceof Error ? err.message : String(err);
324
+ hookLogWarn(`daemon service install failed: ${msg}`);
325
+ return { installed: false, reason: msg };
326
+ }
327
+
328
+ // `enable --now` / `load -w` returning 0 only means the job was accepted.
329
+ // A daemon that dies at startup (missing shared library, a crash-looping
330
+ // worker, a binary the service manager can't execute) still gets a clean
331
+ // exit status here — and the caller would then set `daemonConfigured`,
332
+ // which makes every hook event on this machine fail closed against a
333
+ // daemon that isn't there. Confirm it actually reached a running state
334
+ // before reporting success.
335
+ if (!(await waitForDaemonRunning())) {
336
+ const reason = `failproofaid was installed but did not reach a running state within ${SERVICE_START_TIMEOUT_MS}ms (status: ${daemonServiceStatus()})`;
337
+ hookLogWarn(`daemon service install failed: ${reason}`);
338
+ return { installed: false, reason };
339
+ }
340
+ return { installed: true };
341
+ }
342
+
343
+ /**
344
+ * Waits for the service to report running, then re-checks after a settle
345
+ * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
346
+ * pass on the strength of one optimistic reading.
347
+ */
348
+ async function waitForDaemonRunning(): Promise<boolean> {
349
+ const deadline = Date.now() + SERVICE_START_TIMEOUT_MS;
350
+ for (;;) {
351
+ if (daemonServiceStatus() === "running") break;
352
+ if (Date.now() >= deadline) return false;
353
+ await new Promise((r) => setTimeout(r, SERVICE_START_POLL_MS));
354
+ }
355
+ await new Promise((r) => setTimeout(r, SERVICE_SETTLE_MS));
356
+ return daemonServiceStatus() === "running";
357
+ }
358
+
359
+ /**
360
+ * Stops and removes the service, and clears the `daemonConfigured` marker
361
+ * so this machine goes back to in-process evaluation. Best-effort: never
362
+ * throws.
363
+ *
364
+ * The flag is cleared **first and unconditionally**: leaving it set with no
365
+ * daemon to reach is strictly worse than any failure this function can hit,
366
+ * because `bin/failproofai.mjs` fails closed on it and would deny every hook
367
+ * event on the machine with no recovery short of hand-editing
368
+ * `~/.failproofai/policies-config.json`.
369
+ */
370
+ export async function uninstallDaemonService(): Promise<void> {
371
+ setDaemonConfigured(false);
372
+ if (!isDaemonSupportedPlatform()) return;
373
+ try {
374
+ if (process.platform === "linux") {
375
+ try {
376
+ execFileSync("systemctl", ["--user", "disable", "--now", "failproofaid.service"], {
377
+ stdio: "ignore",
378
+ timeout: SERVICE_CMD_TIMEOUT_MS,
379
+ });
380
+ } catch {
381
+ // Already stopped/not installed — fine.
382
+ }
383
+ const unitPath = systemdUnitPath();
384
+ if (existsSync(unitPath)) unlinkSync(unitPath);
385
+ try {
386
+ execFileSync("systemctl", ["--user", "daemon-reload"], {
387
+ stdio: "ignore",
388
+ timeout: SERVICE_CMD_TIMEOUT_MS,
389
+ });
390
+ } catch {
391
+ // Best-effort.
392
+ }
393
+ } else {
394
+ const plistPath = launchdPlistPath();
395
+ try {
396
+ execFileSync("launchctl", ["unload", "-w", plistPath], {
397
+ stdio: "ignore",
398
+ timeout: SERVICE_CMD_TIMEOUT_MS,
399
+ });
400
+ } catch {
401
+ // Already unloaded/not installed — fine.
402
+ }
403
+ if (existsSync(plistPath)) unlinkSync(plistPath);
404
+ }
405
+ } catch (err) {
406
+ hookLogWarn(`daemon service uninstall failed: ${err instanceof Error ? err.message : String(err)}`);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Reports the service's actual current state, not just whether the unit
412
+ * file exists — a unit can be installed but crash-looped into a stopped
413
+ * state.
414
+ */
415
+ export function daemonServiceStatus(): DaemonServiceStatus {
416
+ if (!isDaemonSupportedPlatform()) return "unsupported-platform";
417
+
418
+ if (process.platform === "linux") {
419
+ if (!existsSync(systemdUnitPath())) return "not-installed";
420
+ try {
421
+ const out = execFileSync("systemctl", ["--user", "is-active", "failproofaid.service"], {
422
+ stdio: ["ignore", "pipe", "ignore"],
423
+ timeout: SERVICE_CMD_TIMEOUT_MS,
424
+ })
425
+ .toString()
426
+ .trim();
427
+ return out === "active" ? "running" : "stopped";
428
+ } catch {
429
+ // `systemctl is-active` exits non-zero (and execFileSync throws) for
430
+ // every non-"active" state — inactive, failed, or the command not
431
+ // working at all (no systemd user session, systemctl missing). All
432
+ // of those are indistinguishable from "stopped" from here, and the
433
+ // unit file existing is what already ruled out "not-installed".
434
+ return "stopped";
435
+ }
436
+ }
437
+
438
+ const plistPath = launchdPlistPath();
439
+ if (!existsSync(plistPath)) return "not-installed";
440
+ try {
441
+ const out = execFileSync("launchctl", ["list"], {
442
+ stdio: ["ignore", "pipe", "ignore"],
443
+ timeout: SERVICE_CMD_TIMEOUT_MS,
444
+ }).toString();
445
+ return out.includes("ai.failproof.failproofaid") ? "running" : "stopped";
446
+ } catch {
447
+ return "stopped";
448
+ }
449
+ }