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.
- 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]__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]__0fopait._.js → [root-of-the-server]__1ig795d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__1yo1gyz._.js} +2 -2
- 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_14q3-7b.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/{3m8wmvlhsy1mx.js → 07ix3lb4gib0x.js} +1 -1
- package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0fdx7ikab08-g.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 191njgoik03fm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1b3a1scouf40o.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 1d71o1_tav721.js} +1 -1
- package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 24gs8ip6_spcb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2ovmv4esqalpb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 30is-6r00sbd7.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 35584kn409ad1.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 +1385 -707
- 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 +77 -1
- package/src/hooks/daemon-client.ts +199 -0
- package/src/hooks/daemon-download.ts +152 -0
- package/src/hooks/daemon-service.ts +650 -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/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_clientMiddlewareManifest.js +0 -0
- /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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|