failproofai 1.0.0-beta.7 → 1.0.0-beta.9
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.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/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 +10 -10
- 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/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +3 -3
- 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]__0c_qcgp._.js → [root-of-the-server]__0abann8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0f9901b._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tyf0tc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_04mekdy._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0r-dsre._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0se484m._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0uz7o9y._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1fahg9e._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_194yh9u._.js → _1t3vvw3._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1w8sola._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1w_6p9g._.js +1 -1
- 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/src_hooks_builtin-policies_ts_09j2ndl._.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 +15 -15
- package/.next/standalone/.next/static/chunks/{2p2kz4aj1tooj.js → 0jibhdbjojuq5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{36pc3_8fpb6ic.js → 0qubxfmqjdk8-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0m-m7wgtxdoav.js → 159mxiauw9a8_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{11l6oh9cyb35a.js → 1xxzquhtvg72x.js} +1 -1
- package/.next/standalone/.next/static/chunks/270jpx60b2kuj.js +1 -0
- package/.next/standalone/.next/static/chunks/{2aej0vi1fm87m.js → 2f295j8d9ta-h.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2bxld6-rym3ah.js → 2g7p545cy5458.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0gr76ps8_zm26.js → 2nsm1u-n43hfa.js} +1 -1
- package/.next/standalone/.next/static/chunks/3940dugfmx1ai.js +6 -0
- package/.next/standalone/.next/static/chunks/{3t1wr_kx6brwc.js → 3dpc4c0l_hf32.js} +1 -1
- package/.next/standalone/package.json +5 -5
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +118 -1
- package/dist/cli.mjs +1199 -98
- package/dist/worker.mjs +77 -6
- package/package.json +5 -5
- package/scripts/repro-npm-install.sh +155 -0
- package/src/hooks/builtin-policies.ts +154 -2
- package/src/hooks/collector-config.ts +18 -2
- package/src/hooks/configure-wizard.ts +65 -34
- package/src/hooks/daemon-client.ts +35 -0
- package/src/hooks/daemon-service.ts +227 -22
- package/src/hooks/first-run-gate.ts +9 -1
- package/src/hooks/fp-home.ts +23 -3
- package/src/hooks/fp-reset.ts +137 -5
- package/src/hooks/uninstall-cli.ts +345 -0
- package/src/hooks/worker-server.ts +12 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kbc9bs._.js +0 -3
- package/.next/standalone/.next/static/chunks/0945ybz3fsvba.js +0 -1
- package/.next/standalone/.next/static/chunks/166f8qnh03jqc.js +0 -6
- /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → biwmgAwO6vVswC0z-soDU}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → biwmgAwO6vVswC0z-soDU}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → biwmgAwO6vVswC0z-soDU}/_ssgManifest.js +0 -0
|
@@ -107,6 +107,41 @@ export function daemonSocketPresent(): boolean {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Whether the daemon socket accepts a connection *right now*.
|
|
112
|
+
*
|
|
113
|
+
* Narrower than `daemonSocketPresent()` (a stale socket file outlives the
|
|
114
|
+
* process that made it) and narrower than `attemptDaemonHook`, which reports
|
|
115
|
+
* `"unreachable"` for BOTH a refused connection and a request that was accepted
|
|
116
|
+
* and then never answered. Those two have different causes and different
|
|
117
|
+
* remedies — a socket that is not up yet versus a worker that cannot run — and
|
|
118
|
+
* telling them apart is the whole point of this function.
|
|
119
|
+
*
|
|
120
|
+
* Connect budget only; nothing is sent. Used by the health probe, never on the
|
|
121
|
+
* hook path.
|
|
122
|
+
*/
|
|
123
|
+
export async function daemonAcceptsConnections(
|
|
124
|
+
timeoutMs = DAEMON_CONNECT_TIMEOUT_MS,
|
|
125
|
+
): Promise<boolean> {
|
|
126
|
+
if (process.platform === "win32") return false;
|
|
127
|
+
return new Promise<boolean>((resolve) => {
|
|
128
|
+
let settled = false;
|
|
129
|
+
const done = (v: boolean) => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
settled = true;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
socket.removeAllListeners();
|
|
134
|
+
socket.destroy();
|
|
135
|
+
resolve(v);
|
|
136
|
+
};
|
|
137
|
+
const socket = createConnection({ path: socketPath() });
|
|
138
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
socket.on("connect", () => done(true));
|
|
141
|
+
socket.on("error", () => done(false));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
110
145
|
function socketPath(): string {
|
|
111
146
|
if (process.env.FAILPROOFAI_DAEMON_SOCKET) return process.env.FAILPROOFAI_DAEMON_SOCKET;
|
|
112
147
|
return daemonSocketPath();
|
|
@@ -94,7 +94,25 @@ export function setDaemonConfigured(value: boolean, installedVersion?: string):
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export type DaemonServiceStatus =
|
|
97
|
+
export type DaemonServiceStatus =
|
|
98
|
+
| "running"
|
|
99
|
+
| "stopped"
|
|
100
|
+
/**
|
|
101
|
+
* Installed, but systemd evaluated its `ConditionPathExists=` and refused to
|
|
102
|
+
* start it — the daemon binary or the worker script is gone.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately its own state rather than folded into "stopped". "Stopped" is
|
|
105
|
+
* ambiguous on purpose (a restart in flight looks identical), which is why
|
|
106
|
+
* nothing is allowed to act destructively on it. A failed condition carries
|
|
107
|
+
* no such ambiguity: systemd has already decided this unit will not run, and
|
|
108
|
+
* will keep deciding that at every boot until the missing path returns. That
|
|
109
|
+
* is provable enough to clear `daemonConfigured` on, and leaving it
|
|
110
|
+
* indistinguishable from a transient stop is what kept a machine denying
|
|
111
|
+
* every tool call with no explanation.
|
|
112
|
+
*/
|
|
113
|
+
| "condition-failed"
|
|
114
|
+
| "not-installed"
|
|
115
|
+
| "unsupported-platform";
|
|
98
116
|
|
|
99
117
|
/** Linux + macOS only, per the plan's platform scope — full stop. */
|
|
100
118
|
export function isDaemonSupportedPlatform(): boolean {
|
|
@@ -261,6 +279,28 @@ function shellQuote(value: string): string {
|
|
|
261
279
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
262
280
|
}
|
|
263
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The raw, unquoted path to the worker script the service will run — the same
|
|
284
|
+
* file `resolveWorkerCommand` builds its shell command around.
|
|
285
|
+
*
|
|
286
|
+
* It exists separately because the unit needs the path as a PATH (for
|
|
287
|
+
* `ConditionPathExists=`), not as a shell word, and recovering one from the
|
|
288
|
+
* other means unparsing POSIX quoting for a value we already had.
|
|
289
|
+
*
|
|
290
|
+
* Returns null when `FAILPROOFAI_WORKER_CMD` is set: that value is an arbitrary
|
|
291
|
+
* shell command — a wrapper script, an interpreter with flags, `exec`ing
|
|
292
|
+
* something else entirely — and guessing which token in it is "the file that
|
|
293
|
+
* must exist" would gate the service on a path nobody promised. An absent
|
|
294
|
+
* condition is the correct answer to a question we cannot answer.
|
|
295
|
+
*/
|
|
296
|
+
export function workerScriptPath(): string | null {
|
|
297
|
+
if (process.env.FAILPROOFAI_WORKER_CMD) return null;
|
|
298
|
+
const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
299
|
+
if (!packageRoot) return null;
|
|
300
|
+
const workerScript = resolve(packageRoot, "dist", "worker.mjs");
|
|
301
|
+
return existsSync(workerScript) ? workerScript : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
264
304
|
/**
|
|
265
305
|
* The environment the service definition carries, built once so the systemd
|
|
266
306
|
* and launchd renderers cannot drift apart — a variable added to one and not
|
|
@@ -576,10 +616,58 @@ export function systemdUnitContents(
|
|
|
576
616
|
const user = assertUnitSafe(serviceUser(), "User");
|
|
577
617
|
assertUnitSafe(binaryPath, "ExecStart");
|
|
578
618
|
assertUnitSafe(homedir(), "HOME");
|
|
619
|
+
|
|
620
|
+
// Gate the unit on the two files it cannot run without, so that an install
|
|
621
|
+
// which is no longer there STOPS rather than thrashes.
|
|
622
|
+
//
|
|
623
|
+
// `npm rm -g failproofai` is the case this is for, and npm runs no uninstall
|
|
624
|
+
// script — see the note on `failproofai uninstall`. It deletes the package,
|
|
625
|
+
// which takes `dist/worker.mjs` with it, while the daemon binary under
|
|
626
|
+
// ~/.failproofai survives. Without a condition systemd keeps a daemon alive
|
|
627
|
+
// whose worker cannot spawn; with a deleted BINARY it is worse, because
|
|
628
|
+
// ExecStart fails 203/EXEC under `Restart=on-failure` and cycles until it
|
|
629
|
+
// trips the start-limit and latches into "start request repeated too
|
|
630
|
+
// quickly" — a state that then refuses a legitimate restart later.
|
|
631
|
+
//
|
|
632
|
+
// A failed condition is not a failure: systemd SKIPS the job, leaves the unit
|
|
633
|
+
// inactive, and `systemctl status` names the exact path that was missing.
|
|
634
|
+
// That turns an unexplained crash-loop into a one-line diagnosis, and
|
|
635
|
+
// `daemonServiceStatus()` reads it back as `condition-failed` so the next CLI
|
|
636
|
+
// command can clear `daemonConfigured` instead of leaving the machine denying
|
|
637
|
+
// every tool call.
|
|
638
|
+
//
|
|
639
|
+
// This deliberately does NOT soften the hook path's fail-closed deny. A
|
|
640
|
+
// machine that was configured to require the daemon still denies while the
|
|
641
|
+
// daemon is absent — being skipped by systemd is not consent to stop
|
|
642
|
+
// enforcing. It shortens how long that lasts and explains why.
|
|
643
|
+
// Only paths that EXIST right now are gated on, and that filter is load-
|
|
644
|
+
// bearing rather than belt-and-braces.
|
|
645
|
+
//
|
|
646
|
+
// `binaryPath` is an ExecStart value, not necessarily a bare path: systemd
|
|
647
|
+
// accepts arguments there, and `FAILPROOFAI_DAEMON_BINARY` is documented as
|
|
648
|
+
// "someone named a binary explicitly" — this repo's own systemd tests set it
|
|
649
|
+
// to `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so
|
|
650
|
+
// gating on that string looks for a file literally named "sleep infinity",
|
|
651
|
+
// never finds it, and skips a unit that would have run perfectly. Splitting
|
|
652
|
+
// on whitespace to recover the binary is worse, because a path may legally
|
|
653
|
+
// contain spaces and there is no way to tell the two apart from here.
|
|
654
|
+
//
|
|
655
|
+
// Existence at render time answers it without guessing: a real binary is on
|
|
656
|
+
// disk when its unit is written (installDaemonService just put it there), and
|
|
657
|
+
// a command-with-arguments is not. Same rule the worker script already
|
|
658
|
+
// follows — never gate on a path that is not there, or the freshly installed
|
|
659
|
+
// unit skips on its very first start.
|
|
660
|
+
const conditionPaths = [binaryPath, workerScriptPath()].filter(
|
|
661
|
+
(p): p is string => typeof p === "string" && existsSync(p),
|
|
662
|
+
);
|
|
663
|
+
const conditionLines = conditionPaths
|
|
664
|
+
.map((p) => `ConditionPathExists=${assertUnitSafe(p, "ConditionPathExists")}\n`)
|
|
665
|
+
.join("");
|
|
666
|
+
|
|
579
667
|
return `[Unit]
|
|
580
668
|
Description=failproofai background daemon (failproofaid) for ${user}
|
|
581
669
|
After=network.target
|
|
582
|
-
|
|
670
|
+
${conditionLines}
|
|
583
671
|
[Service]
|
|
584
672
|
Type=simple
|
|
585
673
|
User=${user}
|
|
@@ -798,6 +886,15 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
|
|
|
798
886
|
*/
|
|
799
887
|
const DAEMON_PROBE_TIMEOUT_MS = 5_000;
|
|
800
888
|
|
|
889
|
+
/**
|
|
890
|
+
* How long the probe keeps waiting for the socket to come up before calling the
|
|
891
|
+
* daemon unreachable. Generous on purpose: this runs once, from an interactive
|
|
892
|
+
* setup command, and the cost of being impatient is aborting setup at a machine
|
|
893
|
+
* whose daemon is merely still starting.
|
|
894
|
+
*/
|
|
895
|
+
const DAEMON_PROBE_READY_TIMEOUT_MS = 10_000;
|
|
896
|
+
const DAEMON_PROBE_RETRY_MS = 150;
|
|
897
|
+
|
|
801
898
|
/**
|
|
802
899
|
* Ask the daemon to evaluate a real hook, end to end.
|
|
803
900
|
*
|
|
@@ -818,26 +915,81 @@ const DAEMON_PROBE_TIMEOUT_MS = 5_000;
|
|
|
818
915
|
* policy would deny and records no tool decision, so probing cannot itself
|
|
819
916
|
* change what the machine does.
|
|
820
917
|
*/
|
|
821
|
-
export
|
|
918
|
+
export type DaemonProbe =
|
|
919
|
+
| { ok: true }
|
|
920
|
+
| {
|
|
921
|
+
ok: false;
|
|
922
|
+
/**
|
|
923
|
+
* `unreachable` — nothing ever accepted a connection on the socket.
|
|
924
|
+
* `worker` — the daemon accepted a connection but could not answer a
|
|
925
|
+
* hook, which is the worker failing to run.
|
|
926
|
+
*
|
|
927
|
+
* Kept apart because the remedies are different, and because reporting
|
|
928
|
+
* "your worker will not start" at someone whose worker is fine sends them
|
|
929
|
+
* to inspect a healthy process. `DaemonFailure` cannot make this
|
|
930
|
+
* distinction: it reports `unreachable` for a refused connection AND for
|
|
931
|
+
* a request that was accepted and never answered.
|
|
932
|
+
*/
|
|
933
|
+
reason: "unreachable" | "worker";
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Ask the daemon to evaluate a real hook, end to end.
|
|
938
|
+
*
|
|
939
|
+
* The thing this catches that nothing else did: a unit that is *running* while
|
|
940
|
+
* the worker behind it cannot start. `ExecStart` bakes in `process.execPath`
|
|
941
|
+
* and an absolute `dist/worker.mjs`, so an `nvm uninstall 20` leaves a service
|
|
942
|
+
* systemd reports as perfectly active and a worker that dies on every spawn.
|
|
943
|
+
*
|
|
944
|
+
* **Why this retries.** It is called moments after `systemctl enable --now`, and
|
|
945
|
+
* a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before
|
|
946
|
+
* the daemon has bound its socket. A single attempt therefore raced the bind
|
|
947
|
+
* and, because the hook path's connect budget is deliberately 150ms, lost that
|
|
948
|
+
* race on any loaded machine. Setup then aborted with "its worker process could
|
|
949
|
+
* not be run" at a daemon that was seconds away from serving happily — the
|
|
950
|
+
* worker had already logged that it was listening. Waiting for the socket is
|
|
951
|
+
* the fix; relaxing the 150ms is NOT, because that budget is what keeps a dead
|
|
952
|
+
* daemon from adding latency to every tool call on the hook path.
|
|
953
|
+
*/
|
|
954
|
+
export async function probeDaemon(): Promise<DaemonProbe> {
|
|
822
955
|
try {
|
|
823
|
-
const { attemptDaemonHook } = await import("./daemon-client");
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
956
|
+
const { attemptDaemonHook, daemonAcceptsConnections } = await import("./daemon-client");
|
|
957
|
+
const deadline = Date.now() + DAEMON_PROBE_READY_TIMEOUT_MS;
|
|
958
|
+
let everConnected = false;
|
|
959
|
+
|
|
960
|
+
for (;;) {
|
|
961
|
+
if (await daemonAcceptsConnections()) {
|
|
962
|
+
everConnected = true;
|
|
963
|
+
const attempt = await attemptDaemonHook(
|
|
964
|
+
{
|
|
965
|
+
hookEvent: "SessionStart",
|
|
966
|
+
cli: "claude",
|
|
967
|
+
stdin: JSON.stringify({
|
|
968
|
+
hook_event_name: "SessionStart",
|
|
969
|
+
source: "failproofai-health-probe",
|
|
970
|
+
}),
|
|
971
|
+
},
|
|
972
|
+
{ responseTimeoutMs: DAEMON_PROBE_TIMEOUT_MS },
|
|
973
|
+
);
|
|
974
|
+
// A protocol mismatch is a REACHABLE daemon of the wrong vintage, which
|
|
975
|
+
// is a version problem rather than the lockout this probe exists to
|
|
976
|
+
// find. It is reported, and acted on, elsewhere.
|
|
977
|
+
if (attempt.ok || attempt.failure === "protocol-mismatch") return { ok: true };
|
|
978
|
+
}
|
|
979
|
+
if (Date.now() >= deadline) break;
|
|
980
|
+
await new Promise((r) => setTimeout(r, DAEMON_PROBE_RETRY_MS));
|
|
981
|
+
}
|
|
982
|
+
return { ok: false, reason: everConnected ? "worker" : "unreachable" };
|
|
836
983
|
} catch {
|
|
837
|
-
return false;
|
|
984
|
+
return { ok: false, reason: "unreachable" };
|
|
838
985
|
}
|
|
839
986
|
}
|
|
840
987
|
|
|
988
|
+
/** Boolean form, for callers that only branch on healthy/not. */
|
|
989
|
+
export async function probeDaemonEndToEnd(): Promise<boolean> {
|
|
990
|
+
return (await probeDaemon()).ok;
|
|
991
|
+
}
|
|
992
|
+
|
|
841
993
|
/**
|
|
842
994
|
* Waits for the service to report running, then re-checks after a settle
|
|
843
995
|
* window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
|
|
@@ -1291,6 +1443,59 @@ export function daemonVersionSkew(): { installed: string; expected: string } | n
|
|
|
1291
1443
|
return recorded === version ? null : { installed: recorded, expected: version };
|
|
1292
1444
|
}
|
|
1293
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Why a Linux unit that exists is not active: skipped on a condition, or
|
|
1448
|
+
* merely stopped.
|
|
1449
|
+
*
|
|
1450
|
+
* Only reached once `is-active` has already said "not active", so it costs a
|
|
1451
|
+
* second `systemctl` call on the unhealthy path and nothing at all on the
|
|
1452
|
+
* healthy one.
|
|
1453
|
+
*
|
|
1454
|
+
* `ConditionResult` is systemd's own record of the last condition evaluation
|
|
1455
|
+
* and is `no` only after it actually ran them and one failed. A unit that has
|
|
1456
|
+
* never been started since boot reports `yes` (the field's default), so this
|
|
1457
|
+
* cannot invent a `condition-failed` for a unit systemd has not judged — it
|
|
1458
|
+
* under-reports rather than over-reports, which is the safe direction for a
|
|
1459
|
+
* signal that clears `daemonConfigured`.
|
|
1460
|
+
*/
|
|
1461
|
+
function inactiveLinuxStatus(): DaemonServiceStatus {
|
|
1462
|
+
try {
|
|
1463
|
+
return interpretConditionResult(
|
|
1464
|
+
execFileSync(
|
|
1465
|
+
"systemctl",
|
|
1466
|
+
["show", systemdUnitName(), "--property=ConditionResult", "--value"],
|
|
1467
|
+
{ stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
|
|
1468
|
+
).toString(),
|
|
1469
|
+
);
|
|
1470
|
+
} catch {
|
|
1471
|
+
// No systemd, no systemctl, or a version without `--value`. The unit file
|
|
1472
|
+
// exists, so "stopped" remains the honest answer — and it is the
|
|
1473
|
+
// conservative one, because nothing acts destructively on it.
|
|
1474
|
+
return "stopped";
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* `systemctl show --property=ConditionResult --value` → a status.
|
|
1480
|
+
*
|
|
1481
|
+
* Split out from the subprocess so the interpretation can be tested without
|
|
1482
|
+
* `/etc/systemd/system`, which this code reads at a fixed path and no test may
|
|
1483
|
+
* write. The live behaviour — that systemd actually skips a unit whose
|
|
1484
|
+
* `ConditionPathExists=` fails, and records `no` when it does — is proven
|
|
1485
|
+
* against a real systemd in the container test rather than asserted here.
|
|
1486
|
+
*
|
|
1487
|
+
* Only a literal `no` means condition-failed. Anything else — `yes`, an empty
|
|
1488
|
+
* string from a unit systemd has not evaluated since boot, an unfamiliar word
|
|
1489
|
+
* from a future version — is "stopped", which is the state nothing acts
|
|
1490
|
+
* destructively on. This under-reports rather than over-reports on purpose: the
|
|
1491
|
+
* cost of a missed `condition-failed` is a flag cleared one command later, and
|
|
1492
|
+
* the cost of a false one is a healthy machine silently dropped to the
|
|
1493
|
+
* in-process path.
|
|
1494
|
+
*/
|
|
1495
|
+
export function interpretConditionResult(raw: string): DaemonServiceStatus {
|
|
1496
|
+
return raw.trim() === "no" ? "condition-failed" : "stopped";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1294
1499
|
export function daemonServiceStatus(): DaemonServiceStatus {
|
|
1295
1500
|
if (!isDaemonSupportedPlatform()) return "unsupported-platform";
|
|
1296
1501
|
|
|
@@ -1305,14 +1510,14 @@ export function daemonServiceStatus(): DaemonServiceStatus {
|
|
|
1305
1510
|
})
|
|
1306
1511
|
.toString()
|
|
1307
1512
|
.trim();
|
|
1308
|
-
return out === "active" ? "running" :
|
|
1513
|
+
return out === "active" ? "running" : inactiveLinuxStatus();
|
|
1309
1514
|
} catch {
|
|
1310
1515
|
// `systemctl is-active` exits non-zero (and execFileSync throws) for
|
|
1311
1516
|
// every non-"active" state — inactive, failed, or the command not
|
|
1312
|
-
// working at all (no systemd user session, systemctl missing).
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
return
|
|
1517
|
+
// working at all (no systemd user session, systemctl missing). The unit
|
|
1518
|
+
// file existing already ruled out "not-installed", so the only question
|
|
1519
|
+
// left is whether systemd skipped it on a condition.
|
|
1520
|
+
return inactiveLinuxStatus();
|
|
1316
1521
|
}
|
|
1317
1522
|
}
|
|
1318
1523
|
|
|
@@ -15,15 +15,23 @@
|
|
|
15
15
|
/**
|
|
16
16
|
* Subcommands that must never be interrupted by onboarding.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* The first three are configuration actions in their own right: `config` IS the
|
|
19
19
|
* wizard, and `policies` / `policy` are the non-interactive way to do setup.
|
|
20
20
|
* Putting a wizard in front of them would override an intent the user just
|
|
21
21
|
* stated, and would hang any script that calls them.
|
|
22
|
+
*
|
|
23
|
+
* `uninstall` is here for the sharper version of the same reason: it states the
|
|
24
|
+
* exact OPPOSITE intent. Offering to set this machine up, on the way to tearing
|
|
25
|
+
* it down, would install hooks and a root-owned systemd unit seconds before the
|
|
26
|
+
* command removes them — and on a machine that had drifted to unconfigured
|
|
27
|
+
* (a cleared flag, a reset home), that is the difference between a clean
|
|
28
|
+
* uninstall and one that leaves behind more than it found.
|
|
22
29
|
*/
|
|
23
30
|
export const FIRST_RUN_EXEMPT_SUBCOMMANDS: readonly string[] = [
|
|
24
31
|
"config",
|
|
25
32
|
"policies",
|
|
26
33
|
"policy",
|
|
34
|
+
"uninstall",
|
|
27
35
|
];
|
|
28
36
|
|
|
29
37
|
export function shouldOfferFirstRun(args: readonly string[]): boolean {
|
package/src/hooks/fp-home.ts
CHANGED
|
@@ -312,7 +312,15 @@ export function resettablePaths(): string[] {
|
|
|
312
312
|
legacy.launcherMarker(),
|
|
313
313
|
legacy.lastVersion(),
|
|
314
314
|
legacy.auditDashboard(),
|
|
315
|
-
legacy.cacheDir(),
|
|
315
|
+
// `legacy.cacheDir()` — the whole of `cache/` — stood here, and it CONTAINS
|
|
316
|
+
// `cache/hook-activity`: layout 1's decision log. Deleting the parent threw
|
|
317
|
+
// away every decision the machine had ever recorded, which is the data the
|
|
318
|
+
// dashboard's activity tab exists to show. Its children are now named
|
|
319
|
+
// individually so the log can be carried across by
|
|
320
|
+
// `migrateHookActivity()`, and everything else in `cache/` still goes —
|
|
321
|
+
// both remaining entries are re-derived on demand.
|
|
322
|
+
legacy.auditCacheDir(),
|
|
323
|
+
legacy.codexSessionPaths(),
|
|
316
324
|
legacy.spoolDir(),
|
|
317
325
|
legacy.failedDir(),
|
|
318
326
|
legacy.collectorHealth(),
|
|
@@ -335,7 +343,14 @@ export function resettablePaths(): string[] {
|
|
|
335
343
|
// setup, `cloud-policies/` by the next daemon poll.
|
|
336
344
|
localPoliciesDir(),
|
|
337
345
|
cloudPoliciesDir(),
|
|
338
|
-
at("cursors"),
|
|
346
|
+
// `at("cursors")` is deliberately NOT here, and it is load-bearing for the
|
|
347
|
+
// activity migration above rather than a separate opinion. Cursors are keyed
|
|
348
|
+
// on `(device, inode)`; `migrateHookActivity()` MOVES pages, which preserves
|
|
349
|
+
// the inode, so a surviving cursor still points at the file it belongs to
|
|
350
|
+
// and resumes at the right offset. Delete the cursors and every carried page
|
|
351
|
+
// reads as new and re-ships in full — which is the outcome the move exists
|
|
352
|
+
// to avoid. A cursor whose file is gone is inert (`retain_existing` drops
|
|
353
|
+
// it), so keeping them costs nothing when there is nothing to resume.
|
|
339
354
|
at("state"),
|
|
340
355
|
// Layout 2.
|
|
341
356
|
// NOT versionFile() — it is the layout marker, not user data, and the
|
|
@@ -344,7 +359,12 @@ export function resettablePaths(): string[] {
|
|
|
344
359
|
configFile(),
|
|
345
360
|
credentialsFile(),
|
|
346
361
|
auditDir(),
|
|
347
|
-
hookActivityDir()
|
|
362
|
+
// NOT hookActivityDir(). It is the DESTINATION `migrateHookActivity()` has
|
|
363
|
+
// just moved layout 1's log into, so listing it here deleted the migration
|
|
364
|
+
// moments after it happened — the reset runs every path in this list after
|
|
365
|
+
// the migrations. It is also the one directory here that is neither derived
|
|
366
|
+
// nor re-fetchable: a decision log is the record of what this machine did,
|
|
367
|
+
// and nothing regenerates it.
|
|
348
368
|
customAgentsDir(),
|
|
349
369
|
logsDir(),
|
|
350
370
|
// NOT bin/ — a downloaded daemon binary is large, version-pinned and
|
package/src/hooks/fp-reset.ts
CHANGED
|
@@ -29,18 +29,21 @@
|
|
|
29
29
|
* user out of their agent entirely, with no way back except hand-editing JSON.
|
|
30
30
|
* A loud warning that survives until setup runs is the proportionate answer.
|
|
31
31
|
*/
|
|
32
|
-
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
32
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
33
33
|
import { resolve } from "node:path";
|
|
34
34
|
import {
|
|
35
35
|
LAYOUT_VERSION,
|
|
36
36
|
customPoliciesDir,
|
|
37
37
|
failproofaiHome,
|
|
38
|
+
hookActivityDir,
|
|
39
|
+
legacy,
|
|
38
40
|
policiesDir,
|
|
39
41
|
resettablePaths,
|
|
40
42
|
} from "./fp-home";
|
|
41
43
|
import { detectLayout, readConfig, updateConfig, writeVersionFile, type LayoutState } from "./fp-config";
|
|
42
44
|
import {
|
|
43
45
|
daemonServiceStatus,
|
|
46
|
+
daemonStatusCommand,
|
|
44
47
|
daemonVersionSkew,
|
|
45
48
|
isDaemonSupportedPlatform,
|
|
46
49
|
probeDaemonEndToEnd,
|
|
@@ -51,6 +54,8 @@ export interface ResetOutcome {
|
|
|
51
54
|
removed: string[];
|
|
52
55
|
/** Basenames of user-authored policy files moved into `custom-policies/`. */
|
|
53
56
|
migrated: string[];
|
|
57
|
+
/** Basenames of decision-log pages carried into layout 2's `hook-activity/`. */
|
|
58
|
+
activity: string[];
|
|
54
59
|
/** The layout that was found before the reset. */
|
|
55
60
|
from: number;
|
|
56
61
|
}
|
|
@@ -109,6 +114,94 @@ export function migrateConventionPolicies(): string[] {
|
|
|
109
114
|
return moved.sort((a, b) => a.localeCompare(b));
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Carry layout 1's decision log into layout 2, WITHOUT re-shipping it.
|
|
119
|
+
*
|
|
120
|
+
* `cache/hook-activity` was deleted along with the rest of `cache/`, so an
|
|
121
|
+
* upgrade silently discarded every decision the machine had ever recorded —
|
|
122
|
+
* the data the dashboard's activity tab exists to show, and on a connected
|
|
123
|
+
* machine the data an operator had already been billed the collection of.
|
|
124
|
+
*
|
|
125
|
+
* # Why this MOVES rather than copies
|
|
126
|
+
*
|
|
127
|
+
* The collector keys its cursors on `(device, inode)` — deliberately, because
|
|
128
|
+
* the store rotates by RENAMING `current.jsonl`, and a path-keyed cursor would
|
|
129
|
+
* both re-ship the rotated file and carry its offset onto the fresh one.
|
|
130
|
+
* `rename()` preserves the inode, so a moved page is still the file its cursor
|
|
131
|
+
* belongs to and resumes at the right offset. A copy gives every page a NEW
|
|
132
|
+
* inode, which reads as "never seen before" and re-ships all of it.
|
|
133
|
+
*
|
|
134
|
+
* `head_fingerprint` is what makes that safe rather than lucky: the cursor
|
|
135
|
+
* verifies the file's first bytes whenever a resumed cursor's path has changed,
|
|
136
|
+
* which is exactly this situation. It was added for inode REUSE; a migration is
|
|
137
|
+
* the same question asked the other way round.
|
|
138
|
+
*
|
|
139
|
+
* The fallback for `EXDEV` (a `cache/` on a different filesystem — rare, since
|
|
140
|
+
* both live under one home, but possible with bind mounts) is a copy, accepting
|
|
141
|
+
* that those pages re-ship. Ingest dedups on a content hash and collapses them
|
|
142
|
+
* at merge, so the cost is bandwidth, not duplicate rows.
|
|
143
|
+
*
|
|
144
|
+
* # What is deliberately NOT carried
|
|
145
|
+
*
|
|
146
|
+
* `current.count` and `stats.json` are derived state, and two of each cannot be
|
|
147
|
+
* merged without inventing a number. They are dropped and the store rebuilds
|
|
148
|
+
* them. The legacy `current.jsonl` is moved under a PAGE name rather than onto
|
|
149
|
+
* the destination's own `current.jsonl`, which may already exist and may be
|
|
150
|
+
* mid-write — a rotated page is exactly what the store would have made of it.
|
|
151
|
+
*/
|
|
152
|
+
export function migrateHookActivity(): string[] {
|
|
153
|
+
const from = legacy.hookActivityDir();
|
|
154
|
+
if (!existsSync(from)) return [];
|
|
155
|
+
const moved: string[] = [];
|
|
156
|
+
try {
|
|
157
|
+
const entries = readdirSync(from, { withFileTypes: true }).filter(
|
|
158
|
+
(e) => e.isFile() && e.name.endsWith(".jsonl"),
|
|
159
|
+
);
|
|
160
|
+
if (entries.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
const to = hookActivityDir();
|
|
163
|
+
mkdirSync(to, { recursive: true });
|
|
164
|
+
|
|
165
|
+
// One timestamp for the whole migration, with a per-file counter, so the
|
|
166
|
+
// names are stable, ordered, and cannot collide with each other.
|
|
167
|
+
const stamp = Date.now();
|
|
168
|
+
let seq = 0;
|
|
169
|
+
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
const source = resolve(from, entry.name);
|
|
172
|
+
// `current.jsonl` becomes a page: the destination has its own, and the
|
|
173
|
+
// store's reader treats pages and current identically.
|
|
174
|
+
let name = entry.name === "current.jsonl" ? `page-${stamp}-${seq++}.jsonl` : entry.name;
|
|
175
|
+
let target = resolve(to, name);
|
|
176
|
+
// Never overwrite. A same-named page in the destination is a different
|
|
177
|
+
// file with different records, and losing either is worse than a rename.
|
|
178
|
+
while (existsSync(target)) {
|
|
179
|
+
name = `page-${stamp}-${seq++}.jsonl`;
|
|
180
|
+
target = resolve(to, name);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
renameSync(source, target);
|
|
184
|
+
moved.push(name);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// EXDEV: a rename across filesystems. Copy instead and accept the
|
|
187
|
+
// re-ship; a page left behind would be data lost.
|
|
188
|
+
if ((err as NodeJS.ErrnoException)?.code === "EXDEV") {
|
|
189
|
+
try {
|
|
190
|
+
copyFileSync(source, target);
|
|
191
|
+
moved.push(name);
|
|
192
|
+
} catch {
|
|
193
|
+
// Unreadable or undeletable — leave it where it is. `cache/` is no
|
|
194
|
+
// longer deleted wholesale, so "left behind" means "still there".
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// An activity directory we cannot read is not worth aborting a reset over.
|
|
201
|
+
}
|
|
202
|
+
return moved.sort((a, b) => a.localeCompare(b));
|
|
203
|
+
}
|
|
204
|
+
|
|
112
205
|
/**
|
|
113
206
|
* Delete every path layout 1 or layout 2 could have written, then stamp
|
|
114
207
|
* VERSION so the next run reads as current.
|
|
@@ -123,6 +216,7 @@ export function resetHome(from: number): ResetOutcome {
|
|
|
123
216
|
// BEFORE the deletions, so a file that is mid-move is never one the reset
|
|
124
217
|
// then walks over.
|
|
125
218
|
const migrated = migrateConventionPolicies();
|
|
219
|
+
const activity = migrateHookActivity();
|
|
126
220
|
const removed: string[] = [];
|
|
127
221
|
for (const path of resettablePaths()) {
|
|
128
222
|
if (!existsSync(path)) continue;
|
|
@@ -136,7 +230,7 @@ export function resetHome(from: number): ResetOutcome {
|
|
|
136
230
|
}
|
|
137
231
|
}
|
|
138
232
|
writeVersionFile();
|
|
139
|
-
return { removed, migrated, from };
|
|
233
|
+
return { removed, migrated, activity, from };
|
|
140
234
|
}
|
|
141
235
|
|
|
142
236
|
export interface LayoutCheck {
|
|
@@ -186,15 +280,19 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
|
|
|
186
280
|
}
|
|
187
281
|
|
|
188
282
|
if (state.kind === "stale") {
|
|
189
|
-
const { removed, migrated } = resetHome(state.found);
|
|
283
|
+
const { removed, migrated, activity } = resetHome(state.found);
|
|
190
284
|
return {
|
|
191
285
|
state,
|
|
192
286
|
fatal: false,
|
|
193
287
|
didReset: true,
|
|
194
288
|
lines: [
|
|
195
289
|
`failproofai reorganised ${failproofaiHome()} in this version.`,
|
|
196
|
-
|
|
197
|
-
|
|
290
|
+
// "activity history" was in this sentence while the reset was deleting
|
|
291
|
+
// it. It is carried over now, so the message says what was KEPT as well
|
|
292
|
+
// as what went — a user who reads "removed" and nothing else has no way
|
|
293
|
+
// to know their decision log survived.
|
|
294
|
+
`Removed ${removed.length} item(s) from the old layout — policy config and`,
|
|
295
|
+
`audit cache. Your daemon binary and decision history were kept.`,
|
|
198
296
|
// Named individually rather than counted. These are files a person
|
|
199
297
|
// wrote; "moved 3 items" is not something you can check at a glance,
|
|
200
298
|
// and the whole point of saying it is that they can.
|
|
@@ -206,6 +304,16 @@ export async function checkLayoutForCli(): Promise<LayoutCheck> {
|
|
|
206
304
|
...migrated.map((name) => ` ${name}`),
|
|
207
305
|
]
|
|
208
306
|
: []),
|
|
307
|
+
// Counted, not named. Unlike policy files these are machine-written
|
|
308
|
+
// pages with generated names — a list of them tells the reader nothing
|
|
309
|
+
// they could act on, where the COUNT answers the only question they
|
|
310
|
+
// have: did my history survive.
|
|
311
|
+
...(activity.length > 0
|
|
312
|
+
? [
|
|
313
|
+
``,
|
|
314
|
+
`Carried ${activity.length} page(s) of decision history into ${hookActivityDir()}.`,
|
|
315
|
+
]
|
|
316
|
+
: []),
|
|
209
317
|
``,
|
|
210
318
|
`Run \`failproofai config\` to set up again.`,
|
|
211
319
|
],
|
|
@@ -253,6 +361,30 @@ async function healDaemonFlag(): Promise<string[]> {
|
|
|
253
361
|
];
|
|
254
362
|
}
|
|
255
363
|
|
|
364
|
+
// Installed, and systemd has refused to start it: one of the paths the unit
|
|
365
|
+
// is gated on is gone. This is what `npm rm -g failproofai` leaves behind —
|
|
366
|
+
// npm runs no uninstall script, so the unit survives the package that
|
|
367
|
+
// supplies its worker, and every tool call on the machine then denies with
|
|
368
|
+
// nothing to point at.
|
|
369
|
+
//
|
|
370
|
+
// Treated like "not-installed" rather than like "stopped" because systemd
|
|
371
|
+
// has already made the call and will keep making it at every boot. That is
|
|
372
|
+
// the distinction `condition-failed` exists to carry; see its definition.
|
|
373
|
+
if (status === "condition-failed") {
|
|
374
|
+
updateConfig({ daemon: { configured: false } });
|
|
375
|
+
return [
|
|
376
|
+
`failproofaid is installed but cannot start — a file its service requires is`,
|
|
377
|
+
`gone (most often because failproofai was removed with \`npm rm -g\`, which`,
|
|
378
|
+
`deletes the worker but leaves the service behind). This machine was`,
|
|
379
|
+
`configured to require the daemon, which denies every tool call, so that flag`,
|
|
380
|
+
`is cleared; policies now evaluate in-process.`,
|
|
381
|
+
``,
|
|
382
|
+
`Run \`failproofai uninstall\` to remove the leftover service, or`,
|
|
383
|
+
`\`failproofai config\` to rebuild it. \`${daemonStatusCommand()}\` names the missing path.`,
|
|
384
|
+
``,
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
|
|
256
388
|
// Installed and RUNNING is not the same as working, and the difference is
|
|
257
389
|
// a total lockout. `ExecStart` bakes in `process.execPath`, so an
|
|
258
390
|
// `nvm uninstall 20` months later leaves a unit systemd still calls active
|