diff --git a/.github/workflows/build-daemon.yml b/.github/workflows/build-daemon.yml index d3724b9d..a7606c70 100644 --- a/.github/workflows/build-daemon.yml +++ b/.github/workflows/build-daemon.yml @@ -60,10 +60,19 @@ jobs: # architectures (including a native arm64 runner) rather than # `cross`/Docker cross-compilation — a real linker for the target # triple, no QEMU emulation overhead. - - target: x86_64-unknown-linux-gnu + # + # musl, NOT gnu: a glibc build links against the runner's own libc, + # and `ubuntu-latest` is 24.04 (glibc 2.39), so the 1.0.0-beta.0 + # binaries refused to start on Ubuntu 22.04, Debian 12, RHEL 9 and + # Amazon Linux 2023 with `version GLIBC_2.39 not found` — measured, + # not predicted. Pinning an older runner would only move the floor + # (22.04 is glibc 2.35, still above RHEL 9's 2.34); a static musl + # binary has no floor at all. The daemon is a socket supervisor with + # no NSS or dlopen use, which is what makes static linking safe here. + - target: x86_64-unknown-linux-musl os: ubuntu-latest platform: linux-x64 - - target: aarch64-unknown-linux-gnu + - target: aarch64-unknown-linux-musl os: ubuntu-24.04-arm platform: linux-arm64 # macOS cannot be cross-compiled reliably from Linux (system @@ -90,6 +99,14 @@ jobs: - run: rustup target add ${{ matrix.target }} + # `rustup target add` ships the musl std library but not a musl linker; + # without musl-tools the leg fails at link time with + # `linker 'musl-gcc' not found`. Both Linux runners are native to their + # own target, so the distro package is the right linker for the triple. + - name: Install the musl toolchain + if: contains(matrix.target, 'musl') + run: sudo apt-get update -qq && sudo apt-get install -y -qq musl-tools + # Split restore/save rather than `actions/cache@v6`, which does both. # This job runs on `pull_request` AND on the release path (via # `workflow_call` from publish.yml, where `github.event_name` is the @@ -139,6 +156,18 @@ jobs: run: | BIN="target/${{ matrix.target }}/release/failproofaid" "$BIN" --version + # A dynamically linked "static" build would reintroduce the glibc + # floor silently — the binary still runs here, on the runner that + # built it, and only fails on the users' older distros. Assert the + # property on the artifact itself. + if [[ "${{ matrix.target }}" == *musl* ]]; then + file "$BIN" + if ldd "$BIN" 2>&1 | grep -qv "not a dynamic executable\|statically linked"; then + echo "::error::${{ matrix.platform }} is not statically linked — it would inherit the runner's glibc floor" + ldd "$BIN" || true + exit 1 + fi + fi gzip -9 -c "$BIN" > "failproofaid-${{ matrix.platform }}.gz" ls -l "failproofaid-${{ matrix.platform }}.gz" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23a5a8b6..db193efc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,19 @@ jobs: MISMATCH=1 fi done + # The daemon binaries ship as GitHub Release assets rather than npm + # platform packages, so there are no optionalDependencies to keep in + # step — but the Cargo version still has to match, because the + # release tag the CLI builds its download URL from is the npm + # version, and the binary at that URL reports the Cargo one. + # Check the Cargo workspace version (failproofaid) against root package.json + if [ -f Cargo.toml ]; then + CARGO_VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.*)"/\1/') + if [ "$CARGO_VERSION" != "$ROOT_VERSION" ]; then + echo "::error file=Cargo.toml::Version mismatch: Cargo.toml has $CARGO_VERSION, expected $ROOT_VERSION" + MISMATCH=1 + fi + fi if [ "$MISMATCH" -eq 1 ]; then echo "::error::Version mismatch detected across package.json files" exit 1 @@ -75,6 +88,67 @@ jobs: timeout_minutes: 5 command: bunx tsc --noEmit + rust-quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + # This job runs `cargo clippy`/`cargo test` over the full + # dependency tree, executing third-party build scripts. The + # default (`true`) would leave GITHUB_TOKEN in .git/config where + # any of them could read it; nothing here needs push access. + persist-credentials: false + + # Stage 1 lands an empty Cargo workspace (zero crates/*/Cargo.toml) so + # the CI plumbing itself can go green before any Rust code exists. + # `cargo build/clippy/test --workspace` (and even `cargo fmt --all`) + # all hard-error on a zero-member workspace ("the workspace has no + # members"), so every real step below is gated on at least one crate + # being present rather than relying on any of them to no-op cleanly. + - name: Detect crates + id: crates + run: | + if ls crates/*/Cargo.toml >/dev/null 2>&1; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "No crates/*/Cargo.toml yet — rust-quality has nothing to check." + fi + + - if: steps.crates.outputs.present == 'true' + run: rustup show + + # cargo test spawns the real TS worker via `bun bin/failproofai-worker.mjs` + # (crates/failproofaid/src/server.rs's live end-to-end test) — bun has to + # be on PATH for that test, not just for the TS-side jobs. + - if: steps.crates.outputs.present == 'true' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - if: steps.crates.outputs.present == 'true' + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: cargo-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'crates/*/Cargo.toml') }} + restore-keys: cargo-${{ runner.os }}- + + - name: cargo fmt --check + if: steps.crates.outputs.present == 'true' + run: cargo fmt --all -- --check + + - name: cargo clippy + if: steps.crates.outputs.present == 'true' + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: cargo test + if: steps.crates.outputs.present == 'true' + run: cargo test --workspace + test: runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index e52e93d3..c80c2157 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ packages/*/assets/ # closed-source platform (cloned separately) /platform +# rust +/target + # WSL/Windows alternate data streams *:Zone.Identifier .dev.log @@ -100,3 +103,11 @@ COMMIT_MSG.tmp /canary.env /integration-suite-state.json /cli-integration-state.json + +# Compressed failproofaid binaries — produced by +# .github/workflows/build-daemon.yml (or a local `cargo build --release` + +# gzip for manual Docker verification), uploaded as release assets, never +# committed. Same for npm-pack tarballs produced anywhere in the repo during +# local packaging tests. +/failproofaid-*.gz +*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index 457d062a..8b0f4d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,29 @@ # Changelog -## 0.0.16-beta.0 — 2026-07-31 +## 1.0.0-beta.2 — 2026-07-31 ### Fixes +- Close three review findings in the system-service install, all of them introduced with it. The staging file for the privileged write used a guessable name (`failproofaid--.tmp`) in the shared temp dir, and `writeFileSync` follows a symlink already sitting at that path — so on a multi-user machine another local user could pre-create it and have `install` copy content they control into `/etc/systemd/system` as root; staging now happens inside a `mkdtempSync` 0700 directory whose name cannot be predicted. `FAILPROOFAI_WORKER_CMD` joined two paths without quoting, and the daemon runs that value through `sh -c` (`WorkerCommand::Shell`), so any path containing a space split into fragments and the worker never started — ordinary on macOS (`/Users/First Last/…`) and newly likely because the absolute `process.execPath` replaced a bare `node`; both halves are shell-quoted now, which systemd's own `Environment="…"` quoting does not do (that protects the unit parse, not the later shell split). And the launchd label was a fixed string while the systemd unit was already per-user, so a second Mac user's install overwrote the first's daemon — `UserName`, the ExecStart path under their own `~/.failproofai/bin`, their log paths — and their uninstall removed it; the label and plist path are namespaced per user, with the shared 1.0.0-beta.1 LaunchDaemon stopped and removed on install like the legacy LaunchAgent, since it holds the same singleton flock. (#632) +- Ask for the daemon first, and prompt for sudo in-process instead of telling people to run the CLI under sudo. `sudo failproofai config` was the advice 1.0.0-beta.1 printed when it could not elevate, and it is actively wrong: under sudo `homedir()` is `/root`, so the hooks land in root's settings, `daemonConfigured` is set for root, the binary downloads to `/root/.failproofai/bin`, and the generated unit carries `User=root` — the whole point of a user-scope daemon, undone silently, on the one path a user follows when something already went wrong. The wizard now refuses to run under `sudo` at all when `SUDO_USER` says a real user is behind it, naming the account to re-run as (a genuinely root-only environment, with no `SUDO_USER`, still works). Service installation moves to **step 0**, before any other question: it is the only step that needs a password, and asking there means `sudo -v` prompts on a clean terminal rather than firing from underneath a drawn TUI screen where the prompt is invisible and the typed characters land in a redrawn frame. That one prompt caches the credential for the run, so the install itself stays non-interactive. The daemon is also no longer inferred from the scope — it is machine-level, one service for every project, so step 0 is where the user consents to it, and a project-scope setup can have one too. Declining, or failing to authenticate, never costs the rest of the setup: the wizard says so and applies everything else, exactly as a machine with no daemon behaved before. (#632) + +## 1.0.0-beta.1 — 2026-07-31 + +### Fixes +- Build the Linux daemon binaries against musl instead of glibc. A glibc build links against whatever libc the runner has, and `ubuntu-latest` is now 24.04 (glibc 2.39), so the 1.0.0-beta.0 binaries refused to start on Ubuntu 22.04, Debian 12, RHEL 9 and Amazon Linux 2023 — `version 'GLIBC_2.39' not found`, measured against real containers rather than predicted. It failed safely (the service never reached a running state, `daemonConfigured` stayed false, the machine kept enforcing in-process) but the daemon was simply unavailable to a large share of Linux users. Pinning an older runner would only move the floor — 22.04 is glibc 2.35, still above RHEL 9's 2.34 — so both Linux legs now target `*-unknown-linux-musl` and link statically, which has no floor at all; the daemon is a socket supervisor with no NSS or `dlopen` use, which is what makes static linking safe here. The build asserts the property on the artifact itself, because a "static" build that silently came out dynamic would still run on the runner that made it and fail only on the users' older distros. (#632) +- Install failproofaid as a **system** service instead of a per-user one, so it survives logout and starts at boot. A systemd `--user` unit only runs while its user manager does: without `loginctl enable-linger` that manager does not start at boot and stops with the last session, so the daemon died on logout and did not come back until the next login — and on a daemon-configured machine an unreachable daemon fails closed, so anything running without a login session (a detached tmux job, cron, a CI runner) hit denials. The unit is now `/etc/systemd/system/failproofaid@.service` with `User=` and `WantedBy=multi-user.target`, enabled with `systemctl enable --now`; macOS moves from a LaunchAgent to a `/Library/LaunchDaemons` plist with `UserName`. It is root-*installed* but never root-*run* — everything it touches still lives in one user's home and is peer-checked against that user's uid — and the unit is named per user so a second person on the same box cannot silently steal the first's service. Two consequences are handled rather than assumed: install now needs root, so it checks `sudo -n` up front and, when it cannot elevate, writes nothing and returns the exact commands to run (classified as `needs_root` in telemetry) rather than half-installing; and a system unit inherits no login environment, so `FAILPROOFAI_WORKER_CMD` now names an absolute runtime via `process.execPath` instead of a bare `node`, which would resolve for the wizard and then fail inside the service on every nvm-based install. Any pre-existing user-scope daemon is stopped and removed first — it holds the same singleton flock, so leaving one behind would make the new service lose the race and leave the machine fail-closed against a daemon that never came up. `failproofai policies` and the wizard's review screen report the new path, and `systemctl status failproofaid@` works without sudo. (#632) + +## 1.0.0-beta.0 — 2026-07-31 + +### Features +- Split `failproofai` into a CLI + `failproofaid`, a persistent Rust background daemon that keeps policy evaluation warm instead of paying a full cold-start cost (bundle parse, custom-policy temp-file dance, config reads) on every hook call — a major version bump, since it changes how enforcement runs on every machine that opts in. failproofaid is a thin Rust supervisor with zero policy logic: it owns a Unix socket (`SO_PEERCRED`/`getpeereid` peer verification, a flock-based singleton guard, `crates/PROTOCOL.md` documents the wire contract) and spawns/supervises a warm Node/Bun worker that runs the existing, unmodified TypeScript policy engine. `failproofai config` installs and starts it as a real systemd `--user` unit (Linux) or launchd `LaunchAgent` (macOS) whenever the global scope is chosen on a supported platform — no separate `failproofai daemon install` command. Once a machine is daemon-configured, an unreachable daemon fails closed (a correctly-shaped deny reusing the real per-CLI response logic, not a generic denial) rather than silently falling back to in-process evaluation; a machine that's never been daemon-configured, or is on Windows (deferred), is completely unaffected — zero socket attempts, byte-for-byte the same behavior as before. None of the 11 supported CLIs' installed hook commands change. The npm package ships no binary — one tarball serves every platform — and `failproofai config` downloads the one built for this machine from the GitHub Release matching the CLI's own version; unsupported platforms simply never download one. (#632) + +- Ship the daemon binary as a GitHub Release asset instead of four npm platform packages. The packages were the plan of record — `@failproofai/failproofaid--`, declared as `optionalDependencies` and pinned to the root version — but nothing ever published them: the workflow that cross-compiles the binaries only uploaded them as Actions artifacts, and `publish.yml` was never touched at all, so every one of those four names 404s on npm to this day and a released CLI would have resolved a daemon that does not exist. Fixing the pipeline (#634) was necessary either way, but the packages themselves are now gone: the release assets have to exist regardless for anyone installing failproofaid on its own, and a second channel is a second thing to keep in step with the first — the scope also has to be created and owned before a single publish can succeed. `src/hooks/daemon-download.ts` fetches `failproofaid--.gz` from the release tagged with this CLI's own version, verifies it against the published `SHA256SUMS` **before** decompressing, and installs it to `~/.failproofai/bin/failproofaid-` by atomic rename with mode 0755. The URL is constructed from `package.json`'s version rather than discovered through the API — no rate limit, no `releases/latest` redirect, and no way to end up running a daemon built from different source than the CLI talking to it — and the versioned filename keeps an upgrade from overwriting a running binary (`ETXTBSY`) or silently repointing a live service unit. A bad checksum, a missing manifest entry and a failed fetch are all refusals, not warnings, because what this writes is an executable a service manager runs at login. Only `failproofai config` downloads; `resolveFailproofaidBinaryPath()` stays a pure disk check, so the hook path can never block on the network. `FAILPROOFAI_NO_DOWNLOAD=1` opts an air-gapped machine out (an already-installed binary keeps working), and `FAILPROOFAI_DAEMON_BASE_URL` points at an internal mirror. (#632) + +### Fixes +- Close eleven review findings on the daemon split, four of them enforcement-breaking. **macOS was broken outright**: the accept loop left accepted sockets in the listener's non-blocking mode, which Linux discards via `accept4` but BSD-derived kernels inherit — `read_message` then returned `WouldBlock` before the client's bytes landed, the daemon read that as a malformed frame and answered with silence, and every hook call on macOS fell through to the fail-closed deny. **Worker restart never worked**: a Unix socket file outlives the process that bound it, so the `socket_path.exists()` readiness check saw the *dead* worker's leftover file the instant a new one spawned and handed `call()` a socket nothing was listening on; readiness is now a real `connect()`, the stale path is cleared before spawn, and `Drop` cleans up after itself. **`daemonConfigured` tracked the service manager rather than a reachable daemon**: it was granted on `systemctl enable --now`/`launchctl load` exiting 0 — which a daemon that dies at startup also does — and never revoked on uninstall, so either end of that lifecycle left the machine denying every hook event across all 11 CLIs with no recovery but hand-editing `~/.failproofai/policies-config.json`; install now waits for the service to reach *and hold* a running state (`Type=simple` reports active the moment it forks, so one reading is not enough) and uninstall clears the marker first and unconditionally. And the client's single 150ms budget covered the whole daemon roundtrip including policy evaluation — which `handler.ts` allows 10s per custom policy and `worker-server.ts` serializes — so a slow-but-correct verdict produced the same block as a dead daemon; the budget is now split into a 150ms *connect* probe (still fast-failing an unreachable daemon) and a 30s *response* budget matching the daemon's own ceiling. Also: `process.exit()` in the `--hook` path discarded unflushed stdout on a pipe, truncating the decision payload the agent CLI reads (measured: 2 MB written, 146 KB delivered) — writes are drained first now; the worker's piped stdout/stderr were never read, so a chatty custom policy would eventually fill the pipe buffer and block the worker mid-write, failing every later hook call closed; the worker server decoded only one frame per `data` event, stranding the second of two coalesced requests until a third write arrived; connections had no read/write deadline and no cap, so a peer that connected and sent nothing held a thread for the daemon's life; every `systemctl`/`launchctl` call was unbounded, turning a wedged user session into a silent wizard hang; and the daemon-install telemetry sent `err.message` verbatim, which for `writeFileSync`/`execFileSync` failures is an errno string containing a `homedir()`-derived absolute path — the OS username now stays local and only a bounded classification is sent. (#632) +- Fix the `darwin-x64` daemon build leg, which used the retired `macos-13` runner label — an unknown label doesn't fail, it simply never gets a runner, so that leg sat pending forever and the matrix could not complete. Also harden the release-artifact build job, whose output is the binary users install: it no longer shares a writable cargo cache between `pull_request` and `release` triggers (a PR branch could seed an entry a later release run restores into a published binary — restore-only on PRs now, save on release/dispatch), it builds with `--locked` so the artifact comes from the committed `Cargo.lock` rather than whatever Cargo resolves in the runner, and the checkouts that then compile third-party crates set `persist-credentials: false` so build scripts can't read `GITHUB_TOKEN` out of `.git/config`. (#632) +- Fix the CI bun cache key, which never matched anything since `hashFiles('bun.lockb')` referenced a filename this repo doesn't track (`bun.lock` is the real lockfile) — the cache silently never invalidated on a lockfile change. (#632) +- Gate the git-branch cache in builtin policies on `.git/HEAD`'s mtime instead of reusing it unconditionally for a process's lifetime — harmless in today's one-shot-per-hook-call model, but would have silently served a stale branch after a checkout once evaluation starts running inside a long-lived warm process. (#632) - Harden the release workflow against shell injection from ref names and generated outputs, align every Bun cache key with the tracked `bun.lock`, and discard the temporary publish-version edit before switching to `main` for the development-version bump. (#634) - Ship the binaries the release already builds, and stop a branch dispatch from rewriting main's version. The daemon split added every packaging input — platform manifests, pinned optional dependencies, a 4-way cross-compile matrix — but never touched `publish.yml`, so each release built four binaries as Actions artifacts and discarded them with the runner; CI stayed green because nothing checks that what gets built also gets shipped. `publish.yml` is now four jobs — preflight (version/dist-tag resolution, an npm credential check that fails in seconds rather than after a 20-minute matrix, and daemon detection), a call into `build-daemon.yml` as a reusable workflow, an asset job that assembles `SHA256SUMS` and attaches it plus the four binaries to the GitHub Release, and the npm publish — in that order, because the installed CLI downloads its daemon from that release tag and publishing the package first ships a version whose binary does not exist yet. A failed cross-compile now blocks the publish explicitly: a failed dependency leaves its dependents `skipped`, which the old-style guard would have read as "nothing to do". The version bump checks main out and pushes to it, so it runs only for a release or a dispatch from main, and `latest` is refused from a non-main dispatch (`auto` resolves to `next` there) so a branch build cannot move a dist-tag that a later release from main would move backwards. Adds a `dry_run` input that builds, checksums and validates the publish while writing nothing, and fixes the bun cache key, which hashed a `bun.lockb` this repo does not track. All of it is gated on the ref carrying a Rust workspace, so on main this changes nothing until the daemon lands. (#634) diff --git a/CLAUDE.md b/CLAUDE.md index c7140786..cf3693c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -867,13 +867,89 @@ Resolve any conflicts, then continue. Never push a branch that is missing commit After every `git push`, run `gh run watch` or poll `gh run list --limit 3` until all checks finish. If any job fails, **stop and fix it before continuing**. Never leave a red CI. -The CI runs four jobs — all must pass: +`.github/workflows/ci.yml` runs these jobs on every push — all must pass: + | Job | Command | |-----|---------| -| quality | lint + tsc + version-consistency check | -| test | `bun run test:run` (unit, 4 env configs) | -| build | `bun run build` (Next.js + dist/index.js) | +| quality | lint + tsc + version-consistency check (now also covers `Cargo.toml`'s workspace version against root `package.json` — the release tag the CLI builds its daemon download URL from is the npm version, and the binary at that URL reports the Cargo one) | +| rust-quality | `cargo fmt --check` + `cargo clippy` + `cargo test --workspace`. `cargo test` spawns the real TS worker via `bun`, so the job installs bun too. (The steps stay gated on `crates/*/Cargo.toml` existing — a zero-member workspace hard-errors — but both crates are present now, so they all run for real.) | +| test | `bun run test:run` (unit, 3 env configs) | +| build | `bun run build` (Next.js + `dist/index.js` + `dist/cli.mjs` + `dist/worker.mjs`) | | test-e2e | `bun run test:e2e` | +| docs | docs build/validation | + +A separate `.github/workflows/build-daemon.yml` ("Build failproofaid") cross-compiles the +4 real `failproofaid` release binaries (linux-x64/arm64, darwin-x64/arm64), gzips each one +and uploads it as an artifact — path-filtered to `crates/**`/`Cargo.*`/ +`rust-toolchain.toml` changes, so it doesn't run on every PR. It is a **reusable +workflow**: `publish.yml` calls it and downloads those artifacts in the same run, which is +how a release gets binaries built from the exact commit being published. It can also be +triggered manually via `workflow_dispatch`. It's slower than `rust-quality` (real +cross-compiles across 4 matrix legs, two of them real macOS runners) — don't expect it to +finish inside a quick `gh run watch` poll; check back or use a longer timeout. + +### How the daemon is supervised + +The service is **system-scope, user-run**: `/etc/systemd/system/failproofaid@.service` +with `User=` and `WantedBy=multi-user.target` (macOS: a `/Library/LaunchDaemons` +plist with `UserName`). It starts at boot, needs no login, and survives logout. + +It was a systemd `--user` unit through 1.0.0-beta.0, and that is what forced the change: a +user manager does not start at boot without `loginctl enable-linger` and stops with the +last session, so the daemon died on logout — and because a daemon-configured machine +**fails closed**, anything running without a login session (detached tmux, cron, a CI +runner) then hit denials. + +Three consequences, all handled explicitly rather than assumed: + +- **Install needs root.** `canElevate()` checks `sudo -n` (or uid 0) *before* writing + anything; when it fails, the install writes nothing and returns the exact commands to + run, classified as `needs_root`. `sudo -n`, never interactive — a password prompt fired + from under the wizard's TUI is unreadable. +- **A system unit has no login environment.** `resolveWorkerCommand()` uses + `process.execPath`, not a bare `node`: the single most common Node install is nvm, whose + binary lives under `~/.nvm/versions/node/*/bin` and is on no system PATH. A bare `node` + resolves when the wizard runs it and then fails inside the service, silently. +- **The old user unit must go first.** `removeLegacyUserService()` runs on every install + and uninstall. It holds the same flock the new service needs, so leaving one behind means + the system unit starts, loses the singleton race, and the machine sits fail-closed + against a daemon that never came up. + +The unit is named per user (`failproofaid@alice`) so a second person on the same box cannot +silently steal the first's service — every field in it is user-specific anyway (ExecStart +under that user's `~/.failproofai/bin`, HOME, the worker command). Reading status needs no +privileges: `systemctl status failproofaid@`, exposed as `daemonStatusCommand()`. + +### How the daemon binary reaches users + +The npm package ships **no binary** — one tarball serves every platform. The four +cross-compiled binaries are **GitHub Release assets** (`failproofaid--.gz` plus a +`SHA256SUMS` manifest), and `src/hooks/daemon-download.ts` fetches the one matching this +CLI's own version into `~/.failproofai/bin/failproofaid-`, verifying the SHA-256 +**before** decompressing and installing via an atomic rename (a versioned filename avoids +`ETXTBSY` against a running daemon, and stops an upgrade from repointing a live service +unit at a binary built from different source). + +The URL is *constructed* from `package.json`'s version, never discovered — no API call, no +`releases/latest` redirect, no rate limit, and no way to end up with a daemon built from +different source than the CLI talking to it. `publish.yml` therefore attaches the assets +**before** it publishes to npm; reverse that and the package ships pointing at a tag whose +binaries do not exist yet. + +Only the install path (`failproofai config`, global scope) downloads. +`resolveFailproofaidBinaryPath()` is a pure disk check — env override → +`~/.failproofai/bin/failproofaid-` → a locally-built `target/{release,debug}` +binary — so the hook path can never block on the network. Two escape hatches: +`FAILPROOFAI_NO_DOWNLOAD=1` (air-gapped: fail with a reason instead of reaching out, while +an already-installed binary keeps working) and `FAILPROOFAI_DAEMON_BASE_URL` (an internal +mirror, and what the tests point at a local HTTP server). + +An earlier design shipped `@failproofai/failproofaid--` optional platform +packages instead. It was removed before release: the four packages were declared as +`optionalDependencies` but nothing ever published them (the daemon PR never touched +`publish.yml`), so every install resolved four 404s — and the release assets have to exist +for standalone installs regardless, so npm would have been a second channel to keep in +sync with the first. ### Always add unit tests for new behaviour When you add or change logic, add a corresponding test in `__tests__/`. Never modify @@ -948,19 +1024,90 @@ After any change to `src/hooks/`, verify these scenarios don't regress: ``` bin/failproofai.mjs Entry point (bun shebang); sets FAILPROOFAI_DIST_PATH +bin/failproofai-worker.mjs Warm-worker entrypoint; spawned by the Rust daemon, not a user +bin/failproofaid-shim.mjs `failproofaid` bin entry; execs the downloaded binary at + ~/.failproofai/bin/failproofaid- (hand invocation + only — service units point at the binary directly) src/hooks/ custom-hooks-loader.ts Orchestrates temp-file creation + dynamic import loader-utils.ts findDistIndex(), createEsmShim(), rewriteFileTree() custom-hooks-registry.ts globalThis registry shared between loader and handler policy-helpers.ts allow() / deny() / instruct() - handler.ts Called by Claude Code --hook events + handler.ts canonicalizeEventType() + evaluateHookEvent() (core logic, + param-in/return-out) + handleHookEvent() (one-shot stdin/ + stdout wrapper called by both bin/failproofai.mjs and tests) + worker-server.ts Listens on the daemon-spawned worker's Unix socket, serializes + concurrent evaluateHookEvent() calls through one async queue + daemon-client.ts isDaemonConfigured() + tryDaemonHook(). TWO budgets, not + one: ~150ms to CONNECT (the "is anything listening" probe + — a dead daemon must never add latency to a hook) and 30s + for the RESPONSE once connected, matching worker.rs's own + read timeout. They are separate because a timeout here is + a DENY on a daemon-configured machine, and one 150ms + budget over the whole roundtrip made a slow-but-correct + evaluation (handler.ts allows 10s per custom policy; + worker-server.ts serializes) indistinguishable from a dead + daemon. See also the worker pre-warming note in worker.rs + below, and the awaitTelemetryFlush note in handler.ts for + a bug class that silently blew through the budget even + when warm + daemon-download.ts Fetches the release asset for this version into + ~/.failproofai/bin, SHA-256 verified before it is + decompressed and atomically renamed into place. Never + throws; FAILPROOFAI_NO_DOWNLOAD / FAILPROOFAI_DAEMON_BASE_URL + opt out or redirect it + daemon-service.ts installDaemonService()/uninstallDaemonService()/ + daemonServiceStatus()/setDaemonConfigured() — SYSTEM-scope + systemd unit (/etc/systemd/system/failproofaid@ + .service, User=, WantedBy=multi-user.target) / + launchd LaunchDaemon with UserName; root-installed via + `sudo -n`, never root-run. Called + directly by configure-wizard.ts, no public + `failproofai daemon` subcommand. install waits for the + service to reach AND HOLD a running state before + reporting success (a Type=simple unit reports active the + moment it forks, so one reading passes a daemon that died + at startup), and uninstall clears daemonConfigured first + and unconditionally — leaving that flag set with no daemon + to reach denies every hook event on the machine, across + all 11 CLIs, recoverable only by hand-editing + ~/.failproofai/policies-config.json manager.ts policies --install / --uninstall / list src/index.ts Public API entry point → compiled to dist/index.js dist/index.js CJS bundle (built by `bun run build`; shipped in npm pkg) +dist/cli.mjs Bundled bin/failproofai.mjs (bun run build:cli) +dist/worker.mjs Bundled bin/failproofai-worker.mjs (bun run build:worker) — + plain Node can't resolve raw .ts specifiers, so the warm + worker needs this bundle just like the CLI does +Cargo.toml Rust workspace root (resolver "3", shared [workspace.package]) +crates/fpai-ipc/ Wire protocol shared by the daemon and its tests: length- + prefixed JSON framing, protocolVersion envelope, peer- + credential checks (see crates/PROTOCOL.md) +crates/failproofaid/ The daemon binary — socket server + service lifecycle + + worker supervision, zero policy logic + src/worker.rs Spawns/supervises the warm worker subprocess; Worker::warm() + pre-starts it off the accept-loop path right after the daemon + binds its socket (main.rs) so the ~700ms Node cold start never + lands on the critical path of a real hook call + src/server.rs Unix socket accept loop, relays Hook requests to the worker + src/paths.rs ~/.failproofai/run/ layout (socket, worker socket, lock) + src/lock.rs Non-blocking flock() singleton guard + (the four compiled binaries are GitHub Release assets, not + npm packages — see "How the daemon binary reaches users") __tests__/ Unit + e2e tests (vitest) examples/ Sample custom policy files ``` +**This repo's own dogfood hook configs (`.claude/settings.json`, +`.codex/hooks.json`, etc.) deliberately stay on the in-process path, never +daemon-configured** — `scripts/dev-hook.mjs` already exists specifically to +avoid a self-reference conflict between this repo's own dogfood hooks and the +package being developed inside it; a locally-running daemon (with its +fail-closed-on-down behavior) in that same loop would multiply that exact +risk class, and a flaky dev daemon could start blocking this repo's own +contributors' tool calls. This is a deliberate, standing decision — don't +wire a daemon into the dogfood configs without revisiting it explicitly. + ## Changelog Every PR **must** include an update to `CHANGELOG.md`. Add your entry under the diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..b6b562d8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1605 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "failproofaid" +version = "1.0.0-beta.0" +dependencies = [ + "fpai-ipc", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fpai-ipc" +version = "1.0.0-beta.0" +dependencies = [ + "libc", + "proptest", + "serde", + "serde_json", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..21f7b483 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "1.0.0-beta.0" +edition = "2024" +license-file = "LICENSE" +repository = "https://github.com/FailproofAI/failproofai" diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 496f2732..d1ba0b4e 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { readFile } from "node:fs/promises"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { execSync, execFileSync } from "node:child_process"; import { BUILTIN_POLICIES, registerBuiltinPolicies, clearGitBranchCache } from "../../src/hooks/builtin-policies"; import { getPoliciesForEvent, clearPolicies } from "../../src/hooks/policy-registry"; @@ -3096,6 +3099,99 @@ describe("hooks/builtin-policies", () => { }); }); + describe("getCurrentBranch mtime-gated caching (via require-pr-before-stop)", () => { + // Exercises the internal, unexported getCurrentBranch through a real + // policy — this is specifically testing the new .git/HEAD-mtime cache + // invalidation added for the daemon's warm worker (see builtin-policies.ts): + // a branch name must never be served stale once .git/HEAD's mtime changes, + // and must be reused (no extra execSync call) while it hasn't. + const policy = BUILTIN_POLICIES.find((p) => p.name === "require-pr-before-stop")!; + let tmpCwd: string; + let headPath: string; + + beforeEach(() => { + tmpCwd = mkdtempSync(join(tmpdir(), "fpai-branch-cache-test-")); + mkdirSync(join(tmpCwd, ".git"), { recursive: true }); + headPath = join(tmpCwd, ".git", "HEAD"); + writeFileSync(headPath, "ref: refs/heads/main\n"); + }); + + afterEach(() => { + vi.mocked(execSync).mockReset(); + vi.mocked(execFileSync).mockReset(); + clearGitBranchCache(); + rmSync(tmpCwd, { recursive: true, force: true }); + }); + + function mockBranch(branch: string) { + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (typeof cmd === "string" && cmd.includes("gh --version")) return "/usr/bin/gh\n"; + if (typeof cmd === "string" && cmd.includes("rev-parse --abbrev-ref")) return `${branch}\n`; + if (typeof cmd === "string" && cmd.includes("gh pr view")) throw new Error("no pull requests found"); + return ""; + }); + vi.mocked(execFileSync).mockImplementation((_cmd: string, args?: readonly string[]) => { + const joined = args?.join(" ") ?? ""; + if (joined.includes("log") && joined.includes("..HEAD")) return "abc123 some commit\n"; + if (joined.includes("diff") && joined.includes("--stat")) return " src/index.ts | 2 +-\n"; + return ""; + }); + } + + it("reuses the cached branch across calls while .git/HEAD's mtime is unchanged", async () => { + mockBranch("first-branch"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: tmpCwd } }); + const first = await policy.fn(ctx); + expect(first.decision).toBe("deny"); + expect(first.reason).toContain('"first-branch"'); + + // Change what execSync would report WITHOUT touching .git/HEAD's mtime — + // a correct cache must still serve the first call's branch. + mockBranch("second-branch"); + const second = await policy.fn(ctx); + expect(second.reason).toContain('"first-branch"'); + expect(second.reason).not.toContain('"second-branch"'); + }); + + it("re-fetches the branch once .git/HEAD's mtime changes", async () => { + mockBranch("first-branch"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: tmpCwd } }); + const first = await policy.fn(ctx); + expect(first.reason).toContain('"first-branch"'); + + // A real checkout/switch updates .git/HEAD's mtime — simulate that + // directly rather than relying on wall-clock drift between two fast + // calls, which could land within the filesystem's mtime resolution. + writeFileSync(headPath, "ref: refs/heads/second-branch\n"); + const bumped = new Date(Date.now() + 5000); + utimesSync(headPath, bumped, bumped); + + mockBranch("second-branch"); + const second = await policy.fn(ctx); + expect(second.reason).toContain('"second-branch"'); + expect(second.reason).not.toContain('"first-branch"'); + }); + + it("does not cache when .git/HEAD cannot be stat'd (e.g. a worktree/submodule layout)", async () => { + // No .git directory at all under this cwd. + const noGitCwd = mkdtempSync(join(tmpdir(), "fpai-branch-cache-nogit-")); + try { + mockBranch("first-branch"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: noGitCwd } }); + const first = await policy.fn(ctx); + expect(first.reason).toContain('"first-branch"'); + + mockBranch("second-branch"); + const second = await policy.fn(ctx); + // Without a stat-able .git/HEAD, every call must re-fetch — matching + // today's behavior for this case rather than caching indefinitely. + expect(second.reason).toContain('"second-branch"'); + } finally { + rmSync(noGitCwd, { recursive: true, force: true }); + } + }); + }); + describe("require-no-conflicts-before-stop", () => { const policy = BUILTIN_POLICIES.find((p) => p.name === "require-no-conflicts-before-stop")!; diff --git a/__tests__/hooks/cloud-managed-policies.test.ts b/__tests__/hooks/cloud-managed-policies.test.ts new file mode 100644 index 00000000..bb5f9485 --- /dev/null +++ b/__tests__/hooks/cloud-managed-policies.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readActiveCloudManagedPolicies } from "../../src/hooks/cloud-managed-policies"; + +const roots: string[] = []; + +function fixture(policyBytes = Buffer.from("export default 'managed';\n")) { + const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-test-")); + roots.push(root); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; + const sha256 = createHash("sha256").update(policyBytes).digest("hex"); + const generationDir = join(root, "generations", "12"); + mkdirSync(generationDir, { recursive: true }); + const policyPath = join(generationDir, "guard.mjs"); + writeFileSync(policyPath, policyBytes); + writeFileSync( + join(root, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 12, + policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs" }], + }), + ); + return { root, policyPath, sha256 }; +} + +afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_POLICY_DIR; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("cloud-managed policy active generation", () => { + it("returns only hash-verified artifacts from active.json", () => { + const { policyPath, sha256 } = fixture(); + expect(readActiveCloudManagedPolicies()).toEqual([ + { id: "guard", revision: 3, sha256, path: policyPath, generation: 12 }, + ]); + }); + + it("returns an empty set when no cloud generation is active", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-empty-")); + roots.push(root); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; + expect(readActiveCloudManagedPolicies()).toEqual([]); + }); + + it("rejects modified policy bytes", () => { + const { policyPath } = fixture(); + writeFileSync(policyPath, "tampered"); + expect(() => readActiveCloudManagedPolicies()).toThrow(/failed integrity verification/); + }); + + it("rejects paths and symlinks escaping the managed root", () => { + const { root, sha256 } = fixture(); + const outside = join(tmpdir(), `fpai-cloud-managed-outside-${process.pid}.mjs`); + writeFileSync(outside, "export default 'managed';\n"); + const link = join(root, "generations", "12", "escape.mjs"); + symlinkSync(outside, link); + writeFileSync( + join(root, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 12, + policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/escape.mjs" }], + }), + ); + try { + expect(() => readActiveCloudManagedPolicies()).toThrow(/symlink escapes/); + } finally { + rmSync(outside, { force: true }); + } + }); +}); diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 535a3d7a..76185f45 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; -import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { summarize } from "../../src/hooks/tui"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -13,6 +13,25 @@ vi.mock("../../src/hooks/tui", async (importOriginal) => { return { ...actual, selectOne: vi.fn(), multiSelect: vi.fn(), intro: vi.fn(), outro: vi.fn() }; }); vi.mock("../../src/hooks/manager", () => ({ installHooks: vi.fn(async () => {}) })); +// installDaemonService shells out to real systemctl/launchctl — several tests +// below drive the wizard with scope "user", which is exactly the condition +// that triggers it. Mocked so an ordinary unit test run never touches this +// machine's real systemd/launchd state. +// Only the three that shell out are stubbed; setDaemonConfigured stays real +// so the `daemonConfigured` assertions below test the actual marker write +// (against this file's isolated HOME), not a mock of it. +vi.mock("../../src/hooks/daemon-service", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isDaemonSupportedPlatform: vi.fn(() => false), + installDaemonService: vi.fn(async () => ({ installed: false, reason: "mocked" })), + daemonServiceFilePath: vi.fn(() => null), + // Step 0 primes sudo before anything is drawn. Mocked true by default so + // no test can block on a real password prompt. + primeElevation: vi.fn(() => true), + }; +}); // The wizard kicks off the audit pipeline after a completed apply; stub it so // tests never scan real history. vi.mock("../../src/audit/cli", () => ({ runPostSetupAudit: vi.fn(async () => {}) })); @@ -25,6 +44,11 @@ vi.mock("../../src/hooks/integrations", async (importOriginal) => { import { selectOne, multiSelect, outro, type TTYIn, type TTYOut } from "../../src/hooks/tui"; import { installHooks } from "../../src/hooks/manager"; +import { + isDaemonSupportedPlatform, + installDaemonService, + primeElevation, +} from "../../src/hooks/daemon-service"; import { buildScopeChoices, buildAgentChoices, @@ -36,11 +60,13 @@ import { maybeFirstRunConfigure, hasSeenLauncher, markLauncherSeen, + classifyDaemonInstallFailure, } from "../../src/hooks/configure-wizard"; import { resolvePreset, resolveEverything } from "../../src/hooks/policy-presets"; import { INTEGRATION_TYPES, type IntegrationType } from "../../src/hooks/types"; import { getIntegration } from "../../src/hooks/integrations"; import { runPostSetupAudit } from "../../src/audit/cli"; +import { trackHookEvent } from "../../src/hooks/hook-telemetry"; const mkTtyStdin = (): TTYIn => ({ isTTY: true }) as unknown as TTYIn; const mkTtyStdout = (): TTYOut => @@ -73,6 +99,13 @@ beforeEach(() => { vi.mocked(installHooks).mockClear(); vi.mocked(runPostSetupAudit).mockClear(); vi.mocked(outro).mockClear(); + vi.mocked(isDaemonSupportedPlatform).mockReset().mockReturnValue(false); + vi.mocked(installDaemonService) + .mockReset() + .mockResolvedValue({ installed: false, reason: "mocked" }); + // Reset too, or call counts leak across tests and "was never asked for sudo" + // silently passes on history from an earlier one. + vi.mocked(primeElevation).mockReset().mockReturnValue(true); }); describe("configure-wizard pure builders", () => { @@ -370,3 +403,256 @@ describe("scope-aware assistant selection", () => { for (const id of clis) expect(getIntegration(id).scopes).toContain("project"); }); }); + +describe("configure-wizard daemon integration", () => { + function globalConfigPath(): string { + return resolve(fileHome, ".failproofai", "policies-config.json"); + } + function readGlobalConfig(): Record { + const path = globalConfigPath(); + if (!existsSync(path)) return {}; + return JSON.parse(readFileSync(path, "utf8")) as Record; + } + + // fileHome (and therefore the global config file) is shared across every + // test in this file — a prior test's daemonConfigured: true write would + // otherwise leak into a later test that expects it to be absent. + beforeEach(() => { + rmSync(globalConfigPath(), { force: true }); + }); + + it("installs the daemon and marks it configured when step 0 asks for it", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") // 0 — daemon + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("apply"); // review + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).toHaveBeenCalledTimes(1); + expect(readGlobalConfig().daemonConfigured).toBe(true); + }); + + it("primes sudo at step 0, before any other question is asked", async () => { + // Ordering is the whole point: sudo must prompt on a clean terminal. Once + // a TUI screen has been drawn, the password prompt is invisible and the + // typed characters land in a redrawn frame. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + const order: string[] = []; + vi.mocked(primeElevation).mockImplementation(() => { + order.push("sudo"); + return true; + }); + const answers = ["install", "user", "apply"]; + vi.mocked(selectOne).mockImplementation(async () => { + order.push("prompt"); + return answers[order.filter((o) => o === "prompt").length - 1] as never; + }); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(order[0]).toBe("prompt"); // the daemon question itself + expect(order[1]).toBe("sudo"); // then the password, before anything else + }); + + it("carries on without a daemon when sudo cannot be obtained", async () => { + // A refused password must not cost the user the rest of their setup. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(primeElevation).mockReturnValue(false); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("never attempts daemon install when step 0 is declined", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(selectOne) + .mockResolvedValueOnce("skip") // 0 — daemon: not now + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(primeElevation).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("installs the daemon at project scope too — it is machine-level, not per-project", async () => { + // Deliberately NOT gated on the scope any more: one daemon serves every + // project on the machine, and step 0 is where the user consents to it. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("project") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).toHaveBeenCalledTimes(1); + }); + + it("never attempts daemon install when the platform is unsupported, even at user scope", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + // No step 0 on an unsupported platform — the daemon question never renders. + vi.mocked(selectOne).mockResolvedValueOnce("user").mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("sends a classification, never the raw reason, in the daemon-install telemetry", async () => { + // The raw reason is an errno message built from homedir()-derived paths + // ("EACCES: permission denied, open '/home//.config/systemd/...'"), + // so it carries the OS username and the local filesystem layout. Only a + // bounded code may leave the machine; the full text stays in the local log. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + reason: "EACCES: permission denied, open '/home/somebody/.config/systemd/user/failproofaid.service'", + }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + // Last, not first: mock calls accumulate across the tests in this block. + const call = vi + .mocked(trackHookEvent) + .mock.calls.filter(([, event]) => event === "configure_daemon_install") + .at(-1); + expect(call).toBeDefined(); + const props = call![2] as Record; + expect(props.installed).toBe(false); + expect(props.reason).toBe("service_manager_error"); + expect(JSON.stringify(props)).not.toContain("somebody"); + }); + + it("classifies each daemon-install failure mode into a fixed set of codes", () => { + expect(classifyDaemonInstallFailure("failproofaid is not supported on win32 yet")).toBe( + "unsupported_platform", + ); + expect( + classifyDaemonInstallFailure("failproofaid binary not found for this platform (no matching …)"), + ).toBe("binary_not_found"); + expect( + classifyDaemonInstallFailure("failproofaid was installed but did not reach a running state within 5000ms"), + ).toBe("did_not_start"); + expect(classifyDaemonInstallFailure("ENOSPC: no space left on device, write")).toBe( + "service_manager_error", + ); + expect(classifyDaemonInstallFailure(undefined)).toBe("unknown"); + }); + + it("classifies the download and elevation failure modes distinctly", () => { + // These three have different remedies — retry the network, re-run under + // sudo, or treat a bad digest as a supply-chain signal — so collapsing + // them into service_manager_error would make the telemetry useless for + // telling an offline laptop from a tampered artifact. + expect( + classifyDaemonInstallFailure( + "root privileges are required to install the failproofaid system service, and sudo is not available without a password. Run: sudo failproofai config", + ), + ).toBe("needs_root"); + expect( + classifyDaemonInstallFailure("checksum mismatch for failproofaid-linux-x64.gz (expected ab…, got cd…)"), + ).toBe("checksum_mismatch"); + expect(classifyDaemonInstallFailure("daemon downloads are disabled (FAILPROOFAI_NO_DOWNLOAD)")).toBe( + "downloads_disabled", + ); + expect( + classifyDaemonInstallFailure("failed to download failproofaid v1.0.0 for linux-x64: fetch failed"), + ).toBe("download_failed"); + }); + + it("does not fail the wizard or mark daemonConfigured when daemon install fails", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + reason: "no failproofaid binary found", + }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + const message = vi.mocked(outro).mock.calls[0]![0]; + expect(message).not.toContain("background daemon enabled"); + }); + + it("mentions the background daemon in the outro message only on a successful install", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + const message = vi.mocked(outro).mock.calls[0]![0]; + expect(message).toContain("background daemon enabled"); + }); + + it("reviewLines shows the daemon line only when step 0 asked for it", () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + const withDaemon = reviewLines({ + scope: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + installDaemon: true, + }).join("\n"); + expect(withDaemon).toContain("Daemon"); + expect(withDaemon).toContain("failproofaid"); + + // Promising a service the apply will not install is the failure mode here. + const declined = reviewLines({ + scope: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + installDaemon: false, + }).join("\n"); + expect(declined).not.toContain("Daemon"); + + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + const unsupported = reviewLines({ + scope: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + }).join("\n"); + expect(unsupported).not.toContain("Daemon"); + }); +}); diff --git a/__tests__/hooks/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts new file mode 100644 index 00000000..d87c832a --- /dev/null +++ b/__tests__/hooks/daemon-client.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment node +/** + * Tests daemon-client.ts against a REAL net.Server speaking the actual + * length-prefixed framing — not a mock of node:net. The point is catching a + * bug in daemon-client.ts's OWN framing/parsing code, which a mocked socket + * cannot do (see the plan's Verification section). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createServer, type Server, type Socket } from "node:net"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogInfo: vi.fn(), + hookLogWarn: vi.fn(), + hookLogError: vi.fn(), +})); + +function encodeFrame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +/** Reads exactly one length-prefixed frame off a connected socket. */ +function readFrame(socket: Socket): Promise> { + return new Promise((resolvePromise, reject) => { + let buf = Buffer.alloc(0); + let declaredLen: number | null = null; + const onData = (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + if (declaredLen === null) { + if (buf.length < 4) return; + declaredLen = buf.readUInt32BE(0); + buf = buf.subarray(4); + } + if (buf.length < declaredLen) return; + socket.off("data", onData); + resolvePromise(JSON.parse(buf.subarray(0, declaredLen).toString("utf8"))); + }; + socket.on("data", onData); + socket.on("error", reject); + }); +} + +describe("hooks/daemon-client", () => { + let tmpDir: string; + let socketPath: string; + let server: Server | null; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "fpai-daemon-client-test-")); + socketPath = join(tmpDir, "test.sock"); + server = null; + process.env.FAILPROOFAI_DAEMON_SOCKET = socketPath; + vi.resetModules(); + }); + + afterEach(async () => { + delete process.env.FAILPROOFAI_DAEMON_SOCKET; + if (server) { + await new Promise((r) => server!.close(() => r())); + } + rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + /** Starts a real Unix-socket server driven by a per-connection handler. */ + async function startServer(onConnection: (socket: Socket) => void): Promise { + server = createServer(onConnection); + await new Promise((resolvePromise) => server!.listen(socketPath, resolvePromise)); + } + + it("returns the parsed result on a real hookResult response", async () => { + await startServer(async (socket) => { + const req = await readFrame(socket); + expect(req.type).toBe("hook"); + expect(req.protocolVersion).toBe(1); + expect(req.hookEvent).toBe("PreToolUse"); + expect(req.cli).toBe("claude"); + socket.end( + encodeFrame({ + type: "hookResult", + protocolVersion: 1, + exitCode: 0, + stdout: "", + stderr: "", + }), + ); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ + hookEvent: "PreToolUse", + cli: "claude", + stdin: "{}", + cwd: "/repo", + }); + expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); + }); + + it("round-trips a deny response with real stdout/stderr content", async () => { + await startServer(async (socket) => { + await readFrame(socket); + socket.end( + encodeFrame({ + type: "hookResult", + protocolVersion: 1, + exitCode: 2, + stdout: "", + stderr: "blocked: sudo is not allowed", + }), + ); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toEqual({ exitCode: 2, stdout: "", stderr: "blocked: sudo is not allowed" }); + }); + + it("returns null when the daemon sends an error-type message", async () => { + await startServer(async (socket) => { + await readFrame(socket); + socket.end(encodeFrame({ type: "error", protocolVersion: 1, message: "daemon unreachable" })); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "Stop", cli: "codex", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("returns null on a protocol-version mismatch", async () => { + await startServer(async (socket) => { + await readFrame(socket); + socket.end( + encodeFrame({ type: "hookResult", protocolVersion: 999, exitCode: 0, stdout: "", stderr: "" }), + ); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("returns null on a well-formed but wrong-shape response (no partial trust)", async () => { + await startServer(async (socket) => { + await readFrame(socket); + // Right protocol version, right general shape, but missing exitCode. + socket.end(encodeFrame({ type: "hookResult", protocolVersion: 1, stdout: "", stderr: "" })); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("returns null immediately when no socket file exists at all", async () => { + // No server started — socketPath was never bound. + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const start = Date.now(); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + const elapsedMs = Date.now() - start; + expect(result).toBeNull(); + // ENOENT/ECONNREFUSED on a nonexistent socket is a kernel-level rejection, + // not a real network timeout — should resolve in well under the 150ms + // attempt budget, not wait for it to expire. + expect(elapsedMs).toBeLessThan(100); + }); + + it("waits out a slow evaluation on a connected daemon rather than denying it", async () => { + // The connect budget answers "is anything listening"; once connected, + // the budget has to cover the daemon's whole evaluation. On a + // daemon-configured machine a timeout here is a DENY, not a fallback — + // so budgeting an evaluation at connect speed turned a slow-but-correct + // verdict into an intermittent block of a legitimate tool call. + await startServer(async (socket) => { + await readFrame(socket); + setTimeout(() => { + socket.end( + encodeFrame({ type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "ok", stderr: "" }), + ); + }, 600); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const start = Date.now(); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toEqual({ exitCode: 0, stdout: "ok", stderr: "" }); + expect(Date.now() - start).toBeGreaterThanOrEqual(500); + }); + + it("does not resolve at the connect budget when the server is connected but silent", async () => { + let serverSocket: Socket | null = null; + await startServer(async (socket) => { + serverSocket = socket; + await readFrame(socket); + // Deliberately never write a response. + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + let settled = false; + const pending = tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }).then((r) => { + settled = true; + return r; + }); + + await new Promise((r) => setTimeout(r, 800)); + expect(settled).toBe(false); + + // A severed connection is a different signal from a slow one, and still + // resolves immediately — the client never hangs on a daemon that went away. + (serverSocket as Socket | null)?.destroy(); + await expect(pending).resolves.toBeNull(); + }); + + it("returns null on a garbage (non-JSON) frame body", async () => { + await startServer(async (socket) => { + await readFrame(socket); + const body = Buffer.from("not json", "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + socket.end(Buffer.concat([header, body])); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("skips the attempt entirely on win32, never touching the socket", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const start = Date.now(); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + const elapsedMs = Date.now() - start; + expect(result).toBeNull(); + expect(elapsedMs).toBeLessThan(20); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + describe("isDaemonConfigured", () => { + let globalConfigDir: string; + let originalHome: string | undefined; + + beforeEach(() => { + globalConfigDir = mkdtempSync(join(tmpdir(), "fpai-daemon-configured-test-")); + originalHome = process.env.HOME; + process.env.HOME = globalConfigDir; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + rmSync(globalConfigDir, { recursive: true, force: true }); + }); + + it("is false when no global config file exists", async () => { + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(false); + }); + + it("is true when the global config has daemonConfigured: true", async () => { + const dir = join(globalConfigDir, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], daemonConfigured: true }), + ); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(true); + }); + + it("is false when daemonConfigured is explicitly false", async () => { + const dir = join(globalConfigDir, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], daemonConfigured: false }), + ); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(false); + }); + + it("is false and does not throw when the config file is malformed JSON", async () => { + const dir = join(globalConfigDir, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "policies-config.json"), "{ not valid json"); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(false); + }); + }); +}); diff --git a/__tests__/hooks/daemon-download.test.ts b/__tests__/hooks/daemon-download.test.ts new file mode 100644 index 00000000..c58c986f --- /dev/null +++ b/__tests__/hooks/daemon-download.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment node +/** + * The daemon binary reaches users through exactly one channel now — the + * GitHub Release for this CLI's own version — so this file covers that + * channel end to end against a real local HTTP server rather than a mocked + * `fetch`: URL construction, checksum verification, decompression, and the + * atomic install. What it is guarding is an executable that a service manager + * will run at login, so every rejection path asserts that nothing was left on + * disk. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, readdirSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { version } from "../../package.json"; + +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogWarn: vi.fn(), + hookLogInfo: vi.fn(), +})); + +const BINARY = Buffer.from("#!/bin/sh\necho failproofaid " + version + "\n"); +const GZIPPED = gzipSync(BINARY); +const DIGEST = createHash("sha256").update(GZIPPED).digest("hex"); + +/** Serves the four assets + SHA256SUMS the release job publishes. */ +function startServer(options: { manifest?: string; assetStatus?: number } = {}): Promise<{ + url: string; + close: () => Promise; + server: Server; +}> { + const manifest = options.manifest ?? `${DIGEST} failproofaid-linux-x64.gz\n`; + const server = createServer((req, res) => { + if (req.url === `/v${version}/SHA256SUMS`) { + res.writeHead(200).end(manifest); + } else if (req.url === `/v${version}/failproofaid-linux-x64.gz`) { + if (options.assetStatus && options.assetStatus !== 200) { + res.writeHead(options.assetStatus).end("nope"); + } else { + res.writeHead(200).end(GZIPPED); + } + } else { + res.writeHead(404).end("not found"); + } + }); + return new Promise((done) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + done({ + url: `http://127.0.0.1:${port}`, + server, + close: () => new Promise((closed) => server.close(() => closed())), + }); + }); + }); +} + +describe("hooks/daemon-download", () => { + const originalHome = process.env.HOME; + const originalBase = process.env.FAILPROOFAI_DAEMON_BASE_URL; + const originalNoDownload = process.env.FAILPROOFAI_NO_DOWNLOAD; + let home: string; + + beforeEach(() => { + vi.resetModules(); + // Never touch the real ~/.failproofai — these tests install executables. + home = mkdtempSync(resolve(tmpdir(), "fpai-daemon-download-")); + process.env.HOME = home; + delete process.env.FAILPROOFAI_NO_DOWNLOAD; + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalBase !== undefined) process.env.FAILPROOFAI_DAEMON_BASE_URL = originalBase; + else delete process.env.FAILPROOFAI_DAEMON_BASE_URL; + if (originalNoDownload !== undefined) process.env.FAILPROOFAI_NO_DOWNLOAD = originalNoDownload; + else delete process.env.FAILPROOFAI_NO_DOWNLOAD; + }); + + describe("URL construction", () => { + it("pins the asset URL to this package's own version", async () => { + delete process.env.FAILPROOFAI_DAEMON_BASE_URL; + const { daemonAssetUrl, checksumsUrl } = await import("../../src/hooks/daemon-download"); + expect(daemonAssetUrl("linux-x64")).toBe( + `https://github.com/FailproofAI/failproofai/releases/download/v${version}/failproofaid-linux-x64.gz`, + ); + expect(checksumsUrl()).toContain(`/v${version}/SHA256SUMS`); + }); + + it("names a distinct asset for every supported platform", async () => { + const { daemonAssetUrl } = await import("../../src/hooks/daemon-download"); + const urls = (["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"] as const).map((k) => + daemonAssetUrl(k), + ); + expect(new Set(urls).size).toBe(4); + }); + + it("honours a mirror base URL and tolerates a trailing slash", async () => { + process.env.FAILPROOFAI_DAEMON_BASE_URL = "https://mirror.internal/failproofai/"; + const { daemonAssetUrl } = await import("../../src/hooks/daemon-download"); + expect(daemonAssetUrl("darwin-arm64")).toBe( + `https://mirror.internal/failproofai/v${version}/failproofaid-darwin-arm64.gz`, + ); + }); + + it("versions the installed path so an upgrade never overwrites a running daemon", async () => { + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + expect(installedBinaryPath()).toBe(resolve(home, ".failproofai", "bin", `failproofaid-${version}`)); + expect(installedBinaryPath("9.9.9")).toContain("failproofaid-9.9.9"); + }); + }); + + describe("digestFor", () => { + it("reads a sha256sum manifest, including the binary-mode marker", async () => { + const { digestFor } = await import("../../src/hooks/daemon-download"); + const manifest = [ + `${"a".repeat(64)} failproofaid-linux-x64.gz`, + `${"b".repeat(64)} *failproofaid-darwin-arm64.gz`, + ].join("\n"); + expect(digestFor(manifest, "failproofaid-linux-x64.gz")).toBe("a".repeat(64)); + expect(digestFor(manifest, "failproofaid-darwin-arm64.gz")).toBe("b".repeat(64)); + }); + + it("returns null for an asset the manifest does not cover", async () => { + const { digestFor } = await import("../../src/hooks/daemon-download"); + expect(digestFor(`${"a".repeat(64)} other.gz`, "failproofaid-linux-x64.gz")).toBeNull(); + }); + }); + + describe("downloadFailproofaidBinary", () => { + it("downloads, verifies, decompresses and installs the binary as executable", async () => { + const server = await startServer(); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + + expect(result.error).toBeUndefined(); + expect(result.path).toBe(installedBinaryPath()); + expect(readFileSync(result.path!)).toEqual(BINARY); + // 0o755: the service manager execs this path directly. + expect(statSync(result.path!).mode & 0o777).toBe(0o755); + // The install is a rename, so no temp file survives it. + expect(readdirSync(resolve(home, ".failproofai", "bin"))).toEqual([`failproofaid-${version}`]); + } finally { + await server.close(); + } + }); + + it("is idempotent — an installed binary is returned without a fetch", async () => { + const server = await startServer(); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary } = await import("../../src/hooks/daemon-download"); + const first = await downloadFailproofaidBinary("linux-x64"); + await server.close(); + // Server is down; a second call must not need it. + const second = await downloadFailproofaidBinary("linux-x64"); + expect(second.path).toBe(first.path); + expect(second.error).toBeUndefined(); + } finally { + server.server.close(); + } + }); + + it("refuses to install a binary whose checksum does not match", async () => { + const server = await startServer({ manifest: `${"f".repeat(64)} failproofaid-linux-x64.gz\n` }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath, daemonBinaryDir } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + + expect(result.path).toBeUndefined(); + expect(result.error).toContain("checksum mismatch"); + expect(existsSync(installedBinaryPath())).toBe(false); + // Nothing half-written left behind either. + expect(existsSync(daemonBinaryDir()) ? readdirSync(daemonBinaryDir()) : []).toEqual([]); + } finally { + await server.close(); + } + }); + + it("refuses an asset the manifest does not cover at all", async () => { + const server = await startServer({ manifest: `${DIGEST} failproofaid-darwin-x64.gz\n` }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.error).toContain("no entry for failproofaid-linux-x64.gz"); + expect(existsSync(installedBinaryPath())).toBe(false); + } finally { + await server.close(); + } + }); + + it("reports a failed fetch without throwing and installs nothing", async () => { + const server = await startServer({ assetStatus: 404 }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.error).toContain("failed to download"); + expect(result.error).toContain("404"); + expect(existsSync(installedBinaryPath())).toBe(false); + } finally { + await server.close(); + } + }); + + it("does not reach the network at all when downloads are disabled", async () => { + // No server: an air-gapped box must fail with a clear reason rather than + // hang on a connection to github.com. + process.env.FAILPROOFAI_DAEMON_BASE_URL = "http://127.0.0.1:1/never"; + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + const { downloadFailproofaidBinary } = await import("../../src/hooks/daemon-download"); + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.error).toContain("downloads are disabled"); + expect(result.path).toBeUndefined(); + }); + + it("still returns an already-installed binary when downloads are disabled", async () => { + // Disabling downloads must not disable the daemon on a machine that + // already has one — the flag gates fetching, not running. + const { installedBinaryPath, downloadFailproofaidBinary } = await import( + "../../src/hooks/daemon-download" + ); + mkdirSync(resolve(home, ".failproofai", "bin"), { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync(installedBinaryPath(), BINARY); + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.path).toBe(installedBinaryPath()); + }); + }); +}); diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts new file mode 100644 index 00000000..0e0bf684 --- /dev/null +++ b/__tests__/hooks/daemon-service.test.ts @@ -0,0 +1,504 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import { resolve } from "node:path"; + +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogWarn: vi.fn(), + hookLogInfo: vi.fn(), +})); + +describe("hooks/daemon-service", () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + const originalBinaryEnv = process.env.FAILPROOFAI_DAEMON_BINARY; + const originalPackageRootEnv = process.env.FAILPROOFAI_PACKAGE_ROOT; + const originalWorkerCmdEnv = process.env.FAILPROOFAI_WORKER_CMD; + const originalHome = process.env.HOME; + const originalNoDownload = process.env.FAILPROOFAI_NO_DOWNLOAD; + // The download channel installs under `$HOME/.failproofai/bin`, so these + // tests point HOME at a scratch dir: a developer machine that really has a + // daemon installed would otherwise turn "resolves to null" into a flake. + let home: string; + + function setPlatform(platform: string) { + Object.defineProperty(process, "platform", { value: platform }); + } + function setArch(arch: string) { + Object.defineProperty(process, "arch", { value: arch }); + } + + /** + * Points HOME at a scratch dir for the tests that exercise binary + * resolution. Deliberately opt-in per test rather than a blanket + * `beforeEach`: the real-systemd lifecycle tests further down install an + * actual user unit, which only works under the session's real HOME. + */ + function useScratchHome(): string { + home = mkdtempSync(resolve(tmpdir(), "fpai-daemon-service-")); + process.env.HOME = home; + return home; + } + + beforeEach(() => { + vi.resetModules(); + home = ""; + // No test in this file may reach the network. installDaemonService() + // downloads the daemon when nothing is resolvable, so without this a + // test asserting "no binary" quietly fetches one from the real release + // — which is what broke CI while passing locally. The download path + // itself is covered in daemon-download.test.ts against a local server. + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + }); + + afterEach(() => { + if (home) rmSync(home, { recursive: true, force: true }); + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalNoDownload !== undefined) process.env.FAILPROOFAI_NO_DOWNLOAD = originalNoDownload; + else delete process.env.FAILPROOFAI_NO_DOWNLOAD; + Object.defineProperty(process, "platform", { value: originalPlatform }); + Object.defineProperty(process, "arch", { value: originalArch }); + if (originalBinaryEnv !== undefined) process.env.FAILPROOFAI_DAEMON_BINARY = originalBinaryEnv; + else delete process.env.FAILPROOFAI_DAEMON_BINARY; + if (originalPackageRootEnv !== undefined) process.env.FAILPROOFAI_PACKAGE_ROOT = originalPackageRootEnv; + else delete process.env.FAILPROOFAI_PACKAGE_ROOT; + if (originalWorkerCmdEnv !== undefined) process.env.FAILPROOFAI_WORKER_CMD = originalWorkerCmdEnv; + else delete process.env.FAILPROOFAI_WORKER_CMD; + }); + + describe("isDaemonSupportedPlatform", () => { + it("is true on linux", async () => { + setPlatform("linux"); + const { isDaemonSupportedPlatform } = await import("../../src/hooks/daemon-service"); + expect(isDaemonSupportedPlatform()).toBe(true); + }); + + it("is true on darwin", async () => { + setPlatform("darwin"); + const { isDaemonSupportedPlatform } = await import("../../src/hooks/daemon-service"); + expect(isDaemonSupportedPlatform()).toBe(true); + }); + + it("is false on win32", async () => { + setPlatform("win32"); + const { isDaemonSupportedPlatform } = await import("../../src/hooks/daemon-service"); + expect(isDaemonSupportedPlatform()).toBe(false); + }); + }); + + describe("resolveFailproofaidBinaryPath", () => { + it("returns the FAILPROOFAI_DAEMON_BINARY override verbatim, regardless of platform", async () => { + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBe("/usr/bin/sleep infinity"); + }); + + it("returns null on win32 with nothing else configured", async () => { + // Scratch HOME: a machine that really has a daemon installed (a CI + // runner that just ran the lifecycle tests, a developer laptop) would + // otherwise resolve that binary and turn this into a flake. + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("win32"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBeNull(); + }); + + it("returns null when nothing has been downloaded and no dev build is present", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + process.env.FAILPROOFAI_PACKAGE_ROOT = "/nonexistent/package/root"; + setPlatform("linux"); + setArch("x64"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBeNull(); + }); + + it("finds the binary downloaded for this version under ~/.failproofai/bin", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("x64"); + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + mkdirSync(resolve(home, ".failproofai", "bin"), { recursive: true }); + writeFileSync(installedBinaryPath(), "#!/bin/sh\n"); + + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBe(installedBinaryPath()); + }); + + it("never fetches — resolution is a disk check, so the hook path cannot block on the network", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("x64"); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("finds a locally-built dev binary under target/release relative to the package root", async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + // The real repo's own target/{release,debug}/failproofaid — built by + // the Rust test suite / a local `cargo build` earlier in this session. + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve(__dirname, "..", ".."); + setPlatform("linux"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + const result = resolveFailproofaidBinaryPath(); + // Not asserting a specific outcome beyond "doesn't throw and returns a + // sensible type" here would be too weak — but whether target/ has been + // built depends on test execution order across files sharing state in + // this repo, so assert the *shape* of a real hit without depending on + // build state: either null, or an absolute path that actually exists. + if (result !== null) { + expect(existsSync(result)).toBe(true); + expect(result).toContain("failproofaid"); + } + }); + }); + + describe("ensureFailproofaidBinary", () => { + it("returns an already-resolved binary without downloading", async () => { + process.env.FAILPROOFAI_DAEMON_BINARY = "/opt/failproofaid"; + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + + await expect(ensureFailproofaidBinary()).resolves.toEqual({ path: "/opt/failproofaid" }); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("reports an unsupported architecture rather than attempting a download", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("ppc64"); + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + + const result = await ensureFailproofaidBinary(); + expect(result.path).toBeUndefined(); + expect(result.reason).toContain("no prebuilt binary"); + }); + + it("surfaces the download failure verbatim for the local log", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("x64"); + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + try { + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + const result = await ensureFailproofaidBinary(); + expect(result.reason).toContain("downloads are disabled"); + } finally { + delete process.env.FAILPROOFAI_NO_DOWNLOAD; + } + }); + }); + + describe("system-scope service definition", () => { + it("names the unit per user so a second install cannot steal the first's service", async () => { + setPlatform("linux"); + const { daemonServiceFilePath, daemonStatusCommand } = await import("../../src/hooks/daemon-service"); + const user = userInfo().username; + + expect(daemonServiceFilePath()).toBe(`/etc/systemd/system/failproofaid@${user}.service`); + expect(daemonStatusCommand()).toBe(`systemctl status failproofaid@${user}.service`); + }); + + it("namespaces the launchd label per user too — a plist is just as user-specific", async () => { + // A shared label meant the second Mac user's install overwrote the + // first's daemon (UserName, ExecStart under their ~/.failproofai/bin, + // their log paths) and their uninstall deleted it. + setPlatform("darwin"); + const { daemonServiceFilePath, daemonStatusCommand } = await import("../../src/hooks/daemon-service"); + const user = userInfo().username; + + expect(daemonServiceFilePath()).toBe( + `/Library/LaunchDaemons/ai.failproof.failproofaid.${user}.plist`, + ); + expect(daemonStatusCommand()).toContain(`system/ai.failproof.failproofaid.${user}`); + }); + + it("writes a unit that runs as the user, starts at boot, and knows where HOME is", async () => { + useScratchHome(); + setPlatform("linux"); + const { systemdUnitContents } = await import("../../src/hooks/daemon-service"); + const unit = systemdUnitContents("/opt/failproofaid", null); + + expect(unit).toContain(`User=${userInfo().username}`); + expect(unit).toContain("ExecStart=/opt/failproofaid"); + // WantedBy=multi-user.target is the whole point of the system unit: + // default.target only starts with a user session, which is what made + // the daemon die on logout and never come back after a reboot. + expect(unit).toContain("WantedBy=multi-user.target"); + // The daemon refuses to start without HOME, and a system unit gets no + // login environment, so this must be explicit rather than inherited. + expect(unit).toContain(`Environment="HOME=${process.env.HOME}"`); + }); + + it("bakes an absolute runtime into the worker command", async () => { + // A bare `node` resolves for the wizard and then fails inside a system + // unit whose PATH never includes ~/.nvm/versions/node/*/bin — silently, + // and only on the machines least likely to notice. + useScratchHome(); + delete process.env.FAILPROOFAI_WORKER_CMD; + // The repo's own dist/worker.mjs — built by `bun run build`, which the + // test job runs before this suite. + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve(__dirname, "..", ".."); + setPlatform("linux"); + const { resolveWorkerCommand, systemdUnitContents } = await import("../../src/hooks/daemon-service"); + + const workerCmd = resolveWorkerCommand(); + if (workerCmd) { + expect(workerCmd).toContain(process.execPath); + expect(workerCmd).not.toMatch(/^node /); + // Shell-quoted, because the daemon runs this through `sh -c`: an + // unquoted `/Users/First Last/...` splits on its space and the worker + // never starts. Ordinary on macOS, and more likely since execPath + // (home-derived) replaced a bare `node`. + expect(workerCmd).toBe(`'${process.execPath}' '${resolve(process.env.FAILPROOFAI_PACKAGE_ROOT!, "dist", "worker.mjs")}'`); + // Environment= values containing a space must be quoted or systemd + // rejects the unit — and this value always contains one. + expect(systemdUnitContents("/opt/failproofaid", workerCmd)).toContain( + `Environment="FAILPROOFAI_WORKER_CMD=${workerCmd}"`, + ); + } + }); + + // Meaningless as root, where elevation always succeeds. + it.skipIf(typeof process.getuid === "function" && process.getuid() === 0)( + "refuses to half-install when it cannot elevate, and says exactly what to run", + async () => { + useScratchHome(); + process.env.FAILPROOFAI_DAEMON_BINARY = "/opt/failproofaid"; + setPlatform("linux"); + // Every privileged command fails the way a machine without + // passwordless sudo fails. Nothing may be written, and the reason + // has to be actionable rather than an errno. + vi.doMock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync: (cmd: string) => { + if (cmd === "sudo") throw new Error("sudo: a password is required"); + throw new Error(`nothing else should run before elevation succeeds, but got: ${cmd}`); + }, + })); + try { + vi.resetModules(); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + const result = await installDaemonService(); + + expect(result.installed).toBe(false); + expect(result.reason).toContain("root privileges are required"); + expect(result.reason).toContain("systemctl enable --now"); + expect(existsSync(`/etc/systemd/system/failproofaid@${userInfo().username}.service`)).toBe(false); + } finally { + vi.doUnmock("node:child_process"); + vi.resetModules(); + } + }, + ); + }); + + describe("daemonServiceStatus", () => { + it("is unsupported-platform on win32", async () => { + setPlatform("win32"); + const { daemonServiceStatus } = await import("../../src/hooks/daemon-service"); + expect(daemonServiceStatus()).toBe("unsupported-platform"); + }); + }); + + // Real systemd integration — the service is system-scope now, so this + // needs root or passwordless sudo (CI runners have it; a locked-down + // laptop may not). Skips loudly rather than silently passing when it + // can't run, per the plan's "no silent caps" verification guidance. + const canInstallSystemService = (() => { + if (process.platform !== "linux") return false; + try { + execFileSync("systemctl", ["--version"], { stdio: "ignore" }); + } catch { + return false; + } + if (typeof process.getuid === "function" && process.getuid() === 0) return true; + try { + execFileSync("sudo", ["-n", "true"], { stdio: "ignore" }); + return true; + } catch { + return false; + } + })(); + + const sudoPrefix = typeof process.getuid === "function" && process.getuid() === 0 ? [] : ["sudo", "-n"]; + const run = (args: string[]) => + execFileSync(sudoPrefix[0] ?? args[0], sudoPrefix.length ? [...sudoPrefix.slice(1), ...args] : args.slice(1), { + stdio: "ignore", + }); + + (canInstallSystemService ? describe : describe.skip)( + "real systemd system-scope lifecycle (linux only, requires root or passwordless sudo)", + () => { + const unitName = `failproofaid@${userInfo().username}.service`; + const unitPath = resolve("/etc/systemd/system", unitName); + let preexistingUnit: string | null = null; + + beforeEach(() => { + // Never clobber a real installed daemon if this sandbox happens to + // have one — capture and restore it rather than assuming a clean + // slate. + preexistingUnit = existsSync(unitPath) ? readFileSync(unitPath, "utf8") : null; + }); + + afterEach(async () => { + setPlatform("linux"); + const { uninstallDaemonService } = await import("../../src/hooks/daemon-service"); + await uninstallDaemonService(); + if (preexistingUnit !== null) { + const staging = resolve(tmpdir(), `failproofaid-restore-${process.pid}`); + writeFileSync(staging, preexistingUnit, "utf8"); + try { + run(["install", "-m", "0644", staging, unitPath]); + run(["systemctl", "daemon-reload"]); + } catch { + /* best-effort restore */ + } + rmSync(staging, { force: true }); + } + }); + + it("installs a real user unit, reports it running, then fully removes it on uninstall", async () => { + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + setPlatform("linux"); + const { installDaemonService, daemonServiceStatus, uninstallDaemonService } = await import( + "../../src/hooks/daemon-service" + ); + + expect(daemonServiceStatus()).toBe("not-installed"); + + const result = await installDaemonService(); + expect(result).toEqual({ installed: true }); + expect(existsSync(unitPath)).toBe(true); + expect(readFileSync(unitPath, "utf8")).toContain("ExecStart=/usr/bin/sleep infinity"); + + // systemd needs a beat to actually transition the unit to active + // after `enable --now`. + await new Promise((r) => setTimeout(r, 300)); + expect(daemonServiceStatus()).toBe("running"); + + await uninstallDaemonService(); + expect(existsSync(unitPath)).toBe(false); + expect(daemonServiceStatus()).toBe("not-installed"); + }); + + it("writes FAILPROOFAI_WORKER_CMD into the unit's environment and systemd still accepts it", async () => { + // Caught by a real Docker clean-install run: the daemon's own + // built-in worker fallback is a *relative* path (dist/worker.mjs), + // which only resolves when the daemon happens to be started from + // the npm package's own directory — never true for a real + // service-managed daemon, which systemd starts from an arbitrary + // cwd. This is the fix: an absolute worker command threaded through + // as an environment line in the unit itself. The real assertion + // here isn't just string content — it's that `systemctl enable + // --now` (called by installDaemonService) doesn't choke on the + // quoted Environment= syntax. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + process.env.FAILPROOFAI_WORKER_CMD = "node /some/absolute/path/worker.mjs"; + setPlatform("linux"); + const { installDaemonService, daemonServiceStatus } = await import("../../src/hooks/daemon-service"); + + const result = await installDaemonService(); + expect(result).toEqual({ installed: true }); + const contents = readFileSync(unitPath, "utf8"); + expect(contents).toContain('Environment="FAILPROOFAI_WORKER_CMD=node /some/absolute/path/worker.mjs"'); + + await new Promise((r) => setTimeout(r, 300)); + expect(daemonServiceStatus()).toBe("running"); + }); + + it("re-installing replaces the unit file with a new binary path", async () => { + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + await installDaemonService(); + expect(readFileSync(unitPath, "utf8")).toContain("ExecStart=/usr/bin/sleep infinity"); + + // A *genuinely* different command. Re-installing with a trailing-space + // variant of the first one proves nothing: the assertion's needle is + // still a substring of the original unit, so the test would pass even + // if the second install were a no-op. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep 3600"; + await installDaemonService(); + const rewritten = readFileSync(unitPath, "utf8"); + expect(rewritten).toContain("ExecStart=/usr/bin/sleep 3600"); + expect(rewritten).not.toContain("infinity"); + }); + + it("does not report installed when the service never stays running", async () => { + // A "daemon" that exits the moment it starts: systemd accepts the + // job and `enable --now` exits 0, but nothing is left running. + // Reporting success here is what lets the wizard set + // `daemonConfigured`, after which every hook event on the machine + // fails closed against a daemon that does not exist. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep 0"; + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + + const result = await installDaemonService(); + expect(result.installed).toBe(false); + expect(result.reason).toContain("did not reach a running state"); + }, 20_000); + + it("uninstall clears the daemonConfigured marker", async () => { + setPlatform("linux"); + const { installDaemonService, uninstallDaemonService, setDaemonConfigured } = await import( + "../../src/hooks/daemon-service" + ); + const { getConfigPathForScope } = await import("../../src/hooks/hooks-config"); + const configPath = getConfigPathForScope("user"); + const preexisting = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + + try { + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + expect((await installDaemonService()).installed).toBe(true); + setDaemonConfigured(true); + expect(JSON.parse(readFileSync(configPath, "utf8")).daemonConfigured).toBe(true); + + // Without this, removing the service leaves the machine failing + // closed forever against a socket that is gone. + await uninstallDaemonService(); + expect(JSON.parse(readFileSync(configPath, "utf8")).daemonConfigured).toBeUndefined(); + } finally { + if (preexisting !== null) writeFileSync(configPath, preexisting, "utf8"); + else rmSync(configPath, { force: true }); + } + }, 20_000); + + it("installDaemonService fails cleanly when the binary cannot be resolved", async () => { + // Scratch HOME + downloads off, or "cannot be resolved" is a lie: + // install would reach ensureFailproofaidBinary, fetch the real + // release asset over the network, and succeed. It did exactly that + // on CI — passing locally only because this sandbox has no network + // access in the test environment. + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + const result = await installDaemonService(); + expect(result.installed).toBe(false); + expect(result.reason).toBeTruthy(); + expect(existsSync(unitPath)).toBe(false); + }); + }, + ); +}); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 072020aa..0083f302 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { handleHookEvent } from "../../src/hooks/handler"; +import { handleHookEvent, evaluateHookEvent } from "../../src/hooks/handler"; vi.mock("../../src/hooks/hooks-config", () => ({ readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: ["block-sudo"] })), @@ -263,6 +263,49 @@ describe("hooks/handler", () => { ); }); + it("does not block a deny decision on the telemetry POST when awaitTelemetryFlush is false (regression: warm-worker deny latency)", async () => { + // Caught via a real Docker daemon test: this call used to be + // unconditionally awaited regardless of opts.awaitTelemetryFlush, so + // every deny/instruct decision through the warm worker paid a live + // network round-trip (hundreds of ms, up to sendEvent's 5s abort + // timeout when PostHog is unreachable) before returning — blowing + // through daemon-client.ts's 150ms fail-closed budget on nearly every + // real block. A slow/never-resolving trackHookEvent must not delay + // evaluateHookEvent's return when the caller opts out via + // awaitTelemetryFlush:false (exactly what worker-server.ts passes). + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: '{"hookSpecificOutput":{"permissionDecision":"deny"}}', + stderr: "", + policyName: "block-sudo", + reason: "sudo blocked", + decision: "deny", + }); + const { trackHookEvent } = await import("../../src/hooks/hook-telemetry"); + let releaseTelemetry: () => void = () => {}; + vi.mocked(trackHookEvent).mockReturnValueOnce( + new Promise((resolve) => { + releaseTelemetry = () => resolve(undefined); + }), + ); + + const outcomePromise = evaluateHookEvent( + "PreToolUse", + "claude", + JSON.stringify({ tool_name: "Bash" }), + { awaitTelemetryFlush: false }, + ); + const raced = await Promise.race([ + outcomePromise.then(() => "resolved"), + new Promise((resolve) => setTimeout(() => resolve("timed-out"), 50)), + ]); + expect(raced).toBe("resolved"); + + releaseTelemetry(); + await outcomePromise; + }); + it("tags telemetry with cli=copilot when invoked with --cli copilot", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ diff --git a/__tests__/hooks/worker-server.test.ts b/__tests__/hooks/worker-server.test.ts new file mode 100644 index 00000000..780a8fc1 --- /dev/null +++ b/__tests__/hooks/worker-server.test.ts @@ -0,0 +1,353 @@ +// @vitest-environment node +/** + * End-to-end test of the warm worker's real server loop: real socket, real + * framing, real policy evaluation (not mocked) — proves the worker + * genuinely reuses the unchanged evaluation engine rather than a stub. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createConnection, type Socket } from "node:net"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/hook-telemetry", () => ({ + trackHookEvent: vi.fn(() => Promise.resolve()), + flushHookTelemetry: vi.fn(() => Promise.resolve()), +})); + +function encodeFrame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +function readFrame(socket: Socket): Promise> { + return new Promise((resolvePromise, reject) => { + let buf = Buffer.alloc(0); + let declaredLen: number | null = null; + const onData = (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + if (declaredLen === null) { + if (buf.length < 4) return; + declaredLen = buf.readUInt32BE(0); + buf = buf.subarray(4); + } + if (buf.length < declaredLen) return; + socket.off("data", onData); + resolvePromise(JSON.parse(buf.subarray(0, declaredLen).toString("utf8"))); + }; + socket.on("data", onData); + socket.on("error", reject); + }); +} + +/** + * Claude's PreToolUse deny contract is JSON on stdout at exit code 0 + * (`hookSpecificOutput.permissionDecision`), not a nonzero exit code — see + * policy-evaluator.ts. Parse it out rather than asserting on exitCode. + */ +function permissionDecisionOf(response: Record): string | undefined { + const stdout = response.stdout; + if (typeof stdout !== "string" || !stdout) return undefined; + try { + const parsed = JSON.parse(stdout) as { hookSpecificOutput?: { permissionDecision?: string } }; + return parsed.hookSpecificOutput?.permissionDecision; + } catch { + return undefined; + } +} + +async function sendRequest(socketPath: string, request: unknown): Promise> { + return new Promise((resolvePromise, reject) => { + const socket = createConnection({ path: socketPath }, () => { + socket.write(encodeFrame(request)); + }); + readFrame(socket) + .then((msg) => { + socket.end(); + resolvePromise(msg); + }) + .catch(reject); + socket.on("error", reject); + }); +} + +describe("hooks/worker-server (real socket, real evaluation)", () => { + let projectDir: string; + let workerSocketPath: string; + let server: import("node:net").Server; + + beforeEach(async () => { + projectDir = mkdtempSync(join(tmpdir(), "fpai-worker-server-test-")); + mkdirSync(join(projectDir, ".failproofai"), { recursive: true }); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: ["block-sudo"] }), + ); + + workerSocketPath = join(tmpdir(), `fpai-worker-server-test-${process.pid}-${Date.now()}.sock`); + const { startWorkerServer } = await import("../../src/hooks/worker-server"); + server = startWorkerServer(workerSocketPath); + await new Promise((resolvePromise) => { + if (server.listening) resolvePromise(); + else server.once("listening", () => resolvePromise()); + }); + }); + + afterEach(async () => { + await new Promise((r) => server.close(() => r())); + delete process.env.FAILPROOFAI_CLOUD_POLICY_DIR; + delete (globalThis as Record).__fpaiRepeatLoadCount; + rmSync(projectDir, { recursive: true, force: true }); + }); + + it("denies a sudo command via the real, unmodified builtin policy engine", async () => { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: "sudo rm -rf /" }, + }), + }); + expect(response.type).toBe("hookResult"); + expect(response.exitCode).toBe(0); + expect(permissionDecisionOf(response)).toBe("deny"); + }); + + it("allows a benign command through the real policy engine", async () => { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: "ls -la" }, + }), + }); + expect(response.type).toBe("hookResult"); + expect(response.exitCode).toBe(0); + }); + + it("handles multiple requests on the same connection-per-request pattern sequentially and correctly", async () => { + const results = await Promise.all([ + sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "sudo ls" } }), + }), + sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "echo hi" } }), + }), + sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "sudo whoami" } }), + }), + ]); + expect(permissionDecisionOf(results[0])).toBe("deny"); // sudo -> deny + expect(permissionDecisionOf(results[1])).toBeUndefined(); // echo -> allow + expect(permissionDecisionOf(results[2])).toBe("deny"); // sudo -> deny + }); + + it("re-executes an explicit custom policy on every warm-worker request", async () => { + const policyPath = join(projectDir, "custom-policy.mjs"); + writeFileSync( + policyPath, + `import { customPolicies, allow, deny } from "failproofai"; +globalThis.__fpaiRepeatLoadCount = (globalThis.__fpaiRepeatLoadCount ?? 0) + 1; +const moduleLoadCount = globalThis.__fpaiRepeatLoadCount; +customPolicies.add({ + name: "repeat-load", + description: "must survive warm worker reloads", + match: { events: ["PreToolUse"], tools: ["Bash"] }, + fn: async (ctx) => String(ctx.toolInput?.command ?? "").includes("blocked-custom") + ? deny("custom policy blocked the command at module-load-" + moduleLoadCount) + : allow(), +});\n`, + ); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: [], customPoliciesPaths: [policyPath] }), + ); + + for (let requestNumber = 0; requestNumber < 3; requestNumber++) { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: `echo blocked-custom-${requestNumber}` }, + }), + }); + expect(permissionDecisionOf(response), `warm request ${requestNumber + 1}`).toBe("deny"); + expect(response.stdout).toContain("custom policy blocked the command at module-load-1"); + } + }); + + it("loads a hash-verified active cloud policy with a cloud-qualified identity", async () => { + const managedRoot = join(projectDir, "cloud-managed"); + const generationDir = join(managedRoot, "generations", "42"); + mkdirSync(generationDir, { recursive: true }); + const policyPath = join(generationDir, "org-guard.mjs"); + const policyBytes = `import { customPolicies, deny } from "failproofai"; +customPolicies.add({ + name: "org-guard", + description: "cloud managed test guard", + match: { events: ["PreToolUse"], tools: ["Bash"] }, + fn: async () => deny("cloud-managed policy blocked the command"), +});\n`; + writeFileSync(policyPath, policyBytes); + const sha256 = createHash("sha256").update(policyBytes).digest("hex"); + writeFileSync( + join(managedRoot, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 42, + policies: [ + { + id: "org-guard", + revision: 8, + sha256, + path: "generations/42/org-guard.mjs", + }, + ], + }), + ); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = managedRoot; + + // A local disabledCustomPolicies entry with the generated cloud ID must + // not override a centrally assigned policy. + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ + enabledPolicies: [], + disabledCustomPolicies: ["cloud:org-guard@8:org-guard"], + }), + ); + + for (let requestNumber = 0; requestNumber < 2; requestNumber++) { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: `echo cloud-request-${requestNumber}` }, + }), + }); + expect(permissionDecisionOf(response)).toBe("deny"); + expect(response.stdout).toContain("cloud-managed policy blocked the command"); + } + }); + + it("uses fallbackCwd when the stdin payload carries no cwd at all", async () => { + // No cwd in the payload — the worker must inject the client-forwarded + // cwd rather than resolving project config against its own process.cwd(). + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + cwd: projectDir, + stdin: JSON.stringify({ tool_name: "Bash", tool_input: { command: "sudo ls" } }), + }); + expect(response.type).toBe("hookResult"); + expect(permissionDecisionOf(response)).toBe("deny"); + }); + + it("returns an error response for a malformed (non-JSON) frame body, without crashing the server", async () => { + const response = await new Promise>((resolvePromise, reject) => { + const socket = createConnection({ path: workerSocketPath }, () => { + const body = Buffer.from("not json", "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + socket.write(Buffer.concat([header, body])); + }); + readFrame(socket) + .then((msg) => { + socket.end(); + resolvePromise(msg); + }) + .catch(reject); + socket.on("error", reject); + }); + expect(response.type).toBe("error"); + + // The server must still be alive and answer a subsequent valid request. + const followUp = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "echo still-alive" } }), + }); + expect(followUp.type).toBe("hookResult"); + expect(followUp.exitCode).toBe(0); + }); + + it("returns an error response for an unrecognized request shape", async () => { + const response = await sendRequest(workerSocketPath, { type: "ping" }); + expect(response.type).toBe("error"); + }); + + it("answers both requests when two frames arrive coalesced in one read", async () => { + // Two requests written back-to-back on one connection routinely land in + // a single `data` event. Decoding only the first leaves the second + // stranded in the receive buffer until some *later* write happens to + // arrive — meanwhile the caller sees no response and hits its own + // fail-closed timeout against a daemon that is working fine. + const frame = (command: string) => + encodeFrame({ + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command } }), + }); + + const responses = await new Promise[]>((resolvePromise, reject) => { + const socket = createConnection({ path: workerSocketPath }, () => { + // One write, both frames — the coalesced case, deterministically. + socket.write(Buffer.concat([frame("sudo rm -rf /"), frame("echo hi")])); + }); + const collected: Record[] = []; + let buf = Buffer.alloc(0); + let declaredLen: number | null = null; + socket.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + for (;;) { + if (declaredLen === null) { + if (buf.length < 4) return; + declaredLen = buf.readUInt32BE(0); + buf = buf.subarray(4); + } + if (buf.length < declaredLen) return; + collected.push(JSON.parse(buf.subarray(0, declaredLen).toString("utf8"))); + buf = buf.subarray(declaredLen); + declaredLen = null; + if (collected.length === 2) { + socket.end(); + resolvePromise(collected); + return; + } + } + }); + socket.on("error", reject); + }); + + expect(responses.map((r) => r.type)).toEqual(["hookResult", "hookResult"]); + expect(permissionDecisionOf(responses[0])).toBe("deny"); // sudo + expect(permissionDecisionOf(responses[1])).toBeUndefined(); // echo + }); +}); diff --git a/bin/failproofai-worker.mjs b/bin/failproofai-worker.mjs new file mode 100644 index 00000000..6ddc58b0 --- /dev/null +++ b/bin/failproofai-worker.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * failproofai-worker — the warm worker process failproofaid (the Rust + * supervisor) spawns and supervises. NOT a user-facing entry point (no + * "bin" entry in package.json) — the daemon always invokes this directly by + * path, either `node bin/failproofai-worker.mjs` (dev, via + * FAILPROOFAI_WORKER_CMD) or the built `dist/worker.mjs` in production. + * + * Reads FAILPROOFAI_WORKER_SOCKET for where to listen — the daemon resolves + * and passes this; the worker never guesses a path of its own. + */ +import { realpathSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (!process.env.FAILPROOFAI_PACKAGE_ROOT) { + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve( + dirname(realpathSync(fileURLToPath(import.meta.url))), + ".." + ); +} + +if (!process.env.FAILPROOFAI_DIST_PATH) { + process.env.FAILPROOFAI_DIST_PATH = resolve( + dirname(realpathSync(fileURLToPath(import.meta.url))), + "..", + "dist" + ); +} + +const socketPath = process.env.FAILPROOFAI_WORKER_SOCKET; +if (!socketPath) { + console.error("[failproofai-worker] FAILPROOFAI_WORKER_SOCKET is not set"); + process.exit(1); +} + +const { startWorkerServer } = await import("../src/hooks/worker-server"); +const server = startWorkerServer(socketPath); + +server.on("error", (err) => { + console.error(`[failproofai-worker] server error: ${err.message}`); + process.exit(1); +}); + +console.error(`[failproofai-worker] listening on ${socketPath}`); + +function shutdown() { + server.close(() => process.exit(0)); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index eed99600..9f8c9077 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -62,6 +62,32 @@ async function track(name, props) { } catch {} } +/** + * Exits only once stdout/stderr have actually been flushed. + * + * Under every agent CLI a hook's stdout is a pipe, and pipe writes in Node + * are asynchronous — `process.exit()` terminates without draining what's + * still buffered. That stdout carries the decision payload, so a truncated + * write silently changes the decision the CLI observes; on the fail-closed + * path it would drop the deny reason entirely and leave the CLI with a bare + * exit code and no explanation. + */ +async function exitAfterFlush(code) { + const drain = (stream) => + new Promise((resolveDrain) => { + // A zero-length write's callback still queues behind everything + // already buffered, so this resolves after the real output lands. + if (stream.writableLength === 0 || stream.destroyed) resolveDrain(); + else stream.write("", () => resolveDrain()); + }); + try { + await Promise.all([drain(process.stdout), drain(process.stderr)]); + } catch { + // Never let a flush problem swallow the exit code itself. + } + process.exit(code); +} + // --hook [--cli ] — called by an agent CLI hook; fast path, outside // runCli() because it has its own exit code contract with the calling agent. const hookIdx = args.indexOf("--hook"); @@ -94,11 +120,52 @@ if (hookIdx >= 0) { ? cliArg : "claude"; try { + // Daemon-aware path — inert (and this whole block skipped) on every + // machine until `failproofai config` has installed failproofaid AND + // written the daemonConfigured marker (Stage 4). Until then this is + // byte-for-byte the same handleHookEvent(...) call below. + const { isDaemonConfigured, tryDaemonHook } = await import("../src/hooks/daemon-client"); + if (isDaemonConfigured()) { + const { readStdinPayload } = await import("../src/hooks/read-stdin"); + const { evaluateHookEvent } = await import("../src/hooks/handler"); + const stdinRead = await readStdinPayload(); + + const daemonResult = await tryDaemonHook({ + hookEvent: eventType, + cli, + stdin: stdinRead.payload, + // The client's own cwd IS the originating CLI session's cwd — this + // process is spawned fresh, at that location, by the calling agent + // CLI's own hook mechanism. See daemon-client.ts / PROTOCOL.md. + cwd: process.cwd(), + }); + + // No silent fallback to in-process evaluation here: this machine was + // explicitly configured to run through the daemon, so an unreachable + // daemon fails closed instead — a loud, correctly-shaped deny (real + // per-CLI response shaping, not a generic denial) rather than a + // silent downgrade. See the plan's "Confirmed scope decisions". + const result = + daemonResult ?? + (await evaluateHookEvent(eventType, cli, stdinRead.payload, { + forceDecision: { + decision: "deny", + reason: + "failproofaid could not be reached. This machine is configured to run hooks through it " + + "— check the daemon (see `failproofai config`) rather than retrying blindly.", + }, + })); + + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + await exitAfterFlush(result.exitCode); + } + const { handleHookEvent } = await import("../src/hooks/handler"); const exitCode = await handleHookEvent(eventType, cli); // handleHookEvent already flushes its own telemetry before returning; this // is the normal, reliable exit. - process.exit(exitCode); + await exitAfterFlush(exitCode); } catch (err) { const msg = err instanceof Error ? err.message : String(err); await track("hook_dispatch_error", { diff --git a/bin/failproofaid-shim.mjs b/bin/failproofaid-shim.mjs new file mode 100644 index 00000000..ba38e10b --- /dev/null +++ b/bin/failproofaid-shim.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * `failproofaid` npm bin entry — runs the compiled daemon binary this CLI + * version downloaded into `~/.failproofai/bin`, forwarding argv and + * propagating the exit code verbatim. + * + * NOT what any service manager invokes: the systemd unit / launchd plist + * `daemon-service.ts` writes points `ExecStart`/`ProgramArguments` + * directly at the resolved binary, bypassing this shim entirely — a + * supervised service needs a direct path, not a wrapper it would have to + * keep alive itself. This shim exists only for a user (or script) + * invoking `failproofaid` by hand. + * + * The npm package deliberately ships no binary: one tarball serves every + * platform, and the four cross-compiled binaries live on the GitHub + * Release for this version (see `src/hooks/daemon-download.ts`). + * `failproofai config` is what fetches one. So "not installed" here is a + * normal state rather than a broken install, and it degrades with a + * one-line message and a non-zero exit — never a stack trace — including + * on a platform that has no binary at all (Windows). The daemon-connect + * logic in the CLI never depends on this shim: it detects "no daemon" + * independently via the socket/daemonConfigured marker. + */ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +const requireFromHere = createRequire(import.meta.url); +const { version } = requireFromHere("../package.json"); + +function platformKey() { + const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : null; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null; + if (!os || !arch) return null; + return `${os}-${arch}`; +} + +/** + * Mirrors `resolveFailproofaidBinaryPath()` in src/hooks/daemon-service.ts. + * Deliberately its own copy: this file is plain .mjs, run by node straight + * out of the installed package, with no access to the bundled TypeScript. + */ +function resolveBinary() { + if (process.env.FAILPROOFAI_DAEMON_BINARY) return process.env.FAILPROOFAI_DAEMON_BINARY; + const downloaded = resolve(homedir(), ".failproofai", "bin", `failproofaid-${version}`); + return existsSync(downloaded) ? downloaded : null; +} + +const binaryPath = resolveBinary(); +if (!binaryPath) { + const key = platformKey(); + process.stderr.write( + key + ? `failproofaid ${version} is not installed on this machine. Run \`failproofai config\` and choose the global scope to install it.\n` + : `failproofaid is not available on ${process.platform}/${process.arch} yet — the CLI's in-process enforcement is unaffected.\n`, + ); + process.exit(1); +} + +const result = spawnSync(binaryPath, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) { + process.stderr.write(`failproofaid: failed to run ${binaryPath}: ${result.error.message}\n`); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/crates/.gitkeep b/crates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/CLOUD_POLICIES.md b/crates/CLOUD_POLICIES.md new file mode 100644 index 00000000..b32d1950 --- /dev/null +++ b/crates/CLOUD_POLICIES.md @@ -0,0 +1,110 @@ +# Cloud-managed policy generations + +This is the contract between the Failproof Cloud HTTP transport and the +`failproofaid` policy worker. Polling and integrity maintenance run outside the +hook path; hooks evaluate only the last verified local generation. + +## Layout + +```text +~/.failproofai/policies/cloud-managed/ +├── desired-state.json +├── active.json +├── artifacts/ +│ └── .mjs +└── generations/ + └── / + ├── manifest.json + └── .mjs +``` + +- `desired-state.json` is the last complete desired-state snapshot received + from cloud. A later slice must authenticate it with a publisher signature; + SHA-256 alone proves byte identity, not publisher identity. +- `artifacts/` is a content-addressed cache. +- `generations/` contains complete materialized policy sets. New generations + are built without modifying the active one. +- `active.json` is an atomically replaced pointer to one complete generation. + It is derived state and is reconstructed when it is deleted, malformed, or + disagrees with `desired-state.json`. + +## Desired-state schema + +```json +{ + "schemaVersion": 1, + "generation": 184, + "policies": [ + { + "id": "block-secret-exfiltration", + "revision": 7, + "sha256": "<64 lowercase hex characters>", + "artifactUrl": "https://..." + } + ] +} +``` + +The artifact URL is opaque to the store. A cloud client supplies downloaded +bytes through `ArtifactFetcher`; the reconciler trusts none of those bytes +until their SHA-256 matches the desired state. + +## Cloud transport + +Set all three variables to enable polling: + +```text +FAILPROOFAI_CLOUD_URL=https://be.failproof.ai +FAILPROOFAI_CLOUD_TOKEN= +FAILPROOFAI_MACHINE_ID= +``` + +`FAILPROOFAI_CLOUD_POLICY_POLL_MS` controls the interval (30 seconds by +default, clamped to at least 100 ms). The client sends Bearer authentication +to both desired-state and artifact endpoints. Relative artifact locators are +resolved against the configured base URL; cross-origin locators are rejected +before the token is sent. + +An HTTP failure, invalid desired-state response, bad digest, or incomplete +generation leaves the previous generation active. + +## Activation transaction + +1. Validate schema, policy IDs, unique IDs, digests, and monotonic generation. +2. Reuse a verified cache/generation copy or fetch missing bytes. +3. Verify every artifact digest. +4. Materialize the complete generation and `fsync` its files/directories. +5. Persist `desired-state.json`. +6. Atomically replace `active.json`. + +Any failure before step 6 leaves the previous generation active. The worker +loads only paths named by `active.json` and independently verifies every digest +immediately before importing JavaScript. + +## Integrity maintenance + +`failproofaid` runs a maintenance thread outside the hook path. It hashes the +active generation periodically (30 seconds by default) and repairs either a +modified generation copy or a modified content-addressed artifact from the +other verified copy. If both are gone, it retains the active manifest and +reports that a cloud re-fetch is required. + +`FAILPROOFAI_CLOUD_POLICY_DIR` overrides the root for tests and development. +When cloud polling is disabled, `FAILPROOFAI_CLOUD_POLICY_RECONCILE_MS` +overrides the standalone integrity interval, clamped to at least 100 ms. With +cloud polling enabled, integrity repair runs on each poll. + +## Current security boundary + +PR #632 runs the daemon as the same OS user as the governed agent. This layer +provides deterministic deployment, drift detection, and self-healing, but it +does not make policies tamper-proof against that user. The user can stop the +service or delete both verified copies. Publisher signatures, deployment +acknowledgement, and a stronger service identity are separate follow-up layers. +The current machine credential is an org-scoped Bearer key transported over +HTTPS. + +Downloaded JavaScript executes in the existing TypeScript policy worker with +the user's authority. Cloud authorization must therefore treat assigning an +arbitrary JavaScript policy as remote code execution until a sandboxed policy +runtime exists. diff --git a/crates/PROTOCOL.md b/crates/PROTOCOL.md new file mode 100644 index 00000000..26613275 --- /dev/null +++ b/crates/PROTOCOL.md @@ -0,0 +1,122 @@ +# failproofaid wire protocol + +The contract between the `failproofai` CLI (thin client) and the `failproofaid` +daemon over a Unix domain socket. This is the same contract every hook +invocation already has today — see `src/hooks/handler.ts`'s +`handleHookEvent`: `(argv --hook --cli , stdin JSON payload) -> +(stdout, stderr, exitCode)`. The daemon adds nothing to that contract; it only +relays it over a socket instead of a fresh process's argv/stdin/stdout. + +Implemented in `crates/fpai-ipc` (framing + envelope + peer verification) and +`crates/failproofaid` (the socket server itself). As of Stage 2, the daemon +answers `ping` and rejects `hook` with a stub "not implemented" error — Stage 3 +wires `hook` up to a real warm Node/Bun worker. + +## Transport + +One Unix domain socket, one connection per request. The daemon reads exactly +one frame, dispatches it, writes exactly one frame back, and lets the +connection close — there is no request-ID multiplexing because there is never +more than one logical request in flight per connection. + +Default path: `~/.failproofai/run/failproofaid.sock`. Overridable via the +`FAILPROOFAI_DAEMON_SOCKET` env var (used by local dev and tests — never point +this at a directory failproofaid doesn't own itself; see +`crates/failproofaid/src/paths.rs`'s `ensure_run_dir`, which refuses to modify +permissions on a pre-existing directory it didn't create). + +Permissions: the run directory is `0700` and the socket file `0600` — this is +the actual access-control boundary (user-scope only, no elevation, same OS +user only). `crates/fpai-ipc/src/peer.rs`'s `SO_PEERCRED` (Linux) / +`getpeereid` (macOS) check is defense-in-depth on top of that, not a stronger +boundary — same-user access can always reach this daemon regardless. + +## Framing + +Every message, in both directions, is: + +``` ++----------------------------+------------------------------+ +| length (4 bytes, big-endian u32) | UTF-8 JSON body (length bytes) | ++----------------------------+------------------------------+ +``` + +A declared length over `MAX_FRAME_LEN` (16 MiB) is rejected without +allocating a body buffer for it — a hook payload is at most 1 MiB (see +`handler.ts`'s own stdin cap), so 16 MiB is headroom, not an expected size. + +## Envelope + +Tagged JSON, `"type"` as the discriminant, camelCase field names. + +### Client → daemon (`ClientMessage`) + +```jsonc +// Liveness/handshake check. +{ "type": "ping", "protocolVersion": 1 } + +// One hook evaluation request — one per `failproofai --hook --cli ` invocation. +{ + "type": "hook", + "protocolVersion": 1, + "hookEvent": "PreToolUse", + "cli": "claude", + "stdin": "", + "cwd": "/path/to/session/cwd" // optional; see the note below +} +``` + +`cwd` must be the *originating* CLI process's cwd, captured by the thin +client before dispatch — never the daemon's own cwd. The daemon is a single +long-lived process; its own `cwd` does not vary per request and must never be +used to resolve project config or custom policies (this is the "process.cwd() +hazard" the TS-side plan calls out explicitly). + +### Daemon → client (`ServerMessage`) + +```jsonc +{ "type": "pong", "protocolVersion": 1 } + +{ + "type": "hookResult", + "protocolVersion": 1, + "exitCode": 0, + "stdout": "...", + "stderr": "..." +} + +// The daemon accepted the connection and parsed the request, but could not +// produce a verdict (worker down/hung, or — in Stage 2 — hook evaluation +// simply isn't wired up yet). Distinct from hookResult so the client can +// tell "ran and decided" apart from "daemon couldn't evaluate at all" — the +// latter is what drives the client's fail-closed path. +{ "type": "error", "protocolVersion": 1, "message": "..." } +``` + +## Protocol versioning + +`protocolVersion` is carried on every message in both directions. A mismatch +gets an explicit `error` response from the daemon — the client treats *any* +failure mode (missing socket, refused connection, timeout, malformed +response, or an explicit version mismatch) identically: fall through to +whatever the client's fail-closed/in-process policy dictates. There is no +negotiation, only agree-or-fall-back. + +## Peer verification + +Every connection is checked with `SO_PEERCRED`/`getpeereid` before a single +byte of the request is read. A peer running as a different OS user gets the +connection dropped with **no response at all** — not even an `error` frame — +so a connection from the wrong user can't even confirm a daemon is listening +there. + +## Malformed input handling + +- A frame whose declared length exceeds `MAX_FRAME_LEN`: connection closed, + no response, no allocation attempted for the declared size. +- A syntactically invalid or truncated frame: connection closed, no response. +- A well-formed frame with an unrecognized `"type"`: fails to deserialize, + same as above. + +None of these crash the daemon or the connection-handling thread — a bad +frame from one connection has no effect on any other connection. diff --git a/crates/failproofaid/Cargo.toml b/crates/failproofaid/Cargo.toml new file mode 100644 index 00000000..50b23039 --- /dev/null +++ b/crates/failproofaid/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "failproofaid" +version.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +description = "Thin Rust supervisor: owns the failproofai IPC socket, service lifecycle, and warm worker process supervision. Carries no policy logic." +publish = false + +[[bin]] +name = "failproofaid" +path = "src/main.rs" + +[dependencies] +fpai-ipc = { path = "../fpai-ipc" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +libc = "0.2" +sha2 = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } diff --git a/crates/failproofaid/src/cloud_client.rs b/crates/failproofaid/src/cloud_client.rs new file mode 100644 index 00000000..05aad844 --- /dev/null +++ b/crates/failproofaid/src/cloud_client.rs @@ -0,0 +1,232 @@ +use crate::cloud_policies::{DesiredPolicy, DesiredState, PolicyStore}; +use reqwest::Url; +use reqwest::blocking::Client; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +const DEFAULT_POLL_MS: u64 = 30_000; +const MINIMUM_POLL_MS: u64 = 100; + +#[derive(Clone)] +pub struct CloudClient { + base_url: Url, + token: String, + machine_id: String, + client: Client, +} + +impl CloudClient { + pub fn from_env() -> Result, String> { + let Some(base_url) = env_value("FAILPROOFAI_CLOUD_URL") else { + return Ok(None); + }; + let token = env_value("FAILPROOFAI_CLOUD_TOKEN") + .ok_or("FAILPROOFAI_CLOUD_TOKEN is required when FAILPROOFAI_CLOUD_URL is set")?; + let machine_id = env_value("FAILPROOFAI_MACHINE_ID") + .ok_or("FAILPROOFAI_MACHINE_ID is required when FAILPROOFAI_CLOUD_URL is set")?; + Self::new(&base_url, token, machine_id).map(Some) + } + + fn new(base_url: &str, token: String, machine_id: String) -> Result { + let mut base_url = + Url::parse(base_url).map_err(|err| format!("invalid FAILPROOFAI_CLOUD_URL: {err}"))?; + if !matches!(base_url.scheme(), "http" | "https") { + return Err("FAILPROOFAI_CLOUD_URL must use http or https".to_string()); + } + if !base_url.path().ends_with('/') { + base_url.set_path(&format!("{}/", base_url.path())); + } + if machine_id.is_empty() { + return Err("FAILPROOFAI_MACHINE_ID cannot be empty".to_string()); + } + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|err| format!("failed to build cloud HTTP client: {err}"))?; + Ok(Self { + base_url, + token, + machine_id, + client, + }) + } + + pub fn desired_state(&self) -> Result { + let mut url = self + .base_url + .join("enforcement/v1/desired-state") + .map_err(|err| format!("failed to build desired-state URL: {err}"))?; + url.query_pairs_mut() + .append_pair("machineId", &self.machine_id); + self.client + .get(url) + .bearer_auth(&self.token) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|err| format!("desired-state request failed: {err}"))? + .json() + .map_err(|err| format!("invalid desired-state response: {err}")) + } + + fn artifact(&self, policy: &DesiredPolicy) -> Result, String> { + let url = self + .base_url + .join(&policy.artifact_url) + .map_err(|err| format!("invalid artifact URL: {err}"))?; + if url.origin() != self.base_url.origin() { + return Err("artifact URL points outside the configured cloud origin".to_string()); + } + self.client + .get(url) + .bearer_auth(&self.token) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|err| format!("artifact request failed: {err}"))? + .bytes() + .map(|bytes| bytes.to_vec()) + .map_err(|err| format!("failed to read artifact response: {err}")) + } +} + +pub fn spawn_cloud_manager( + store: PolicyStore, + cloud: CloudClient, + shutdown: Arc, + interval: Duration, +) -> JoinHandle<()> { + std::thread::spawn(move || { + while !shutdown.load(Ordering::Relaxed) { + match cloud.desired_state() { + Ok(desired) => match store + .reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy)) + { + Ok(outcome) + if outcome.activated || outcome.downloaded > 0 || outcome.repaired > 0 => + { + eprintln!( + "[failproofaid] cloud policy generation {} active (downloaded {}, repaired {})", + outcome.generation, outcome.downloaded, outcome.repaired + ); + } + Ok(_) => {} + Err(err) => eprintln!("[failproofaid] cloud policy reconcile error: {err}"), + }, + Err(err) => eprintln!("[failproofaid] cloud policy poll error: {err}"), + } + + // Poll failures never discard the last known-good generation, and + // local tampering is still repaired while the cloud is offline. + if let Err(err) = store.repair_active_from_cache() { + eprintln!("[failproofaid] cloud policy integrity error: {err}"); + } + wait_until_shutdown(&shutdown, interval); + } + }) +} + +pub fn poll_interval_from_env() -> Duration { + let milliseconds = env_value("FAILPROOFAI_CLOUD_POLICY_POLL_MS") + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_POLL_MS) + .max(MINIMUM_POLL_MS); + Duration::from_millis(milliseconds) +} + +fn env_value(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn wait_until_shutdown(shutdown: &AtomicBool, interval: Duration) { + let deadline = Instant::now() + interval; + while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + std::thread::sleep(remaining.min(Duration::from_millis(50))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + use std::fs; + use std::io::{Read, Write}; + use std::net::TcpListener; + + #[test] + fn fetches_desired_state_and_artifact_into_the_store() { + let artifact = b"export default 'managed';\n".to_vec(); + let sha = format!("{:x}", Sha256::digest(&artifact)); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let expected_sha = sha.clone(); + let expected_artifact = artifact.clone(); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert!( + request.contains("Authorization: Bearer test-token") + || request.contains("authorization: Bearer test-token") + ); + let body = if request + .starts_with("GET /enforcement/v1/desired-state?machineId=machine-1") + { + format!(r#"{{"schemaVersion":1,"generation":7,"policies":[{{"id":"guard","revision":2,"sha256":"{expected_sha}","artifactUrl":"/enforcement/v1/artifacts/{expected_sha}"}}]}}"#).into_bytes() + } else { + expected_artifact.clone() + }; + let content_type = if request.contains("desired-state") { + "application/json" + } else { + "text/javascript" + }; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n", body.len(), content_type).unwrap(); + stream.write_all(&body).unwrap(); + } + }); + + let cloud = CloudClient::new( + &format!("http://{address}"), + "test-token".into(), + "machine-1".into(), + ) + .unwrap(); + let desired = cloud.desired_state().unwrap(); + let root = + std::env::temp_dir().join(format!("failproofaid-http-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let store = PolicyStore::new(root.clone()); + let outcome = store + .reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy)) + .unwrap(); + assert_eq!(outcome.generation, 7); + assert_eq!(outcome.downloaded, 1); + assert_eq!( + fs::read(root.join("generations/7/guard.mjs")).unwrap(), + artifact + ); + server.join().unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_cross_origin_artifacts_before_sending_the_token() { + let cloud = + CloudClient::new("https://cloud.example", "secret".into(), "machine".into()).unwrap(); + let policy = DesiredPolicy { + id: "guard".into(), + revision: 1, + sha256: "0".repeat(64), + artifact_url: "https://evil.example/artifact".into(), + }; + assert!(cloud.artifact(&policy).unwrap_err().contains("outside")); + } +} diff --git a/crates/failproofaid/src/cloud_policies.rs b/crates/failproofaid/src/cloud_policies.rs new file mode 100644 index 00000000..78af5310 --- /dev/null +++ b/crates/failproofaid/src/cloud_policies.rs @@ -0,0 +1,732 @@ +//! Local desired-state store for cloud-managed JavaScript policies. +//! +//! Cloud transport deliberately does not live here. A caller supplies an +//! [`ArtifactFetcher`], while this module owns the security-sensitive local +//! transaction: validate the manifest, verify SHA-256, write immutable cache +//! objects, materialize a complete generation, then switch `active.json` +//! atomically. The hook hot path never downloads or partially activates policy. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +pub const DESIRED_STATE_SCHEMA_VERSION: u32 = 1; +const MANAGED_FILE_MODE: u32 = 0o600; +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DesiredState { + pub schema_version: u32, + pub generation: u64, + pub policies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DesiredPolicy { + pub id: String, + pub revision: u64, + pub sha256: String, + /// Opaque locator interpreted only by the cloud transport implementation. + pub artifact_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActiveGeneration { + pub schema_version: u32, + pub generation: u64, + pub policies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivePolicy { + pub id: String, + pub revision: u64, + pub sha256: String, + /// Relative to the cloud-managed root. Never supplied by the server. + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconcileOutcome { + pub generation: u64, + pub downloaded: usize, + pub repaired: usize, + pub activated: bool, +} + +#[derive(Debug)] +pub enum ReconcileError { + Io(io::Error), + Json(serde_json::Error), + InvalidDesiredState(String), + HashMismatch { + policy_id: String, + expected: String, + actual: String, + }, + Fetch { + policy_id: String, + message: String, + }, + NoVerifiedCopy { + policy_id: String, + }, +} + +impl std::fmt::Display for ReconcileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "cloud policy I/O error: {err}"), + Self::Json(err) => write!(f, "cloud policy JSON error: {err}"), + Self::InvalidDesiredState(message) => { + write!(f, "invalid cloud desired state: {message}") + } + Self::HashMismatch { + policy_id, + expected, + actual, + } => write!( + f, + "cloud policy {policy_id} hash mismatch: expected {expected}, got {actual}" + ), + Self::Fetch { policy_id, message } => { + write!(f, "failed to fetch cloud policy {policy_id}: {message}") + } + Self::NoVerifiedCopy { policy_id } => write!( + f, + "cloud policy {policy_id} has no verified artifact or generation copy" + ), + } + } +} + +impl std::error::Error for ReconcileError {} + +impl From for ReconcileError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for ReconcileError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +/// Transport boundary. Production cloud HTTP and tests both implement this; +/// fetched bytes are untrusted until the reconciler verifies their digest. +pub trait ArtifactFetcher { + fn fetch(&self, policy: &DesiredPolicy) -> Result, String>; +} + +impl ArtifactFetcher for F +where + F: Fn(&DesiredPolicy) -> Result, String>, +{ + fn fetch(&self, policy: &DesiredPolicy) -> Result, String> { + self(policy) + } +} + +#[derive(Debug, Clone)] +pub struct PolicyStore { + root: PathBuf, +} + +impl PolicyStore { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn active_manifest_path(&self) -> PathBuf { + self.root.join("active.json") + } + + pub fn desired_state_path(&self) -> PathBuf { + self.root.join("desired-state.json") + } + + pub fn read_active(&self) -> Result, ReconcileError> { + let path = self.active_manifest_path(); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(path)?; + Ok(Some(serde_json::from_slice(&bytes)?)) + } + + pub fn read_desired(&self) -> Result, ReconcileError> { + let path = self.desired_state_path(); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(path)?; + let desired: DesiredState = serde_json::from_slice(&bytes)?; + validate_desired_state(&desired)?; + Ok(Some(desired)) + } + + /// Installs a complete desired generation. Any error before the final + /// `active.json` rename leaves the previous generation authoritative. + pub fn reconcile( + &self, + desired: &DesiredState, + fetcher: &impl ArtifactFetcher, + ) -> Result { + validate_desired_state(desired)?; + // active.json is a derived local pointer, not authority. If it was + // truncated or otherwise corrupted, rebuild it from desired state + // instead of making the corruption permanent. Non-JSON I/O failures + // still surface. + let previous = match self.read_active() { + Ok(active) => active, + Err(ReconcileError::Json(_)) => None, + Err(err) => return Err(err), + }; + if let Some(active) = &previous + && desired.generation < active.generation + { + return Err(ReconcileError::InvalidDesiredState(format!( + "generation rollback from {} to {} is not allowed", + active.generation, desired.generation + ))); + } + + fs::create_dir_all(self.root.join("artifacts"))?; + let generation_dir = self + .root + .join("generations") + .join(desired.generation.to_string()); + fs::create_dir_all(&generation_dir)?; + + let mut downloaded = 0; + let mut repaired = 0; + let mut active_policies = Vec::with_capacity(desired.policies.len()); + + for policy in &desired.policies { + let artifact_path = self.artifact_path(&policy.sha256); + let generation_path = generation_dir.join(format!("{}.mjs", policy.id)); + + let artifact_valid = file_matches_hash(&artifact_path, &policy.sha256)?; + let generation_valid = file_matches_hash(&generation_path, &policy.sha256)?; + + let bytes = if artifact_valid { + fs::read(&artifact_path)? + } else if generation_valid { + let bytes = fs::read(&generation_path)?; + write_atomic(&artifact_path, &bytes)?; + repaired += 1; + bytes + } else { + let bytes = fetcher + .fetch(policy) + .map_err(|message| ReconcileError::Fetch { + policy_id: policy.id.clone(), + message, + })?; + verify_bytes(policy, &bytes)?; + write_atomic(&artifact_path, &bytes)?; + downloaded += 1; + bytes + }; + + if !generation_valid { + write_atomic(&generation_path, &bytes)?; + if artifact_valid { + repaired += 1; + } + } + + let relative_path = generation_path + .strip_prefix(&self.root) + .map_err(|_| { + ReconcileError::InvalidDesiredState( + "generation path escaped policy root".into(), + ) + })? + .to_string_lossy() + .into_owned(); + active_policies.push(ActivePolicy { + id: policy.id.clone(), + revision: policy.revision, + sha256: policy.sha256.clone(), + path: relative_path, + }); + } + + let active = ActiveGeneration { + schema_version: DESIRED_STATE_SCHEMA_VERSION, + generation: desired.generation, + policies: active_policies, + }; + let manifest_bytes = serde_json::to_vec_pretty(&active)?; + write_atomic(&generation_dir.join("manifest.json"), &manifest_bytes)?; + + // Persist the cloud snapshot before switching active.json. A crash in + // between is recoverable: the maintenance loop reconstructs the active + // pointer from this snapshot and the fully staged generation. + write_atomic( + &self.desired_state_path(), + &serde_json::to_vec_pretty(desired)?, + )?; + + let activated = previous.as_ref() != Some(&active); + if activated { + write_atomic(&self.active_manifest_path(), &manifest_bytes)?; + } + + Ok(ReconcileOutcome { + generation: desired.generation, + downloaded, + repaired, + activated, + }) + } + + /// Verifies the active generation and repairs one bad copy from the other + /// verified local copy. If both copies are missing/corrupt, the cloud + /// transport must re-fetch; active.json remains unchanged and the worker's + /// already-loaded generation remains the last known good decision set. + pub fn repair_active_from_cache(&self) -> Result { + if let Some(desired) = self.read_desired()? { + let outcome = self.reconcile(&desired, &|policy: &DesiredPolicy| { + Err(format!( + "no verified cached bytes remain for {}; cloud refetch required", + policy.id + )) + })?; + return Ok(outcome.repaired + usize::from(outcome.activated)); + } + + let Some(active) = self.read_active()? else { + return Ok(0); + }; + if active.schema_version != DESIRED_STATE_SCHEMA_VERSION { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsupported active schema version {}", + active.schema_version + ))); + } + + let mut repaired = 0; + for policy in &active.policies { + validate_policy_identity(&policy.id)?; + validate_sha256(&policy.sha256)?; + let generation_path = safe_join_relative(&self.root, &policy.path)?; + let artifact_path = self.artifact_path(&policy.sha256); + let artifact_valid = file_matches_hash(&artifact_path, &policy.sha256)?; + let generation_valid = file_matches_hash(&generation_path, &policy.sha256)?; + + match (artifact_valid, generation_valid) { + (true, true) => {} + (true, false) => { + write_atomic(&generation_path, &fs::read(&artifact_path)?)?; + repaired += 1; + } + (false, true) => { + write_atomic(&artifact_path, &fs::read(&generation_path)?)?; + repaired += 1; + } + (false, false) => { + return Err(ReconcileError::NoVerifiedCopy { + policy_id: policy.id.clone(), + }); + } + } + } + Ok(repaired) + } + + fn artifact_path(&self, sha256: &str) -> PathBuf { + self.root.join("artifacts").join(format!("{sha256}.mjs")) + } +} + +/// Starts the maintenance-lane integrity loop. It performs one pass +/// immediately, then periodically, and wakes in short slices so daemon +/// shutdown never waits for the full reconciliation interval. +pub fn spawn_integrity_monitor( + store: PolicyStore, + shutdown: Arc, + interval: Duration, +) -> JoinHandle<()> { + std::thread::spawn(move || { + while !shutdown.load(Ordering::Relaxed) { + match store.repair_active_from_cache() { + Ok(0) => {} + Ok(repaired) => { + eprintln!("[failproofaid] repaired {repaired} cloud-managed policy artifact(s)") + } + Err(err) => eprintln!("[failproofaid] cloud policy integrity error: {err}"), + } + + let deadline = Instant::now() + interval; + while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + std::thread::sleep(remaining.min(Duration::from_millis(50))); + } + } + }) +} + +fn validate_desired_state(desired: &DesiredState) -> Result<(), ReconcileError> { + if desired.schema_version != DESIRED_STATE_SCHEMA_VERSION { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsupported schema version {}", + desired.schema_version + ))); + } + let mut ids = HashSet::new(); + for policy in &desired.policies { + validate_policy_identity(&policy.id)?; + validate_sha256(&policy.sha256)?; + if policy.artifact_url.trim().is_empty() { + return Err(ReconcileError::InvalidDesiredState(format!( + "policy {} has an empty artifactUrl", + policy.id + ))); + } + if !ids.insert(policy.id.as_str()) { + return Err(ReconcileError::InvalidDesiredState(format!( + "duplicate policy id {}", + policy.id + ))); + } + } + Ok(()) +} + +fn validate_policy_identity(id: &str) -> Result<(), ReconcileError> { + let valid = !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + && id != "." + && id != ".."; + if !valid { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsafe policy id {id:?}" + ))); + } + Ok(()) +} + +fn validate_sha256(value: &str) -> Result<(), ReconcileError> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(ReconcileError::InvalidDesiredState(format!( + "invalid lowercase SHA-256 digest {value:?}" + ))); + } + Ok(()) +} + +fn safe_join_relative(root: &Path, relative: &str) -> Result { + let path = Path::new(relative); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsafe active policy path {relative:?}" + ))); + } + Ok(root.join(path)) +} + +fn verify_bytes(policy: &DesiredPolicy, bytes: &[u8]) -> Result<(), ReconcileError> { + let actual = sha256_hex(bytes); + if actual != policy.sha256 { + return Err(ReconcileError::HashMismatch { + policy_id: policy.id.clone(), + expected: policy.sha256.clone(), + actual, + }); + } + Ok(()) +} + +fn file_matches_hash(path: &Path, expected: &str) -> Result { + let mut file = match File::open(path) { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err.into()), + }; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize()) == expected) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ReconcileError> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("managed policy path has no parent"))?; + fs::create_dir_all(parent)?; + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("managed-policy"); + let temp = parent.join(format!(".{file_name}.tmp-{}-{counter}", std::process::id())); + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.set_permissions(fs::Permissions::from_mode(MANAGED_FILE_MODE))?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temp, path)?; + File::open(parent)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn temp_store(name: &str) -> PolicyStore { + let root = std::env::temp_dir().join(format!( + "failproofaid-cloud-policy-{name}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + PolicyStore::new(root) + } + + fn desired(generation: u64, id: &str, bytes: &[u8]) -> DesiredState { + DesiredState { + schema_version: DESIRED_STATE_SCHEMA_VERSION, + generation, + policies: vec![DesiredPolicy { + id: id.to_string(), + revision: generation, + sha256: sha256_hex(bytes), + artifact_url: format!("https://cloud.invalid/{id}/{generation}"), + }], + } + } + + #[test] + fn activates_a_complete_verified_generation() { + let store = temp_store("activate"); + let bytes = b"export default 'cloud policy';\n"; + let state = desired(7, "block-secrets", bytes); + let fetches = AtomicUsize::new(0); + let outcome = store + .reconcile(&state, &|_: &DesiredPolicy| { + fetches.fetch_add(1, Ordering::Relaxed); + Ok(bytes.to_vec()) + }) + .unwrap(); + + assert_eq!(outcome.downloaded, 1); + assert!(outcome.activated); + assert_eq!(fetches.load(Ordering::Relaxed), 1); + let active = store.read_active().unwrap().unwrap(); + assert_eq!(active.generation, 7); + assert_eq!(active.policies[0].id, "block-secrets"); + let active_path = store.root().join(&active.policies[0].path); + assert_eq!(fs::read(active_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn a_bad_download_never_replaces_last_known_good() { + let store = temp_store("bad-download"); + let good = b"export default 'good';\n"; + store + .reconcile(&desired(1, "guard", good), &|_: &DesiredPolicy| { + Ok(good.to_vec()) + }) + .unwrap(); + let before = fs::read(store.active_manifest_path()).unwrap(); + + let next = desired(2, "guard", b"export default 'expected';\n"); + let err = store + .reconcile( + &next, + &|_: &DesiredPolicy| Ok(b"tampered download".to_vec()), + ) + .unwrap_err(); + assert!(matches!(err, ReconcileError::HashMismatch { .. })); + assert_eq!(fs::read(store.active_manifest_path()).unwrap(), before); + assert_eq!(store.read_active().unwrap().unwrap().generation, 1); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn repairs_a_tampered_generation_copy_from_the_verified_artifact() { + let store = temp_store("repair-generation"); + let bytes = b"export default 'verified';\n"; + store + .reconcile(&desired(3, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + let generation_path = store.root().join(&active.policies[0].path); + fs::write(&generation_path, b"tampered").unwrap(); + + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!(fs::read(generation_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn repairs_a_tampered_artifact_from_the_verified_generation_copy() { + let store = temp_store("repair-artifact"); + let bytes = b"export default 'verified';\n"; + let state = desired(4, "guard", bytes); + store + .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec())) + .unwrap(); + let artifact_path = store.artifact_path(&state.policies[0].sha256); + fs::write(&artifact_path, b"tampered").unwrap(); + + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!(fs::read(artifact_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn reports_when_both_verified_copies_are_lost() { + let store = temp_store("both-lost"); + let bytes = b"export default 'verified';\n"; + let state = desired(5, "guard", bytes); + store + .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec())) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + fs::write(store.root().join(&active.policies[0].path), b"bad-one").unwrap(); + fs::write(store.artifact_path(&state.policies[0].sha256), b"bad-two").unwrap(); + + assert!(matches!( + store.repair_active_from_cache(), + Err(ReconcileError::Fetch { .. }) + )); + assert_eq!(store.read_active().unwrap().unwrap().generation, 5); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn repairs_a_removed_or_rewritten_active_manifest_from_desired_state() { + let store = temp_store("repair-active"); + let bytes = b"export default 'verified';\n"; + store + .reconcile(&desired(11, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + + fs::write( + store.active_manifest_path(), + br#"{"schemaVersion":1,"generation":11,"policies":[]}"#, + ) + .unwrap(); + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!(store.read_active().unwrap().unwrap().policies.len(), 1); + + fs::write(store.active_manifest_path(), b"not-json").unwrap(); + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!( + store.read_active().unwrap().unwrap().policies[0].id, + "guard" + ); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn rejects_traversal_duplicate_ids_and_generation_rollback() { + let store = temp_store("validation"); + let bytes = b"policy"; + let mut traversal = desired(1, "../escape", bytes); + assert!(matches!( + store.reconcile(&traversal, &|_: &DesiredPolicy| Ok(bytes.to_vec())), + Err(ReconcileError::InvalidDesiredState(_)) + )); + + traversal.policies[0].id = "guard".to_string(); + traversal.policies.push(traversal.policies[0].clone()); + assert!(matches!( + store.reconcile(&traversal, &|_: &DesiredPolicy| Ok(bytes.to_vec())), + Err(ReconcileError::InvalidDesiredState(_)) + )); + + store + .reconcile(&desired(9, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + assert!(matches!( + store.reconcile(&desired(8, "guard", bytes), &|_: &DesiredPolicy| Ok( + bytes.to_vec() + )), + Err(ReconcileError::InvalidDesiredState(_)) + )); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn background_monitor_repairs_without_touching_the_hook_path() { + let store = temp_store("monitor"); + let bytes = b"export default 'verified';\n"; + store + .reconcile(&desired(10, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + let generation_path = store.root().join(&active.policies[0].path); + fs::write(&generation_path, b"tampered").unwrap(); + + let shutdown = Arc::new(AtomicBool::new(false)); + let handle = + spawn_integrity_monitor(store.clone(), shutdown.clone(), Duration::from_millis(10)); + let deadline = Instant::now() + Duration::from_secs(1); + while fs::read(&generation_path).unwrap() != bytes && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); + + assert_eq!(fs::read(generation_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } +} diff --git a/crates/failproofaid/src/lock.rs b/crates/failproofaid/src/lock.rs new file mode 100644 index 00000000..f9acb261 --- /dev/null +++ b/crates/failproofaid/src/lock.rs @@ -0,0 +1,81 @@ +//! Single-instance guard: at most one `failproofaid` per OS user. +//! +//! Uses an advisory `flock()` on a dedicated lock file rather than a +//! PID file — a PID file has to be checked-then-trusted (the PID could +//! have been reused by an unrelated process since), whereas `flock` is +//! released automatically by the kernel when the holding process exits or +//! is killed, for any reason, with no stale-file cleanup required. + +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::unix::io::AsRawFd; +use std::path::Path; + +pub struct SingletonLock { + // Held for the guard's lifetime; the flock is released when this File + // (and its underlying fd) is dropped. + _file: File, +} + +#[derive(Debug)] +pub enum LockError { + Io(io::Error), + AlreadyRunning, +} + +impl std::fmt::Display for LockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LockError::Io(e) => write!(f, "io error acquiring daemon lock: {e}"), + LockError::AlreadyRunning => { + write!(f, "another failproofaid is already running for this user") + } + } + } +} + +impl std::error::Error for LockError {} + +/// Tries to acquire the singleton lock at `path`, creating the file if +/// needed. Returns [`LockError::AlreadyRunning`] immediately (non-blocking) +/// if another live process already holds it. +pub fn acquire(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(LockError::Io)?; + + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if ret != 0 { + let err = io::Error::last_os_error(); + return match err.raw_os_error() { + Some(libc::EWOULDBLOCK) => Err(LockError::AlreadyRunning), + _ => Err(LockError::Io(err)), + }; + } + Ok(SingletonLock { _file: file }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_second_acquire_in_the_same_process_fails_while_the_first_is_held() { + let tmp = std::env::temp_dir().join(format!( + "failproofaid-lock-test-{}-{}", + std::process::id(), + line!() + )); + let first = acquire(&tmp).expect("first acquire should succeed"); + let second = acquire(&tmp); + assert!(matches!(second, Err(LockError::AlreadyRunning))); + drop(first); + // Once released, a fresh acquire succeeds again. + let third = acquire(&tmp); + assert!(third.is_ok()); + std::fs::remove_file(&tmp).ok(); + } +} diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs new file mode 100644 index 00000000..3234251c --- /dev/null +++ b/crates/failproofaid/src/main.rs @@ -0,0 +1,119 @@ +mod cloud_client; +pub mod cloud_policies; +mod lock; +mod paths; +mod server; +mod worker; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--version" || a == "-v") { + println!("failproofaid {}", env!("CARGO_PKG_VERSION")); + return; + } + + if let Err(err) = run() { + eprintln!("[failproofaid] {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let lock_path = paths::lock_path()?; + paths::ensure_run_dir()?; + let _singleton = lock::acquire(&lock_path)?; + + let socket_path = paths::socket_path()?; + let worker_socket_path = paths::worker_socket_path()?; + let worker = Arc::new(worker::Worker::new( + worker_socket_path, + worker::WorkerCommand::from_env(), + )); + // Pre-warm off the startup path: the socket must be accepting + // connections promptly (a service manager / health check shouldn't wait + // on a Node cold start), but a request that arrives before warm-up + // finishes still gets a correct, just slightly slower, answer — `call()` + // -> `ensure_started()` shares the same lock and simply waits for + // whichever spawn (this one or its own) is already in flight. + { + let warm_worker = worker.clone(); + std::thread::spawn(move || warm_worker.warm()); + } + let srv = server::Server::bind(&socket_path, worker)?; + eprintln!("[failproofaid] listening on {}", socket_path.display()); + + let shutdown = Arc::new(AtomicBool::new(false)); + install_signal_handler(shutdown.clone()); + + // Cloud policy integrity is a maintenance-lane responsibility, never a + // hook-path operation. The monitor is useful before cloud transport lands: + // it keeps the active generation and content-addressed artifact cache in + // agreement and reports when both verified copies have been lost. + let cloud_policy_store = cloud_policies::PolicyStore::new(paths::cloud_managed_policy_dir()?); + let cloud_monitor = match cloud_client::CloudClient::from_env()? { + Some(cloud) => { + eprintln!("[failproofaid] cloud-managed policy polling enabled"); + cloud_client::spawn_cloud_manager( + cloud_policy_store, + cloud, + shutdown.clone(), + cloud_client::poll_interval_from_env(), + ) + } + None => cloud_policies::spawn_integrity_monitor( + cloud_policy_store, + shutdown.clone(), + cloud_policy_reconcile_interval(), + ), + }; + + srv.run_until(shutdown)?; + let _ = cloud_monitor.join(); + Ok(()) +} + +fn cloud_policy_reconcile_interval() -> Duration { + const DEFAULT_MS: u64 = 30_000; + const MINIMUM_MS: u64 = 100; + let configured = std::env::var("FAILPROOFAI_CLOUD_POLICY_RECONCILE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_MS); + Duration::from_millis(configured.max(MINIMUM_MS)) +} + +/// Requests a clean shutdown (socket file removal, lock release via Drop) +/// on SIGTERM/SIGINT instead of dying mid-accept-loop — this is how a +/// systemd `stop`/launchd unload is expected to end the process. +fn install_signal_handler(shutdown: Arc) { + static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); + + extern "C" fn handle_signal(_sig: libc::c_int) { + SHUTDOWN_REQUESTED.store(true, Ordering::Relaxed); + } + + unsafe { + libc::signal( + libc::SIGTERM, + handle_signal as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGINT, + handle_signal as *const () as libc::sighandler_t, + ); + } + + std::thread::spawn(move || { + loop { + if SHUTDOWN_REQUESTED.load(Ordering::Relaxed) { + shutdown.store(true, Ordering::Relaxed); + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + }); +} diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs new file mode 100644 index 00000000..381f69d3 --- /dev/null +++ b/crates/failproofaid/src/paths.rs @@ -0,0 +1,187 @@ +//! Resolves where failproofaid's runtime state lives on disk. +//! +//! User-scope only (per the plan: no elevation, everything under the +//! invoking user's home directory) — Linux and macOS only, matching the +//! rest of this crate. + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +/// `~/.failproofai/run` — directory holding the socket and singleton lock +/// file. Overridable via `FAILPROOFAI_DAEMON_SOCKET`'s parent for local dev +/// (see `run_dir_override`), so a `bun run daemon:dev` loop never touches a +/// real installed daemon's state. +pub fn run_dir() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_DAEMON_SOCKET") { + let path = PathBuf::from(socket_override); + return path + .parent() + .map(PathBuf::from) + .ok_or_else(|| io::Error::other("FAILPROOFAI_DAEMON_SOCKET has no parent directory")); + } + let home = std::env::var_os("HOME") + .ok_or_else(|| io::Error::other("HOME is not set; failproofaid is user-scope only"))?; + Ok(PathBuf::from(home).join(".failproofai").join("run")) +} + +pub fn socket_path() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_DAEMON_SOCKET") { + return Ok(PathBuf::from(socket_override)); + } + Ok(run_dir()?.join("failproofaid.sock")) +} + +pub fn lock_path() -> io::Result { + Ok(run_dir()?.join("failproofaid.lock")) +} + +/// Where the daemon tells the worker subprocess to listen — a second +/// socket, distinct from `socket_path()`, that only this process ever +/// connects to. Overridable via `FAILPROOFAI_WORKER_SOCKET` for local dev +/// (mirrors `FAILPROOFAI_DAEMON_SOCKET`'s override for the client-facing +/// socket). +pub fn worker_socket_path() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_WORKER_SOCKET") { + return Ok(PathBuf::from(socket_override)); + } + Ok(run_dir()?.join("worker.sock")) +} + +/// Local cache for cloud-managed policy generations. The override keeps +/// tests and development runs away from a user's real policy directory. +pub fn cloud_managed_policy_dir() -> io::Result { + if let Some(path) = std::env::var_os("FAILPROOFAI_CLOUD_POLICY_DIR") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var_os("HOME").ok_or_else(|| { + io::Error::other("HOME is not set; cannot resolve cloud policy directory") + })?; + Ok(PathBuf::from(home) + .join(".failproofai") + .join("policies") + .join("cloud-managed")) +} + +/// Creates the run directory (`0700`) if it doesn't exist yet. This +/// directory holds a socket that evaluates security-relevant decisions, so +/// a freshly created one is always locked to owner-only. +/// +/// If the directory already exists, this deliberately does **not** chmod +/// it into shape — only a directory failproofaid created itself gets its +/// permissions enforced. `FAILPROOFAI_DAEMON_SOCKET` is a raw path (dev/test +/// override; see `run_dir`), and blindly tightening permissions on whatever +/// pre-existing directory its parent happens to resolve to would let a +/// misconfigured override silently reach out and chmod an unrelated shared +/// directory (worst case, something like `/tmp` itself). Failing loudly is +/// the safe default; a real deployment's `~/.failproofai/run` is always +/// failproofaid's own directory and will simply be created fresh the first +/// time, taking the safe branch below. +pub fn ensure_run_dir() -> io::Result { + let dir = run_dir()?; + if dir.exists() { + let mode = fs::metadata(&dir)?.permissions().mode() & 0o777; + if mode != 0o700 { + return Err(io::Error::other(format!( + "run directory {} already exists with permissions {:o} (expected 0700) — \ + refusing to modify a directory failproofaid did not create itself", + dir.display(), + mode + ))); + } + return Ok(dir); + } + fs::create_dir_all(&dir)?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // std::env::set_var affects the whole process, so these tests must not + // run concurrently with each other or with other tests reading these + // vars. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn socket_override_takes_precedence_over_home() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("FAILPROOFAI_DAEMON_SOCKET", "/tmp/example/daemon.sock"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/tmp/example/daemon.sock") + ); + assert_eq!(run_dir().unwrap(), PathBuf::from("/tmp/example")); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + #[test] + fn default_socket_path_lives_under_home_dot_failproofai_run() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + std::env::set_var("HOME", "/home/example-user"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/home/example-user/.failproofai/run/failproofaid.sock") + ); + } + + #[test] + fn ensure_run_dir_creates_it_with_owner_only_permissions() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = + std::env::temp_dir().join(format!("failproofaid-paths-test-{}", std::process::id())); + unsafe { + std::env::set_var( + "FAILPROOFAI_DAEMON_SOCKET", + tmp.join("run").join("failproofaid.sock"), + ); + } + let dir = ensure_run_dir().unwrap(); + let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + fs::remove_dir_all(&tmp).ok(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + #[test] + fn ensure_run_dir_refuses_to_touch_a_preexisting_directory_with_the_wrong_permissions() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!( + "failproofaid-paths-test-preexisting-{}", + std::process::id() + )); + // Simulate FAILPROOFAI_DAEMON_SOCKET being pointed at some + // unrelated, already-existing directory (e.g. a misconfigured + // override resolving to a shared temp dir) — this must error + // instead of silently chmod-ing a directory failproofaid doesn't + // own. + fs::create_dir_all(&tmp).unwrap(); + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)).unwrap(); + unsafe { + std::env::set_var("FAILPROOFAI_DAEMON_SOCKET", tmp.join("failproofaid.sock")); + } + + let result = ensure_run_dir(); + assert!(result.is_err(), "expected an error, got {result:?}"); + let mode_after = fs::metadata(&tmp).unwrap().permissions().mode() & 0o777; + assert_eq!(mode_after, 0o755, "permissions must be left untouched"); + + fs::remove_dir_all(&tmp).ok(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } +} diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs new file mode 100644 index 00000000..8e4c71b4 --- /dev/null +++ b/crates/failproofaid/src/server.rs @@ -0,0 +1,502 @@ +//! The Unix-socket server: bind, accept, verify the peer, dispatch one +//! request per connection. +//! +//! `Hook` requests are relayed to the warm worker (see `worker.rs`); `Ping` +//! is answered directly. Any failure to reach/use the worker becomes a +//! client-facing `Error` response — never a hang, never a crash of the +//! connection-handling thread. + +use crate::worker::Worker; +use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, peer, read_message, write_message}; +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +/// Bounds how long a single connection may hold its handler thread waiting +/// on the peer. A client that connects and then sends nothing would +/// otherwise park a thread for the daemon's entire lifetime, and those +/// accumulate — the opposite of the "never a hang" promise above. Generous +/// for a real client (which writes its request immediately after connect, +/// over a local socket) and still bounded. +const CONNECTION_IO_TIMEOUT: Duration = Duration::from_secs(10); + +/// Hard ceiling on connection-handling threads alive at once. The worker +/// serializes evaluation anyway, so more than a handful in flight already +/// means the daemon is badly backed up; refusing past this point turns +/// "spawn threads until the process dies" into a bounded, logged overload +/// that the client sees as an unreachable daemon (i.e. its own fail-closed +/// path), which is the correct outcome for a daemon in that state. +const MAX_INFLIGHT_CONNECTIONS: usize = 64; + +pub struct Server { + listener: UnixListener, + socket_path: PathBuf, + worker: Arc, +} + +/// Decrements the in-flight connection count on scope exit, including +/// during an unwind — a leaked count would permanently shrink the budget in +/// [`MAX_INFLIGHT_CONNECTIONS`] and eventually wedge the daemon. +struct InflightGuard(Arc); + +impl Drop for InflightGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +impl Server { + /// Binds a fresh listener at `socket_path`, replacing a stale socket + /// file left behind by a process that didn't shut down cleanly (a live + /// daemon is never listening on a leftover file — the singleton lock in + /// `lock.rs` is what actually prevents two daemons; this only clears + /// the debris of one that's already gone). + pub fn bind(socket_path: &Path, worker: Arc) -> io::Result { + if socket_path.exists() { + fs::remove_file(socket_path)?; + } + let listener = UnixListener::bind(socket_path)?; + fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))?; + Ok(Server { + listener, + socket_path: socket_path.to_path_buf(), + worker, + }) + } + + /// Accepts and handles connections, one thread per connection, until + /// `shutdown` is set to `true`. A short accept timeout keeps the loop + /// polling `shutdown` instead of blocking forever in `accept()`, which + /// is what lets tests stop a server cleanly instead of leaking a + /// blocked thread for the rest of the test process's life. + pub fn run_until(&self, shutdown: Arc) -> io::Result<()> { + self.listener.set_nonblocking(true)?; + let inflight = Arc::new(AtomicUsize::new(0)); + while !shutdown.load(Ordering::Relaxed) { + match self.listener.accept() { + Ok((stream, _addr)) => { + if inflight.load(Ordering::Relaxed) >= MAX_INFLIGHT_CONNECTIONS { + log_connection_error(&io::Error::other(format!( + "refusing connection: {MAX_INFLIGHT_CONNECTIONS} handlers already in flight" + ))); + drop(stream); + continue; + } + inflight.fetch_add(1, Ordering::Relaxed); + let guard = InflightGuard(inflight.clone()); + let worker = self.worker.clone(); + std::thread::spawn(move || { + let _guard = guard; + if let Err(err) = handle_connection(stream, &worker) { + log_connection_error(&err); + } + }); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Err(err) => return Err(err), + } + } + Ok(()) + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = fs::remove_file(&self.socket_path); + } +} + +fn log_connection_error(err: &io::Error) { + eprintln!("[failproofaid] connection error: {err}"); +} + +/// Handles exactly one request on `stream`: verify the peer is the same OS +/// user, read one framed [`ClientMessage`], dispatch it, write back one +/// framed [`ServerMessage`], then let the connection close. +pub fn handle_connection(stream: UnixStream, worker: &Worker) -> io::Result<()> { + match peer::is_same_user(&stream) { + Ok(true) => {} + Ok(false) => { + // Different OS user: drop the connection with no response at + // all, rather than an Error message that would confirm a + // daemon is listening here to a peer that has no business + // asking. + return Ok(()); + } + Err(err) => return Err(err), + } + + // `run_until` puts the *listener* in non-blocking mode. On Linux that + // doesn't reach accepted sockets (std uses `accept4`), but on + // BSD-derived systems — macOS, which this daemon supports via launchd — + // the accepted socket inherits `O_NONBLOCK` from the listener. Left + // inherited, `read_message` below returns `WouldBlock` the instant the + // client's bytes haven't landed yet, which this function would treat as + // a malformed frame and answer with silence: every hook call on macOS + // would fail closed. Set the mode explicitly rather than relying on + // per-platform accept semantics. + stream.set_nonblocking(false)?; + // Bound the peer's share of this thread's life in both directions (see + // CONNECTION_IO_TIMEOUT). + stream.set_read_timeout(Some(CONNECTION_IO_TIMEOUT))?; + stream.set_write_timeout(Some(CONNECTION_IO_TIMEOUT))?; + + let mut reader = stream.try_clone()?; + let mut writer = stream; + + let request: ClientMessage = match read_message(&mut reader) { + Ok(msg) => msg, + Err(_) => return Ok(()), // malformed frame: nothing to respond to, nothing to act on + }; + + let response = dispatch(request, worker); + write_message(&mut writer, &response) + .map_err(|e| io::Error::other(format!("failed to write response: {e}"))) +} + +fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { + if request.protocol_version() != PROTOCOL_VERSION { + return ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!( + "protocol version mismatch: daemon speaks {PROTOCOL_VERSION}, client sent {}", + request.protocol_version() + ), + }; + } + + match request { + ClientMessage::Ping { .. } => ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION, + }, + ClientMessage::Hook { + hook_event, + cli, + stdin, + cwd, + .. + } => match worker.call(&hook_event, &cli, &stdin, cwd.as_deref()) { + Ok(outcome) => ServerMessage::HookResult { + protocol_version: PROTOCOL_VERSION, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + }, + Err(err) => ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!("worker call failed: {err}"), + }, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::WorkerCommand; + use std::sync::atomic::AtomicBool; + use std::time::Duration; + + fn temp_socket_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "failproofaid-server-test-{}-{}-{}", + std::process::id(), + name, + fastrand_ish() + )) + } + + // Avoids pulling in a `rand` dependency just to de-collide temp socket + // paths across tests running in parallel threads. + fn fastrand_ish() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + /// A `WorkerCommand` that never produces a working worker — for tests + /// that don't care about Hook responses and just need *a* command + /// (Ping never touches the worker at all). + fn broken_worker_cmd() -> WorkerCommand { + WorkerCommand::shell("true") + } + + /// Owns a running test server + the worker it supervises. Shuts the + /// server down and joins its thread on drop — including during an + /// unwind from a failed `assert!`/`.unwrap()` — so a failing test + /// cleans up its spawned worker process (via `Worker::drop`'s + /// process-group kill, see worker.rs) exactly as reliably as a passing + /// one. Before this guard existed, a panic between `start_test_server` + /// and the manual `shutdown.store` + `handle.join()` at the end of a + /// test permanently orphaned the server thread and its live `bun` + /// worker subprocess — reproduced live: three orphaned + /// `failproofai-worker.mjs` processes were still running, minutes + /// later, from earlier iterations of this very test file. + struct TestServerGuard { + shutdown: Arc, + handle: Option>, + } + + impl Drop for TestServerGuard { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + fn start_test_server_with_worker( + socket_path: PathBuf, + worker_cmd: WorkerCommand, + ) -> TestServerGuard { + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = shutdown.clone(); + let worker_socket_path = temp_socket_path("worker-internal"); + let handle = std::thread::spawn(move || { + let worker = Arc::new(crate::worker::Worker::new(worker_socket_path, worker_cmd)); + let server = Server::bind(&socket_path, worker).expect("bind should succeed"); + server + .run_until(shutdown_clone) + .expect("run_until should not error"); + }); + // Give the background thread a moment to actually bind before the + // test tries to connect. + std::thread::sleep(Duration::from_millis(50)); + TestServerGuard { + shutdown, + handle: Some(handle), + } + } + + fn start_test_server(socket_path: PathBuf) -> TestServerGuard { + start_test_server_with_worker(socket_path, broken_worker_cmd()) + } + + #[test] + fn ping_gets_pong() { + let socket_path = temp_socket_path("ping"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + } + + #[test] + fn hook_request_gets_an_error_when_the_worker_cannot_be_reached() { + let socket_path = temp_socket_path("hook-broken-worker"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin: "{}".to_string(), + cwd: None, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { .. } => {} + other => panic!("expected Error, got {other:?}"), + } + } + + /// The real end-to-end path: a real `failproofaid` server relaying a + /// real Hook request to the real TypeScript worker (spawned via `bun` + /// against this repo's own `bin/failproofai-worker.mjs`), which runs + /// the actual, unmodified policy-evaluation engine. Proves Rust and TS + /// sides of the wire protocol actually agree, not just that each side's + /// own unit tests pass in isolation. + #[test] + fn relays_a_hook_request_to_the_real_typescript_worker_end_to_end() { + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crates/failproofaid should be two levels under the repo root") + .to_path_buf(); + let worker_script = repo_root.join("bin").join("failproofai-worker.mjs"); + assert!( + worker_script.exists(), + "expected {worker_script:?} to exist" + ); + + // A real project dir with block-sudo enabled, so the response + // proves real policy evaluation ran, not just that SOME response + // came back. + let project_dir = std::env::temp_dir().join(format!( + "failproofaid-e2e-project-{}-{}", + std::process::id(), + fastrand_ish() + )); + std::fs::create_dir_all(project_dir.join(".failproofai")).unwrap(); + std::fs::write( + project_dir + .join(".failproofai") + .join("policies-config.json"), + r#"{"enabledPolicies":["block-sudo"]}"#, + ) + .unwrap(); + + let socket_path = temp_socket_path("hook-real-worker"); + let worker_cmd = WorkerCommand::shell(format!("bun {}", worker_script.display())); + let _guard = start_test_server_with_worker(socket_path.clone(), worker_cmd); + + let stdin = serde_json::json!({ + "cwd": project_dir.to_string_lossy(), + "tool_name": "Bash", + "tool_input": { "command": "sudo rm -rf /" }, + }) + .to_string(); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin, + cwd: Some(project_dir.to_string_lossy().to_string()), + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::HookResult { + exit_code, stdout, .. + } => { + // Claude's PreToolUse deny contract is JSON on stdout at + // exit 0 (hookSpecificOutput.permissionDecision), not a + // nonzero exit code. + assert_eq!(exit_code, 0); + assert!( + stdout.contains("\"permissionDecision\":\"deny\""), + "expected a real deny from block-sudo, got stdout: {stdout}" + ); + } + other => panic!("expected HookResult, got {other:?}"), + } + + std::fs::remove_dir_all(&project_dir).ok(); + } + + #[test] + fn mismatched_protocol_version_gets_an_explicit_error() { + let socket_path = temp_socket_path("version-mismatch"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION + 999, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { message, .. } => { + assert!(message.contains("version")); + } + other => panic!("expected Error, got {other:?}"), + } + } + + #[test] + fn bind_replaces_a_stale_socket_file() { + let socket_path = temp_socket_path("stale"); + // Simulate a leftover file from a crashed daemon: not even a valid + // socket, just a regular file at that path. + std::fs::write(&socket_path, b"not a socket").unwrap(); + + let worker = Arc::new(crate::worker::Worker::new( + temp_socket_path("stale-worker"), + broken_worker_cmd(), + )); + let server = Server::bind(&socket_path, worker).expect("bind should clear the stale file"); + drop(server); + assert!( + !socket_path.exists(), + "Drop should clean up the socket file" + ); + } + + #[test] + fn bound_socket_file_is_owner_only() { + let socket_path = temp_socket_path("perms"); + let worker = Arc::new(crate::worker::Worker::new( + temp_socket_path("perms-worker"), + broken_worker_cmd(), + )); + let server = Server::bind(&socket_path, worker).unwrap(); + let mode = std::fs::metadata(&socket_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + drop(server); + } + + #[test] + fn multiple_concurrent_pings_all_get_answered() { + let socket_path = temp_socket_path("concurrent"); + let _guard = start_test_server(socket_path.clone()); + + let clients: Vec<_> = (0..8) + .map(|_| { + let path = socket_path.clone(); + std::thread::spawn(move || { + let mut stream = UnixStream::connect(&path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + }) + }) + .collect(); + for c in clients { + c.join().unwrap(); + } + } +} diff --git a/crates/failproofaid/src/worker.rs b/crates/failproofaid/src/worker.rs new file mode 100644 index 00000000..e87fd8f8 --- /dev/null +++ b/crates/failproofaid/src/worker.rs @@ -0,0 +1,400 @@ +//! Spawns and supervises the warm Node/Bun worker process, and relays +//! `Hook` requests to it. +//! +//! The worker speaks a SEPARATE, simpler internal protocol on its own +//! socket (no `protocolVersion` — this process always spawns a +//! version-matched worker, so there's nothing to negotiate) — this module +//! is the only thing that talks to it, and it's the one place that +//! translates between that internal protocol and the client-facing +//! [`fpai_ipc::ServerMessage`] envelope (which DOES carry `protocolVersion`, +//! since that one crosses a real compatibility boundary against whatever +//! `failproofai` CLI version happens to be installed). + +use fpai_ipc::framing::{read_message, write_message}; +use serde_json::json; +use std::io; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub enum WorkerError { + Io(io::Error), + /// The worker never created its socket within the startup deadline. + StartupTimedOut, + /// A response arrived but wasn't a well-formed hookResult/error. + BadResponse(String), +} + +impl std::fmt::Display for WorkerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WorkerError::Io(e) => write!(f, "worker io error: {e}"), + WorkerError::StartupTimedOut => write!(f, "worker did not start in time"), + WorkerError::BadResponse(s) => write!(f, "unexpected worker response: {s}"), + } + } +} + +impl std::error::Error for WorkerError {} + +#[derive(Debug)] +pub struct HookOutcome { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +/// How to launch the worker process. `FAILPROOFAI_WORKER_CMD` (dev/test +/// override — a full shell command string, run via `sh -c`) always takes +/// precedence; otherwise falls back to `node