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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +9 -9
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__0259h38._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zj-87g._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0hs_5mh.js → node_modules_html-to-image_es_index_1-upb14.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +11 -11
- package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 0ip1n8xmgxarv.js} +1 -1
- package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0u9hc6oxx-gpj.js} +1 -1
- package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 1d6ncspjagm53.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1grd7y94-igz9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3m8wmvlhsy1mx.js → 1s-_swlc80y43.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 29k6ef49zppxj.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 29ydr-8q0zis6.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2vr7aazw6k2-n.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 3wrgbih-516uj.js} +1 -1
- package/.next/standalone/Cargo.lock +428 -0
- package/.next/standalone/Cargo.toml +9 -0
- package/.next/standalone/crates/PROTOCOL.md +122 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +18 -0
- package/.next/standalone/crates/failproofaid/src/lock.rs +81 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +83 -0
- package/.next/standalone/crates/failproofaid/src/paths.rs +172 -0
- package/.next/standalone/crates/failproofaid/src/server.rs +502 -0
- package/.next/standalone/crates/failproofaid/src/worker.rs +400 -0
- package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +241 -0
- package/.next/standalone/crates/fpai-ipc/Cargo.toml +16 -0
- package/.next/standalone/crates/fpai-ipc/src/envelope.rs +175 -0
- package/.next/standalone/crates/fpai-ipc/src/framing.rs +177 -0
- package/.next/standalone/crates/fpai-ipc/src/lib.rs +10 -0
- package/.next/standalone/crates/fpai-ipc/src/peer.rs +80 -0
- package/.next/standalone/package.json +5 -3
- package/.next/standalone/rust-toolchain.toml +4 -0
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai-worker.mjs +51 -0
- package/bin/failproofai.mjs +68 -1
- package/bin/failproofaid-shim.mjs +67 -0
- package/dist/cli.mjs +1202 -624
- package/dist/worker.mjs +8441 -0
- package/package.json +5 -3
- package/src/hooks/builtin-policies.ts +37 -13
- package/src/hooks/configure-wizard.ts +73 -1
- package/src/hooks/daemon-client.ts +199 -0
- package/src/hooks/daemon-download.ts +152 -0
- package/src/hooks/daemon-service.ts +449 -0
- package/src/hooks/handler.ts +231 -195
- package/src/hooks/normalize-cli-payload.ts +61 -0
- package/src/hooks/policy-types.ts +15 -0
- package/src/hooks/read-stdin.ts +48 -0
- package/src/hooks/worker-server.ts +154 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1is3glr._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0fopait._.js +0 -3
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_clientMiddlewareManifest.js +0 -0
- /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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|