From fd9cf9980a496722d6fc06b712df1a7618c0333c Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 7 Aug 2026 15:29:04 +0530 Subject: [PATCH 01/11] chore(wizard): name the tool in the setup intro instead of calling it a safety net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "let's set up your safety net" was the first line of `failproofai config`, and it says nothing a first-time user can act on: it names no tool, describes no step, and leaves them guessing what the next four screens will change on their machine. The wizard installs hook entries across up to twelve agent CLIs and a root-owned system service — an opening line that reaches for a metaphor instead of naming the thing is at odds with what follows. "let's set up failproofai" is what the command actually does. This was the only occurrence in the wizard. The word survives in `src/audit/archetypes.ts` and `src/audit/findings.ts`, which are user-facing audit copy written in a deliberate persona voice — a separate call, left alone here rather than swept up mechanically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BDLTPtbQvUE62eCfQrUbf --- CHANGELOG.md | 1 + src/hooks/configure-wizard.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0108aee1..774aebbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes - Make `failproofai config` refuse setup on an unsupported platform (Windows, today) instead of completing it unenforced. The wizard used to skip the daemon requirement and finish anyway, leaving the machine reading as configured while enforcing in-process with no fail-closed guarantee — now it prints why and exits 1 before drawing a single prompt, writing nothing. (#664) +- Drop the "safety net" metaphor from the setup wizard's intro, which now names the tool it is setting up. A metaphor tells a first-time user nothing about what the next four steps will do to their machine, and this is the first line they see. (#PR) ## 1.0.0-beta.12 — 2026-08-07 diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index eeae2357..d004e1c8 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -763,7 +763,7 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise { outro("Cancelled — nothing was changed.", { ok: false }, stdout); From d0132a8e491522fb519d15c1efb72ef3f7f7cc62 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 7 Aug 2026 18:22:50 +0530 Subject: [PATCH 02/11] fix(cli): five setup-flow defects, and add `failproofai flush` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a live run-through of `failproofai config`. **The API-key prompt printed one copy of itself per character.** `\r\x1b[2K` erases the row the cursor is on and nothing above it. `API key for ` plus the masked value plus the `needs events:add · policies:pull …` hint is past 80 columns before the key is half typed, so the line WRAPPED, the erase reached only its last row, and every keystroke left the previous row on screen. Pasting a 40-character key stacked 40 prompts down the terminal. The prompt now truncates to one physical row. The regression test is red-proven: without the fix it renders 88 columns into an 80-column terminal. **Cloud was offered second and "stay local" was preselected**, which is not what most people running the wizard came to do. The two options are swapped; neither option's copy changed, so staying local is still stated as plainly as it was and is one keystroke away. **"AI assistants" is a word no other surface uses.** The wizard protects agent CLIs — harnesses. Four user-facing strings, including the `12 assistants` count on the closing line. **`--help` did not mention `backfill` at all**, nor any of the `config` cloud flags (`--connect`, `--token`, `--machine-id`, `--machine-label`, `--no-transcripts`, `--disconnect`, `--status`, `--pause`, `--resume`). A flag nobody can discover is a flag that does not exist. **`failproofai flush` is new.** The collector is unhurried by design — swept once older than two minutes, at most 64 per pass, every 60 seconds — pacing that keeps a backlog from stampeding the server and is exactly wrong for somebody watching a dashboard for their own events, where "not delivered yet" and "not working" look identical. `flush` asks the daemon for a pass with no minimum age and no cap; `--wait` blocks until the spool drains so a script can flush and then assert. It hands off rather than doing the work, for the same reason `backfill` does: the uploader's concurrency limiter and in-flight set live in the running daemon, so a second uploader started by the CLI would POST every batch twice. It does NOT hand off the checking — collection off, no credential, daemon stopped are each verified synchronously, because a CLI that prints "requested" while the daemon is down has told the user the opposite of what happened. The daemon side is a flag rather than a channel: the sweeper is rebuilt whenever the collector cycles, and a receiver would go with it, dropping a request already taken off disk. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BDLTPtbQvUE62eCfQrUbf --- CHANGELOG.md | 7 + __tests__/hooks/configure-wizard.test.ts | 2 +- __tests__/hooks/flush-cli.test.ts | 140 +++++++++++++++++++ __tests__/hooks/tui.test.ts | 48 +++++++ bin/failproofai.mjs | 101 +++++++++++++- crates/failproofaid/src/main.rs | 37 +++++ crates/failproofaid/src/paths.rs | 9 ++ crates/fpai-collect/src/delivery.rs | 36 ++++- crates/fpai-collect/tests/delivery.rs | 8 +- src/hooks/configure-wizard.ts | 30 +++-- src/hooks/flush-cli.ts | 163 +++++++++++++++++++++++ src/hooks/tui.ts | 13 +- 12 files changed, 572 insertions(+), 22 deletions(-) create mode 100644 __tests__/hooks/flush-cli.test.ts create mode 100644 src/hooks/flush-cli.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 774aebbd..9abb4d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,15 @@ ## 1.0.0-beta.13 — 2026-08-07 +### Features +- Add `failproofai flush` — deliver what is already spooled, now. The collector is unhurried on purpose (a batch is swept once it is older than two minutes, at most 64 per pass, on a 60-second cadence), which is right for a backlog and exactly wrong for somebody standing at a dashboard waiting to see their own events: from there "not delivered yet" and "not working" look identical. The command asks the daemon for a pass with no minimum age and no cap, and `--wait` blocks until the spool drains so a script can flush and then assert. It re-sends nothing — for history the collector already read past, that is still `backfill`. (#PR) + ### Fixes - Make `failproofai config` refuse setup on an unsupported platform (Windows, today) instead of completing it unenforced. The wizard used to skip the daemon requirement and finish anyway, leaving the machine reading as configured while enforcing in-process with no fail-closed guarantee — now it prints why and exits 1 before drawing a single prompt, writing nothing. (#664) +- Stop the API-key prompt printing one copy of itself per character typed. `\r\x1b[2K` erases the row the cursor is on and nothing above it, so a line wider than the terminal wrapped, the erase reached only its last row, and every keystroke left the previous rows behind — pasting a 40-character key stacked 40 prompts down the screen. The prompt now truncates to one physical row. (#PR) +- Offer the cloud connection first in the setup wizard, and preselect it. Connecting is what most people running the wizard came to do; staying local is one keystroke away and neither option's copy changed. (#PR) +- Say "harnesses" rather than "AI assistants" throughout setup — the wizard protects agent CLIs, and the word it used for them matched no other surface. (#PR) +- Document the commands and flags that `--help` never mentioned: `backfill` (absent entirely, with `--since` and `--dry-run`), the new `flush`, and the whole `config` cloud surface — `--connect`, `--token`, `--machine-id`, `--machine-label`, `--no-transcripts`, `--disconnect`, `--status`, `--pause`, `--resume`. A flag nobody can discover is a flag that does not exist. (#PR) - Drop the "safety net" metaphor from the setup wizard's intro, which now names the tool it is setting up. A metaphor tells a first-time user nothing about what the next four steps will do to their machine, and this is the first line they see. (#PR) ## 1.0.0-beta.12 — 2026-08-07 diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index cf34d52a..07000f4b 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -611,7 +611,7 @@ describe("scope-aware assistant selection", () => { expect(message).toContain("Setup complete"); // 3 columns of gutter ("└ ") sit in front of it when rendered. expect(message.length + 3).toBeLessThanOrEqual(80); - expect(message).toContain("assistants"); // the tail survived + expect(message).toContain("harnesses"); // the tail survived }); it("applies to only the scope-supported CLIs when Everything available is ticked", async () => { diff --git a/__tests__/hooks/flush-cli.test.ts b/__tests__/hooks/flush-cli.test.ts new file mode 100644 index 00000000..ba4628b5 --- /dev/null +++ b/__tests__/hooks/flush-cli.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/fp-config", () => ({ + readConfig: vi.fn(() => ({ collector: { hooks: true, sessions: true } })), +})); +vi.mock("../../src/hooks/collector-config", () => ({ + readIngestCredential: vi.fn(() => ({ url: "http://localhost:3000/v1/events", key: "k" })), +})); +vi.mock("../../src/hooks/daemon-service", () => ({ + daemonServiceStatus: vi.fn(() => "running"), + isDaemonSupportedPlatform: vi.fn(() => true), +})); + +import { runFlushCommand, pendingBatches, flushRequestPath } from "../../src/hooks/flush-cli"; +import { readConfig } from "../../src/hooks/fp-config"; +import { readIngestCredential } from "../../src/hooks/collector-config"; +import { daemonServiceStatus } from "../../src/hooks/daemon-service"; + +let home: string; + +const spool = (name: string, files: string[]) => { + // `home` is the HOME dir; the failproofai home is `/.failproofai`. + const dir = join(home, ".failproofai", "state", "spool", name); + mkdirSync(dir, { recursive: true }); + for (const f of files) writeFileSync(join(dir, f), "{}\n"); +}; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-flush-")); + vi.mocked(readConfig).mockReturnValue({ collector: { hooks: true, sessions: true } } as never); + vi.mocked(readIngestCredential).mockReturnValue({ url: "u", key: "k" } as never); + vi.mocked(daemonServiceStatus).mockReturnValue("running" as never); +}); +afterEach(() => rmSync(home, { recursive: true, force: true })); + +describe("pendingBatches", () => { + it("counts .jsonl across every source dir and ignores half-written .tmp", () => { + spool("claude", ["a.jsonl", "b.jsonl", "c.tmp"]); + spool("hooks", ["d.jsonl"]); + expect(pendingBatches(home)).toBe(3); + }); + + it("skips failed/, which is the parked-batch retry lane, not pending delivery", () => { + spool("claude", ["a.jsonl"]); + spool("failed", ["parked.jsonl", "parked2.jsonl"]); + expect(pendingBatches(home)).toBe(1); + }); + + it("is zero when nothing has ever spooled", () => { + expect(pendingBatches(home)).toBe(0); + }); +}); + +describe("runFlushCommand preconditions", () => { + it("refuses when collection is off, and does not write a request", async () => { + vi.mocked(readConfig).mockReturnValue({ collector: { hooks: false, sessions: false } } as never); + const r = await runFlushCommand({ home }); + expect(r.exitCode).toBe(1); + expect(r.lines[0]).toContain("Collection is off"); + expect(existsSync(flushRequestPath(home))).toBe(false); + }); + + it("refuses when the machine is not connected", async () => { + vi.mocked(readIngestCredential).mockReturnValue(null as never); + const r = await runFlushCommand({ home }); + expect(r.exitCode).toBe(1); + expect(r.lines[0]).toContain("not connected"); + expect(existsSync(flushRequestPath(home))).toBe(false); + }); + + it("refuses when the daemon is not running — it is what delivers", async () => { + spool("claude", ["a.jsonl"]); + vi.mocked(daemonServiceStatus).mockReturnValue("stopped" as never); + const r = await runFlushCommand({ home }); + expect(r.exitCode).toBe(1); + expect(r.lines[0]).toContain("stopped"); + // A request the daemon will never read is worse than no request: it sits + // on disk and fires whenever the daemon next starts, long after the ask. + expect(existsSync(flushRequestPath(home))).toBe(false); + }); + + it("succeeds without writing a request when nothing is spooled", async () => { + const r = await runFlushCommand({ home }); + expect(r.exitCode).toBe(0); + expect(r.lines[0]).toContain("Nothing spooled"); + expect(existsSync(flushRequestPath(home))).toBe(false); + }); +}); + +describe("runFlushCommand request", () => { + it("writes the request when batches are pending", async () => { + spool("claude", ["a.jsonl", "b.jsonl"]); + const r = await runFlushCommand({ home }); + expect(r.exitCode).toBe(0); + expect(r.pending).toBe(2); + expect(r.lines[0]).toContain("2 batches spooled"); + expect(existsSync(flushRequestPath(home))).toBe(true); + }); + + it("says 'batch' not 'batches' for one", async () => { + spool("claude", ["a.jsonl"]); + const r = await runFlushCommand({ home }); + expect(r.lines[0]).toContain("1 batch spooled"); + expect(r.lines[0]).not.toContain("batches"); + }); + + it("--wait returns 0 once the spool drains", async () => { + spool("claude", ["a.jsonl"]); + let ticks = 0; + const sleep = async () => { + // Simulate the daemon delivering on the second poll. + if (++ticks === 2) + rmSync(join(home, ".failproofai", "state", "spool", "claude"), { recursive: true }); + }; + const r = await runFlushCommand({ home, wait: true, timeoutSecs: 10, sleep }); + expect(r.exitCode).toBe(0); + expect(r.pending).toBe(0); + expect(r.lines.at(-1)).toBe("Spool drained."); + }); + + it("--wait times out non-zero, and says delivery continues rather than calling it broken", async () => { + spool("claude", ["a.jsonl"]); + // Never drains. Advance the clock so the deadline passes without real waiting. + const realNow = Date.now; + let t = realNow(); + vi.spyOn(Date, "now").mockImplementation(() => t); + const sleep = async () => { + t += 1000; + }; + const r = await runFlushCommand({ home, wait: true, timeoutSecs: 3, sleep }); + vi.mocked(Date.now).mockRestore(); + expect(r.exitCode).toBe(1); + expect(r.pending).toBe(1); + expect(r.lines.join(" ")).toContain("Delivery continues in the background"); + }); +}); diff --git a/__tests__/hooks/tui.test.ts b/__tests__/hooks/tui.test.ts index b58d8389..f0c657d2 100644 --- a/__tests__/hooks/tui.test.ts +++ b/__tests__/hooks/tui.test.ts @@ -5,6 +5,7 @@ import { ellipsize, summarize, renderBrandLogo, + promptText, type TTYIn, type TTYOut, } from "../../src/hooks/tui"; @@ -139,3 +140,50 @@ describe("brand logomark", () => { expect(lines).toHaveLength(1); }); }); + +describe("promptText redraw stays on one physical row", () => { + // Regression: `\r\x1b[2K` erases only the row the cursor is on. A composed + // line wider than the terminal WRAPS, so the erase misses the earlier rows + // and every keystroke leaves one behind — pasting a 40-character API key + // printed 40 stacked copies of the prompt. + const drawnRows = (cols: number, message: string, hint: string, typed: number) => { + const writes: string[] = []; + const stdout = { + isTTY: true, + columns: cols, + write: vi.fn((s: string) => { writes.push(s); return true; }), + } as unknown as TTYOut; + let onKey: ((s: string | undefined, k: unknown) => void) | undefined; + const stdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: vi.fn((ev: string, fn: never) => { if (ev === "keypress") onKey = fn; }), + removeListener: vi.fn(), + } as unknown as TTYIn; + + void promptText({ message, hint, mask: true, stdin, stdout }); + for (let i = 0; i < typed; i++) onKey?.("x", { name: "x" }); + + // Widest single write, measured without ANSI, is the widest row rendered. + const widest = Math.max( + ...writes.map((w) => w.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "").length), + ); + return widest; + }; + + it("never renders wider than the terminal, even with a long hint and a long value", () => { + const widest = drawnRows( + 80, + "API key for localhost:3000", + "needs events:add · policies:pull enables managed policy too", + 40, + ); + expect(widest).toBeLessThanOrEqual(80); + }); + + it("holds at a narrow width too", () => { + expect(drawnRows(40, "API key for localhost:3000", "needs events:add", 40)).toBeLessThanOrEqual(40); + }); +}); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 6c70e835..c5a603c9 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -271,7 +271,7 @@ if (hookIdx >= 0) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "policy", "audit", "config", "uninstall", "backfill"]; + const SUBCOMMANDS = ["policies", "policy", "audit", "config", "uninstall", "backfill", "flush"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -286,6 +286,13 @@ USAGE COMMANDS (no args) Launch the policy dashboard config Interactive setup — pick scope, agents & policies + --connect --token Connect to Failproof Cloud non-interactively + --machine-id Stable id for this machine + --machine-label Human-readable name in the dashboard + --no-transcripts Report decisions only, never transcripts + --disconnect Stop pulling policy and sending activity + --status Show connection, daemon and pause state + --pause / --resume Pause or resume enforcement policy add Enable a single policy (see \`policy --help\`) policy remove Disable a single policy @@ -316,6 +323,18 @@ COMMANDS dashboard at http://localhost:8020/audit audit --help, -h Show this help for the audit command + backfill Re-send history the collector already read past + — after clearing the dashboard, re-enrolling a + machine, or connecting later than the work + --since How far back: 30d, 6m, or YYYY-MM-DD + (default: 30 days) + --dry-run Report what would be re-read, change nothing + + flush Deliver everything already spooled, now, + instead of waiting for the next sweep + --wait Block until the spool drains (or --timeout) + --timeout How long to wait with --wait (default: 60) + uninstall Remove failproofai from this machine: hook entries from every agent CLI, and the daemon service. Run this BEFORE \`npm rm -g failproofai\` @@ -355,6 +374,10 @@ EXAMPLES failproofai policies --uninstall --cli opencode failproofai policies --uninstall --cli pi failproofai policies --uninstall --custom + failproofai backfill --since 6m + failproofai backfill --dry-run + failproofai flush --wait + failproofai config --status LINKS ⭐ Star us: https://github.com/failproofai/failproofai @@ -487,6 +510,72 @@ LINKS // over. Every precondition a person can get wrong is still checked HERE, // synchronously, because reporting success and leaving the real failure in the // journal is what already cost twenty minutes on a live machine. + if (args[0] === "flush") { + const subArgs = args.slice(1); + if (subArgs.includes("--help") || subArgs.includes("-h")) { + console.log(` +failproofai flush — deliver what is already spooled, now + +USAGE + failproofai flush [--wait] [--timeout ] + +WHY + The collector is unhurried on purpose: a batch is swept once it is older than + two minutes, at most 64 per pass, on a 60-second cadence. That pacing keeps a + backlog from stampeding the server, and it is exactly wrong when you are + standing at a dashboard waiting to see your own events — "not delivered yet" + and "not working" look identical from there. + + This asks the daemon to make a pass right now, with no minimum age and no + per-pass cap. It re-sends nothing: only batches already spooled and not yet + delivered. For history the collector has already read past, use \`backfill\`. + +OPTIONS + --wait Block until the spool drains, or --timeout elapses. + --timeout How long --wait waits. Default: 60. +`); + process.exit(0); + } + + const KNOWN = new Set(["--wait", "--timeout"]); + const unknown = subArgs.find( + (a, i) => a.startsWith("-") && !KNOWN.has(a) && subArgs[i - 1] !== "--timeout", + ); + if (unknown) { + throw new CliError(`Unexpected argument: ${unknown}\nRun \`failproofai flush --help\` for usage.`); + } + + let timeoutSecs; + const tIdx = subArgs.indexOf("--timeout"); + if (tIdx >= 0) { + const raw = subArgs[tIdx + 1]; + if (!raw || raw.startsWith("-")) throw new CliError("Missing value after --timeout."); + const n = Number(raw); + // Rejected rather than coerced: NaN would silently become "wait forever + // or not at all" depending on the comparison, and neither is what was asked. + if (!Number.isFinite(n) || n <= 0) { + throw new CliError(`Could not read --timeout ${raw}. Give a number of seconds.`); + } + timeoutSecs = n; + } + + lastSubcommand = "flush"; + const { runFlushCommand } = await import("../src/hooks/flush-cli"); + const result = await runFlushCommand({ wait: subArgs.includes("--wait"), timeoutSecs }); + for (const line of result.lines) { + if (result.exitCode === 0) console.log(line); + else console.error(line); + } + await track("cli_flush", { + ok: result.exitCode === 0, + waited: subArgs.includes("--wait"), + pending: result.pending, + }); + lastSubcommand = null; + await exitAfterFlush(result.exitCode); + return; + } + if (args[0] === "backfill") { const subArgs = args.slice(1); if (subArgs.includes("--help") || subArgs.includes("-h")) { @@ -1115,7 +1204,7 @@ USAGE WHAT IT DOES Walks you through 4 quick steps and writes everything for you: 1. Where — global (all projects) or just this project - 2. Assistants — which agent CLIs to protect (Claude, Codex, ...) + 2. Harnesses — which agent CLIs to protect (Claude, Codex, ...) 3. Policies — presets (combine any), Everything, or a custom pick 4. Review — confirms the exact files it will change, then applies @@ -1124,7 +1213,13 @@ FAILPROOF CLOUD Connect this machine to Failproof Cloud [--no-transcripts] decisions only, no transcripts failproofai config --disconnect Stop pulling policy and sending activity - failproofai config --status Show connection and pause state + failproofai config --status Show connection, daemon and pause state + failproofai config --pause [--session ] + Pause enforcement, time-boxed + failproofai config --resume [--all] + Resume enforcement + + --machine-label Human-readable name in the dashboard One connection, two capabilities: this machine PULLS centrally-managed policies and SENDS what its hooks decided, so the dashboard shows the fleet diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index ed080590..5e43d89e 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -354,6 +354,14 @@ fn spawn_collector_manager( continue; } + // Cheap by comparison with a backfill: nothing is rewound and + // the collector keeps running. Just tell the sweeper to stop + // waiting out its interval. + if take_flush_request() { + flush_flag().store(true, Ordering::SeqCst); + tracing::info!("flush requested; delivering spooled batches now"); + } + let next = current_collector_config(); // `None` means unreadable, not "disabled". A half-written file // caught mid-save would otherwise tear down a healthy collector @@ -488,6 +496,34 @@ fn file_source_since_days() -> Option { /// CLI is the only writer, and it writes this file atomically, so a malformed /// one means a hand-edit or a truncated disk rather than a race worth waiting /// out. +/// Set by the maintenance tick when a `failproofai flush` request lands, and +/// swapped back to false by the spool sweeper when it has done the pass. +/// +/// A flag rather than a channel because the sweeper is rebuilt whenever the +/// collector cycles (a config change, a backfill) and a channel receiver would +/// go with it — dropping a request that had already been taken off disk. +static FLUSH_NOW: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +fn flush_flag() -> Arc { + FLUSH_NOW + .get_or_init(|| Arc::new(std::sync::atomic::AtomicBool::new(false))) + .clone() +} + +/// True when a flush was requested. Removed before acting, like the backfill +/// request, so a panic mid-pass cannot re-trigger it forever. +fn take_flush_request() -> bool { + let Ok(path) = paths::flush_request_path() else { + return false; + }; + if !path.exists() { + return false; + } + let _ = std::fs::remove_file(&path); + true +} + fn take_backfill_request() -> Option { let path = paths::backfill_request_path().ok()?; let raw = std::fs::read_to_string(&path).ok()?; @@ -917,6 +953,7 @@ fn collector_tasks() -> Vec { sweep_dirs.clone(), failed_dir.clone(), sd, + flush_flag(), ) }), ]); diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs index c79bbafa..7a113e92 100644 --- a/crates/failproofaid/src/paths.rs +++ b/crates/failproofaid/src/paths.rs @@ -123,6 +123,15 @@ pub fn backfill_request_path() -> io::Result { .join("backfill-request.json")) } +/// Where `failproofai flush` leaves its request. Same hand-off shape as the +/// backfill request: the CLI cannot deliver spooled batches itself (the +/// uploader's concurrency limiter and in-flight set live in the running +/// daemon, and a second uploader would POST the same files twice), so it +/// writes a request the daemon drains on its next tick. +pub fn flush_request_path() -> io::Result { + Ok(failproofai_home()?.join("state").join("flush-request.json")) +} + pub fn audit_schedule_path() -> io::Result { Ok(failproofai_home()? .join("state") diff --git a/crates/fpai-collect/src/delivery.rs b/crates/fpai-collect/src/delivery.rs index 0cbf040c..8027cb54 100644 --- a/crates/fpai-collect/src/delivery.rs +++ b/crates/fpai-collect/src/delivery.rs @@ -29,6 +29,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; @@ -48,6 +49,11 @@ const MAX_CONCURRENT_UPLOADS: usize = 8; /// How often the sweeper scans the spool directories. const SWEEP_INTERVAL: Duration = Duration::from_secs(60); + +/// How often the sweeper looks for a `failproofai flush` request while it is +/// otherwise idle. Short enough that a flush feels immediate, long enough that +/// an idle daemon is not spinning. +const FLUSH_POLL_INTERVAL: Duration = Duration::from_secs(1); /// How old a batch must be before the sweeper claims it. /// /// Not politeness — it is what keeps the two paths from racing on a file the @@ -219,6 +225,7 @@ pub async fn sweep( dirs: Vec, failed_dir: PathBuf, sd: Shutdown, + flush_now: Arc, ) -> Result<(), TaskError> { // Run one pass immediately. A cold start must not wait a full interval to // deliver what accumulated while the daemon was stopped, and a filesystem @@ -241,8 +248,33 @@ pub async fn sweep( next_failed_pass = SystemTime::now() + FAILED_RETRY_INTERVAL; } - if !sd.sleep(SWEEP_INTERVAL).await { - return Ok(()); + // Poll for a flush request rather than sleeping the whole interval in + // one go. `failproofai flush` exists because the guarantees that make + // the sweeper safe in steady state — only touch batches older than + // SWEEP_MIN_AGE, at most SWEEP_MAX_FILES per pass — are exactly wrong + // for someone standing at a dashboard waiting for their own events. + let deadline = SystemTime::now() + SWEEP_INTERVAL; + loop { + if flush_now.swap(false, Ordering::SeqCst) { + // A flush pass: no minimum age, no per-pass cap. This is the + // one caller that has explicitly asked to trade the backlog + // pacing for latency. + for dir in &dirs { + for path in stale_batches(dir, Duration::ZERO, usize::MAX).await { + if sd.is_set() { + return Ok(()); + } + delivery.deliver(path).await; + } + } + break; + } + if SystemTime::now() >= deadline { + break; + } + if !sd.sleep(FLUSH_POLL_INTERVAL).await { + return Ok(()); + } } } } diff --git a/crates/fpai-collect/tests/delivery.rs b/crates/fpai-collect/tests/delivery.rs index d7ac5b0f..a4c56d91 100644 --- a/crates/fpai-collect/tests/delivery.rs +++ b/crates/fpai-collect/tests/delivery.rs @@ -250,7 +250,13 @@ async fn watcher_and_sweeper_run_under_the_supervisor_and_stop_on_shutdown() { fpai_collect::delivery::watch(dw.clone(), vec![sw.clone()], sd) }), TaskSpec::new("spool-sweeper", move |sd| { - fpai_collect::delivery::sweep(ds.clone(), vec![ss.clone()], fs_dir.clone(), sd) + fpai_collect::delivery::sweep( + ds.clone(), + vec![ss.clone()], + fs_dir.clone(), + sd, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) }), ], shutdown.clone(), diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index d004e1c8..23975654 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -432,7 +432,7 @@ export function describeCustomPolicies(cwd: string): { */ export function buildCompletionSummary( policiesCount: number, - assistantsCount: number, + harnessesCount: number, customEnabled: boolean | undefined, daemonInstalled: boolean, connected: boolean, @@ -443,8 +443,8 @@ export function buildCompletionSummary( if (daemonInstalled) extras.push("daemon"); if (connected) extras.push("reporting"); const extrasNote = extras.length > 0 ? ` · ${extras.join(", ")}` : ""; - const assistants = `${assistantsCount} assistant${assistantsCount === 1 ? "" : "s"}`; - return `Setup complete — ${policiesCount} policies · ${assistants}${extrasNote}`; + const harnesses = `${harnessesCount} harness${harnessesCount === 1 ? "" : "es"}`; + return `Setup complete — ${policiesCount} policies · ${harnesses}${extrasNote}`; } export function reviewLines(state: { @@ -473,9 +473,9 @@ export function reviewLines(state: { ? `This project (${homeify(cwd)})` : "Everywhere (global)"; const lines: string[] = []; - const assistantNames = clis.map((c) => getIntegration(c).displayName); + const harnessNames = clis.map((c) => getIntegration(c).displayName); lines.push(` Where : ${where}`); - lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`); + lines.push(` Harnesses : ${harnessNames.length ? summarize(harnessNames, "harnesses") : "(none)"}`); // Zero is a deliberate answer, not a failed step — say so, and say where to // change it, so the review screen doesn't read like the wizard lost the // selection. Hooks still install; only the builtin set is empty. @@ -977,7 +977,7 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise({ - message: "Which AI assistants should it protect?", + message: "Which harnesses should it protect?", choices: [ { label: "Everything available", @@ -985,14 +985,14 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise Promise; +} + +export interface FlushResult { + exitCode: number; + lines: string[]; + /** Batches still spooled when we stopped looking. */ + pending: number; +} + +/** Mirrored from the daemon's `paths::flush_request_path()`. */ +export function flushRequestPath(home?: string): string { + return join(failproofaiHome(home), "state", "flush-request.json"); +} + +/** Every directory the collector spools batches into. */ +function spoolDirs(home?: string): string[] { + const root = spoolDir(home); + if (!existsSync(root)) return []; + try { + return readdirSync(root, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name !== "failed") + .map((e) => join(root, e.name)); + } catch { + return []; + } +} + +/** Batches awaiting delivery. `.tmp` files are half-written and not counted. */ +export function pendingBatches(home?: string): number { + let n = 0; + for (const dir of spoolDirs(home)) { + try { + n += readdirSync(dir).filter((f) => f.endsWith(".jsonl")).length; + } catch { + // A directory that vanished mid-scan is one the collector just drained. + } + } + return n; +} + +export async function runFlushCommand(opts: FlushOptions = {}): Promise { + const { home, wait = false } = opts; + const timeoutSecs = opts.timeoutSecs ?? DEFAULT_FLUSH_TIMEOUT_SECS; + const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const lines: string[] = []; + + // Preconditions, in the order that produces the most useful message. Each of + // these makes a flush a no-op, and each has a different remedy. + const cfg = readConfig(); + if (!cfg.collector?.hooks && !cfg.collector?.sessions) { + return { + exitCode: 1, + pending: 0, + lines: [ + "Collection is off, so nothing is spooled to flush.", + "Turn it on with `failproofai config`.", + ], + }; + } + + if (!readIngestCredential()) { + return { + exitCode: 1, + pending: 0, + lines: [ + "This machine is not connected, so there is nowhere to flush to.", + "Connect it with `failproofai config`.", + ], + }; + } + + if (isDaemonSupportedPlatform()) { + const status = daemonServiceStatus(); + if (status !== "running") { + return { + exitCode: 1, + pending: pendingBatches(home), + lines: [ + `failproofaid is ${status}, and it is what delivers batches.`, + "Start it with `failproofai config`.", + ], + }; + } + } + + const before = pendingBatches(home); + if (before === 0) { + return { exitCode: 0, pending: 0, lines: ["Nothing spooled — everything already delivered."] }; + } + + const path = flushRequestPath(home); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify({ requestedAtMs: Date.now() }, null, 2)}\n`, { + mode: 0o600, + }); + + lines.push(`${before} batch${before === 1 ? "" : "es"} spooled. Requested delivery.`); + + if (!wait) { + lines.push("The daemon picks this up within a few seconds."); + return { exitCode: 0, pending: before, lines }; + } + + // `--wait` exists so a script can flush and then assert. Poll the spool + // rather than the server: the question is whether THIS machine still holds + // events, and the spool is the only place that can answer it locally. + const deadline = Date.now() + timeoutSecs * 1000; + let pending = before; + while (Date.now() < deadline) { + await sleep(1000); + pending = pendingBatches(home); + if (pending === 0) { + lines.push("Spool drained."); + return { exitCode: 0, pending: 0, lines }; + } + } + + // A timeout is not necessarily a failure — a large backlog legitimately takes + // longer than the budget — so say what is still outstanding rather than + // calling it broken. + lines.push( + `Still ${pending} batch${pending === 1 ? "" : "es"} spooled after ${timeoutSecs}s.`, + "Delivery continues in the background; re-run with a longer --timeout to keep watching.", + ); + return { exitCode: 1, pending, lines }; +} diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index 5758d92f..fd52b19f 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -705,10 +705,19 @@ export function promptText(opts: PromptTextOptions): Promise { return new Promise((resolve) => { let value = ""; const draw = (error?: string) => { + const cols = stdout.columns || 80; const shown = opts.mask ? "•".repeat(value.length) : value; const hint = opts.hint ? ` ${c.dim(opts.hint)}` : ""; - const err = error ? `\n ${c.warn(error)}` : ""; - stdout.write(`\r\x1b[2K${c.bold(opts.message)} ${shown}${hint}${err}`); + // Truncate to ONE physical row. `\r\x1b[2K` erases the row the cursor is + // on and nothing above it — so a line wider than the terminal wraps, the + // erase reaches only its last row, and every keystroke leaves the earlier + // rows behind. That is why pasting a 40-character API key printed 40 + // stacked copies of the prompt: `API key for ` plus the masked + // value plus the `needs events:add · policies:pull …` hint is past 80 + // columns before the key is even half typed. + const line = truncate(`${c.bold(opts.message)} ${shown}${hint}`, cols - 1); + const err = error ? `\n ${truncate(c.warn(error), cols - 3)}` : ""; + stdout.write(`\r\x1b[2K${line}${err}`); if (err) stdout.write("\x1b[1A"); }; draw(); From 153636279ebf060721443ce601cfeeeb37e519bc Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 7 Aug 2026 18:37:17 +0530 Subject: [PATCH 03/11] fix(cli): stop the install waiting on a blind sleep, add wizard back-nav, list cloud policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The daemon install slept 750ms for no reason, twice.** Measured on a real machine: systemctl stop/start/enable/daemon-reload are 3-6ms each, the socket appears at 13ms, and a real hook is answered at 125ms. Then `waitForDaemonRunning` slept a flat `SERVICE_SETTLE_MS` and read the status once at the end — wrong in both directions. Healthy: setup sat out the remaining ~600ms with the answer already in hand, and the repair path does this twice (uninstall, reinstall). Broken: a daemon that died at 100ms was not noticed until 750ms, because nothing looked until the sleep was over. The window is now watched rather than slept through. Leaving `running` fails immediately, and the wait ends early once the daemon accepts a connection — a strictly stronger signal of "did not die at startup" than "still active after an arbitrary sleep", which is all the settle ever established. The connect check, not a hook evaluation: `probeDaemon` runs the end-to-end one moments later and paying twice is the thing being fixed. The timing is now injectable, so the tests assert the SHAPE of the wait against a virtual clock instead of spending it: early exit, startup-death detected on the first poll, full window still held when nothing answers, give-up when the unit never starts. **The wizard could not go back.** Cancel and "go back" were both `null`, so changing an earlier answer meant abandoning setup. `←` on the harness step now returns to the policy step with the previous selection still ticked. `BACK` is a symbol, not a sentinel string: a caller's value type is its own and `selectOne` could legitimately carry "back" as a real choice. It reaches the return type through overloads, so the dozens of existing call sites are unchanged rather than widened to handle a value they can never receive. The policy step deliberately takes no `allowBack` — the scope question before it is often not asked at all, so `←` there would sometimes go nowhere. **Cloud-managed policies were invisible to `failproofai policies`.** They enforce on the machine exactly like the builtins and convention policies the command already lists, so it was answering "what is enforcing here?" with a subset — and the policies an operator pushed to a fleet were the ones the person standing at the machine could not see. Listed read-only, because they are owned by the deployment: `--uninstall` cannot switch one off, and printing them beside toggleable rows without saying so would imply it can. `observe` renders OBS rather than ON — its verdict is discarded, so a row claiming enforcement would be claiming something it deliberately is not doing. An unreadable manifest drops the section rather than breaking the listing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BDLTPtbQvUE62eCfQrUbf --- CHANGELOG.md | 3 + __tests__/hooks/configure-wizard.test.ts | 44 ++++++++++- __tests__/hooks/daemon-service.test.ts | 74 ++++++++++++++++++ __tests__/hooks/manager-cloud-listing.test.ts | 70 +++++++++++++++++ src/hooks/configure-wizard.ts | 76 +++++++++++++------ src/hooks/daemon-service.ts | 75 +++++++++++++++--- src/hooks/manager.ts | 35 +++++++++ src/hooks/tui.ts | 45 ++++++++++- 8 files changed, 382 insertions(+), 40 deletions(-) create mode 100644 __tests__/hooks/manager-cloud-listing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9abb4d19..2fb08e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ ### Fixes - Make `failproofai config` refuse setup on an unsupported platform (Windows, today) instead of completing it unenforced. The wizard used to skip the daemon requirement and finish anyway, leaving the machine reading as configured while enforcing in-process with no fail-closed guarantee — now it prints why and exits 1 before drawing a single prompt, writing nothing. (#664) +- Cut roughly a second of dead wait out of installing the daemon. `waitForDaemonRunning` slept a flat 750ms and then read the service status once, which was wrong in both directions: the socket is up in ~13ms and answers a real hook in ~125ms, so a healthy machine sat there with the answer already in hand — twice, because the repair path uninstalls and reinstalls — while a daemon that died at 100ms went unnoticed until the sleep was over. The window is now polled: leaving `running` fails immediately, and the wait ends as soon as the daemon accepts a connection, which is stronger evidence it did not die at startup than "still active after an arbitrary sleep". (#PR) +- Let the setup wizard go back a step. Cancelling and going back were both `null`, so the only way to change an earlier answer was to abandon setup and start over; `←` on the harness step now re-asks the policy step with the previous selection still ticked. The policy step itself offers no `←` — the step before it is frequently not asked at all (a single scope choice is stated, not prompted), so it would sometimes go nowhere. (#PR) +- List cloud-managed policies in `failproofai policies`. They enforce on the machine exactly like builtins and convention policies, and nothing showed them — so the command answered "what is enforcing here?" with a subset, and the policies an operator pushed to a fleet were precisely the ones invisible to the person standing at the machine. `observe` deployments render as OBS rather than ON, because their verdict is discarded, and the section says outright that these are not switchable with `--uninstall`. (#PR) - Stop the API-key prompt printing one copy of itself per character typed. `\r\x1b[2K` erases the row the cursor is on and nothing above it, so a line wider than the terminal wrapped, the erase reached only its last row, and every keystroke left the previous rows behind — pasting a 40-character key stacked 40 prompts down the screen. The prompt now truncates to one physical row. (#PR) - Offer the cloud connection first in the setup wizard, and preselect it. Connecting is what most people running the wizard came to do; staying local is one keystroke away and neither option's copy changed. (#PR) - Say "harnesses" rather than "AI assistants" throughout setup — the wizard protects agent CLIs, and the word it used for them matched no other surface. (#PR) diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 07000f4b..6cf61997 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; -import { summarize } from "../../src/hooks/tui"; +import { summarize, + BACK, +} from "../../src/hooks/tui"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -1258,3 +1260,43 @@ describe("connect step", () => { expect(connectToCloud).not.toHaveBeenCalled(); }); }); + +describe("wizard back-navigation", () => { + it("← on the harness step re-asks the policy step, and carries the answer back in", async () => { + const one = vi.mocked(selectOne); + const many = vi.mocked(multiSelect); + one.mockResolvedValueOnce("user" as never); // scope + many.mockResolvedValueOnce(["secrets", "git"] as never); // policies, 1st pass + many.mockResolvedValueOnce(BACK as never); // harnesses -> ← + many.mockResolvedValueOnce(["secrets"] as never); // policies, re-asked + many.mockResolvedValueOnce(["claude"] as never); // harnesses, 2nd pass + one.mockResolvedValueOnce("local" as never); // connect + one.mockResolvedValueOnce("apply" as never); // review + + await runConfigureWizard(ttyIO()); + + // Four multiSelect calls: policies, harnesses, policies again, harnesses. + expect(many.mock.calls.length).toBe(4); + + // The re-asked policy step must arrive pre-checked with the first answer, + // or a ← silently discards what the user already chose. + const reasked = many.mock.calls[2]![0]; + const checked = reasked.choices.filter((c) => c.checked); + expect(checked.map((c) => String(c.value)).sort()).toEqual(["git", "secrets"]); + }); + + it("the policy step itself offers no ←, because the step before it is often not asked", async () => { + const one = vi.mocked(selectOne); + const many = vi.mocked(multiSelect); + one.mockResolvedValueOnce("user" as never); + many.mockResolvedValueOnce(["git"] as never); + many.mockResolvedValueOnce(["claude"] as never); + one.mockResolvedValueOnce("local" as never); + one.mockResolvedValueOnce("apply" as never); + + await runConfigureWizard(ttyIO()); + + expect(many.mock.calls[0]![0].allowBack).toBeFalsy(); + expect(many.mock.calls[1]![0].allowBack).toBe(true); + }); +}); diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts index f8386fb3..5209ec51 100644 --- a/__tests__/hooks/daemon-service.test.ts +++ b/__tests__/hooks/daemon-service.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir, userInfo } from "node:os"; import { resolve } from "node:path"; import { binDir } from "../../src/hooks/fp-home"; +import { waitForDaemonRunning } from "../../src/hooks/daemon-service"; vi.mock("../../src/hooks/hook-logger", () => ({ hookLogWarn: vi.fn(), @@ -874,3 +875,76 @@ describe("hooks/daemon-service", () => { }, ); }); + +describe("hooks/daemon-service waitForDaemonRunning", () => { + // A virtual clock: `sleep` advances it, so these assert the SHAPE of the wait + // without spending the wall-clock time being asserted about. + const clock = () => { + let t = 0; + return { + now: () => t, + sleep: async (ms: number) => { + t += ms; + }, + elapsed: () => t, + }; + }; + + it("returns as soon as the socket answers, instead of sitting out the settle window", async () => { + const c = clock(); + let calls = 0; + const ok = await waitForDaemonRunning({ + now: c.now, + sleep: c.sleep, + status: () => "running", + // Answers on the second look — the real machine binds its socket in ~13ms. + accepts: async () => ++calls >= 2, + }); + expect(ok).toBe(true); + // The old code slept a flat 750ms here regardless. Anything near that means + // the early exit is gone. + expect(c.elapsed()).toBeLessThan(300); + }); + + it("fails the moment the unit leaves running, rather than waiting the window out", async () => { + const c = clock(); + let looks = 0; + const ok = await waitForDaemonRunning({ + now: c.now, + sleep: c.sleep, + // Active on the first read, dead on the next — the startup-death case the + // settle window exists to catch. + status: () => (++looks <= 1 ? "running" : "stopped"), + accepts: async () => false, + }); + expect(ok).toBe(false); + // Detected on the first poll, not after a 750ms sleep. + expect(c.elapsed()).toBeLessThan(300); + }); + + it("still holds the full window when the socket never answers but the unit stays up", async () => { + const c = clock(); + const ok = await waitForDaemonRunning({ + now: c.now, + sleep: c.sleep, + status: () => "running", + accepts: async () => false, + }); + // Held rather than failed: a unit that stayed active for the whole window + // did not die at startup, which is all the settle ever established. + expect(ok).toBe(true); + expect(c.elapsed()).toBeGreaterThanOrEqual(750); + }); + + it("gives up when the unit never reaches running at all", async () => { + const c = clock(); + const ok = await waitForDaemonRunning({ + now: c.now, + sleep: c.sleep, + status: () => "stopped", + accepts: async () => false, + }); + expect(ok).toBe(false); + expect(c.elapsed()).toBeGreaterThanOrEqual(5000); + }); +}); diff --git a/__tests__/hooks/manager-cloud-listing.test.ts b/__tests__/hooks/manager-cloud-listing.test.ts new file mode 100644 index 00000000..0355dc7f --- /dev/null +++ b/__tests__/hooks/manager-cloud-listing.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/hooks/cloud-managed-policies", () => ({ + readActiveCloudManagedPolicies: vi.fn(), +})); + +import { listHooks } from "../../src/hooks/manager"; +import { readActiveCloudManagedPolicies } from "../../src/hooks/cloud-managed-policies"; + +const readActive = vi.mocked(readActiveCloudManagedPolicies); + +describe("failproofai policies — cloud-managed section", () => { + let out: string[]; + let spy: ReturnType; + + beforeEach(() => { + out = []; + spy = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { + out.push(a.map(String).join(" ")); + }); + }); + afterEach(() => spy.mockRestore()); + + const text = () => out.join("\n").replace(/\x1B\[[0-9;]*m/g, ""); + + it("lists a deployed policy, with its version and the deployment number", async () => { + readActive.mockReturnValue([ + { id: "org-guard", revision: 3, effect: "enforce", sha256: "a", path: "p", generation: 7 }, + ]); + await listHooks(); + expect(text()).toContain("Cloud-managed — deployment 7"); + expect(text()).toContain("org-guard"); + expect(text()).toContain("v3"); + }); + + it("marks an observe policy as OBS, never ON — its verdict is discarded", async () => { + readActive.mockReturnValue([ + { id: "watch-only", revision: 1, effect: "observe", sha256: "a", path: "p", generation: 2 }, + ]); + await listHooks(); + const line = text().split("\n").find((l) => l.includes("watch-only")) ?? ""; + expect(line).toContain("OBS"); + expect(line).not.toContain("✓ ON"); + }); + + it("says these are not switchable locally, because --uninstall cannot touch them", async () => { + readActive.mockReturnValue([ + { id: "org-guard", revision: 1, effect: "enforce", sha256: "a", path: "p", generation: 1 }, + ]); + await listHooks(); + expect(text()).toContain("Managed from the dashboard"); + }); + + it("prints no section at all on a machine with no deployment", async () => { + readActive.mockReturnValue([]); + await listHooks(); + expect(text()).not.toContain("Cloud-managed"); + }); + + it("survives an unreadable manifest rather than breaking the whole listing", async () => { + readActive.mockImplementation(() => { + throw new Error("corrupt manifest"); + }); + await expect(listHooks()).resolves.not.toThrow(); + expect(text()).not.toContain("Cloud-managed"); + // The builtin listing above it must still have printed. + expect(text()).toContain("Failproof AI Hook Policies"); + }); +}); diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts index 23975654..e1099e6a 100644 --- a/src/hooks/configure-wizard.ts +++ b/src/hooks/configure-wizard.ts @@ -33,6 +33,7 @@ import { dirname, resolve, sep } from "node:path"; import { selectOne, multiSelect, + BACK, promptText, intro, outro, @@ -960,23 +961,37 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise({ - message: "What should we guard against?", - choices: presetChoices, - summaryNoun: "bundles", - hint: "space toggles · combine presets · ↵ confirm · none is fine", - stdin, - stdout, - }); - if (presets === null) return cancel(); - const policies = resolvePresetSelection(presets); - // Only meaningful when there are files to switch off; with none, the row is - // locked-unchecked and must not write a disabling flag. - const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined; + // Steps 2 and 3 are navigable: ← on the harness step returns to the policy + // step with the previous answer still selected. Before this, changing an + // earlier answer meant abandoning setup and starting over, because a prompt + // had exactly one way out and it was `null`. + // + // The policy step itself takes no `allowBack`: the only thing before it is + // the scope question, which is frequently not asked at all (a single choice + // is stated, not prompted), so ← there would sometimes go nowhere. + let presets: string[] | null = null; + let clisSel: string[] | null = null; + while (clisSel === null) { + // Re-entering after a ← must show what was picked, not a blank slate. + // Selection state lives on each choice, so carry it back in. + // Loop-carried: narrowed to `null` on the first pass, repopulated on a ←. + const priorPresets = presets as string[] | null; + presets = await multiSelect({ + message: "What should we guard against?", + choices: priorPresets + ? presetChoices.map((c) => ({ ...c, checked: priorPresets.includes(c.value) })) + : presetChoices, + summaryNoun: "bundles", + hint: "space toggles · combine presets · ↵ confirm · none is fine", + stdin, + stdout, + }); + if (presets === null) return cancel(); - // 3 — Which assistants? An "Everything available" row protects every supported - // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes. - const clisSel = await multiSelect({ + // 3 — Which harnesses? An "Everything available" row protects every supported + // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes. + const priorClis = clisSel as string[] | null; + const picked: string[] | typeof BACK | null = await multiSelect({ message: "Which harnesses should it protect?", choices: [ { @@ -990,14 +1005,25 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise (priorClis ? { ...c, checked: priorClis.includes(c.value) } : c)), + minSelected: 1, + summaryNoun: "harnesses", + hint: "detected CLIs are pre-selected · space toggles · ctrl+a all · ← back · ↵ confirm", + allowBack: true as const, + stdin, + stdout, + }); + if (picked === null) return cancel(); + // ← re-runs the loop, which re-asks the policy step with its answer intact. + if (picked === BACK) continue; + clisSel = picked; + } + // Non-null by construction: the loop only exits once both are assigned. + const chosenPresets: string[] = presets ?? []; + const policies = resolvePresetSelection(chosenPresets); + // Only meaningful when there are files to switch off; with none, the row is + // locked-unchecked and must not write a disabling flag. + const customEnabled = hasCustomFiles ? chosenPresets.includes(CUSTOM) : undefined; // Filter to what the chosen scopes support in BOTH branches: "Everything // available" must not expand to CLIs that cannot take any selected scope, // and a locked row can't be ticked but belt-and-braces keeps the invariant @@ -1374,7 +1400,7 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise { } } +/** + * "Is anything listening on the daemon socket?" — never throws. + * + * Split out because `waitForDaemonRunning` needs the connect check WITHOUT the + * hook evaluation: it runs inside a settle loop, and a failed import or a + * transient refusal there must read as "not yet", never as an error. + */ +async function daemonAcceptsConnectionsQuietly(): Promise { + try { + const { daemonAcceptsConnections } = await import("./daemon-client"); + return await daemonAcceptsConnections(); + } catch { + return false; + } +} + /** Boolean form, for callers that only branch on healthy/not. */ export async function probeDaemonEndToEnd(): Promise { return (await probeDaemon()).ok; } /** - * Waits for the service to report running, then re-checks after a settle - * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't - * pass on the strength of one optimistic reading. + * Waits for the service to report running and to HOLD it — a `Type=simple` unit + * is active the moment it forks, so one optimistic reading passes a daemon that + * died at startup. + * + * WATCHED, not slept through. This used to sleep `SERVICE_SETTLE_MS` blind and + * read the status once at the end, which was wrong in both directions on a + * healthy machine and a broken one: + * + * • Healthy: the socket is up in ~13ms and answers a real hook in ~125ms, and + * setup still sat there for the remaining ~600ms with the answer already in + * hand. Setup runs this twice on the repair path (uninstall, reinstall), so + * it was over a second of dead wait every time. + * • Broken: a daemon that died at 100ms was not noticed until 750ms, because + * nothing looked until the sleep was over. + * + * Now the window is polled. Leaving `running` at any point fails immediately, + * and the wait ends early once the daemon has answered a real hook — a reply is + * strictly stronger evidence of "did not die at startup" than "still active + * after an arbitrary sleep", which is all the settle ever established. */ -async function waitForDaemonRunning(): Promise { - const deadline = Date.now() + SERVICE_START_TIMEOUT_MS; +export interface WaitForDaemonDeps { + status?: () => DaemonServiceStatus; + accepts?: () => Promise; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +export async function waitForDaemonRunning(deps: WaitForDaemonDeps = {}): Promise { + const status = deps.status ?? daemonServiceStatus; + const accepts = deps.accepts ?? daemonAcceptsConnectionsQuietly; + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? Date.now; + + const deadline = now() + SERVICE_START_TIMEOUT_MS; + for (;;) { + if (status() === "running") break; + if (now() >= deadline) return false; + await sleep(SERVICE_START_POLL_MS); + } + + const settleUntil = now() + SERVICE_SETTLE_MS; for (;;) { - if (daemonServiceStatus() === "running") break; - if (Date.now() >= deadline) return false; - await new Promise((r) => setTimeout(r, SERVICE_START_POLL_MS)); + // A unit that has left `running` is dead now; there is nothing to wait out. + if (status() !== "running") return false; + // A daemon that accepted a connection is a daemon that got past startup and + // bound its socket. Deliberately the CHEAP check, not a full hook + // evaluation: `probeDaemon` runs the end-to-end one moments later, and + // paying for it twice is what this rewrite exists to stop. + if (await accepts()) return true; + if (now() >= settleUntil) return true; + await sleep(SERVICE_START_POLL_MS); } - await new Promise((r) => setTimeout(r, SERVICE_SETTLE_MS)); - return daemonServiceStatus() === "running"; } // ── Upgrading a service definition that predates a variable ────────────────── diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 1202f6ee..1b7db92e 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -25,6 +25,7 @@ import { getInstanceId, hashToId } from "../../lib/telemetry-id"; import { CliError } from "../cli-error"; import { hookLogWarn } from "./hook-logger"; import { customPoliciesDir, globalPolicyConfigFile } from "./fp-home"; +import { readActiveCloudManagedPolicies } from "./cloud-managed-policies"; const VALID_POLICY_NAMES = new Set(BUILTIN_POLICIES.map((p) => p.name)); @@ -839,6 +840,40 @@ export async function listHooks(cwd?: string): Promise { console.log(); } + // Cloud-managed policies. These enforce on this machine exactly like the two + // sections above, but nothing here listed them — so `failproofai policies` + // answered "what is enforcing?" with a subset, and the policies an operator + // pushed to a fleet were the ones invisible to the person running the + // command on it. + // + // Read-only on purpose: these are owned by the deployment, not by local + // config. `--uninstall ` cannot switch one off, and printing them + // beside toggleable rows without saying so would imply it can. + try { + const cloud = readActiveCloudManagedPolicies(); + if (cloud.length > 0) { + const gen = cloud[0].generation; + console.log( + `\n \u2500\u2500 Cloud-managed \u2014 deployment ${gen} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`, + ); + const colWidth = Math.max(nameColWidth, ...cloud.map((c) => c.id.length + 2)); + for (const artifact of cloud) { + // `observe` is evaluated and then has its verdict discarded, so a row + // that read "ON" would claim enforcement this policy deliberately is + // not doing. + const status = + artifact.effect === "observe" ? "\x1B[33m\u25D0 OBS\x1B[0m" : "\x1B[32m\u2713 ON\x1B[0m"; + console.log(` ${status} ${artifact.id.padEnd(colWidth)}v${artifact.revision}`); + } + console.log("\n Managed from the dashboard \u2014 not switchable with `failproofai policies`."); + console.log(); + } + } catch { + // A machine with no deployment, or an unreadable manifest, simply has no + // section. The hook path reports its own failures; a listing must not be + // the thing that turns a bad manifest into a broken command. + } + // Mirror what was just listed into the USER config. Safe here because // `failproofai policies` is a one-shot command — never do this on the hook // path (see the HooksConfig.conventionPolicies doc comment). diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index fd52b19f..854d1776 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -34,6 +34,8 @@ export interface SelectChoice { } export interface SelectOneOptions { + /** Offer ← to go back a step. The caller must handle `BACK`. */ + allowBack?: boolean; message: string; choices: SelectChoice[]; /** Static info lines rendered under the question (e.g. a review summary). */ @@ -64,6 +66,8 @@ export interface MultiChoice { } export interface MultiSelectOptions { + /** Offer ← to go back a step. The caller must handle `BACK`. */ + allowBack?: boolean; message: string; choices: MultiChoice[]; minSelected?: number; @@ -423,6 +427,8 @@ interface PromptSpec { renderRow: (index: number, active: boolean, budget: number) => string; /** Extra line(s) above the footer (e.g. a min-selected warning). */ warnLine?: () => string | null; + /** When set, ← resolves `BACK` so the caller can step backwards. */ + allowBack?: boolean; footer: string; /** Handle non-navigation keys. `{done}` finishes, `"redraw"` repaints. */ onKey: (key: readline.Key, cursor: number) => { done: R } | "redraw" | undefined; @@ -500,6 +506,10 @@ function runPrompt(p: PromptSpec): Promise { if (!key) return; if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { finish(null); + } else if (key.name === "left" && p.allowBack) { + // Only when the caller opted in. A prompt with nowhere to go back TO + // must not appear to offer it. + finish(BACK as unknown as never); } else if (key.name === "up") { cursor = cursor > 0 ? cursor - 1 : choices.length - 1; repaint(stdout, region, build()); @@ -519,7 +529,24 @@ function runPrompt(p: PromptSpec): Promise { // ── selectOne (radio) ───────────────────────────────────────────────────────── -export function selectOne(opts: SelectOneOptions): Promise { +/** + * Returned by a prompt when the user asked to go BACK a step, as distinct from + * cancelling. Both used to be `null`, which made "I picked the wrong scope" and + * "I want out" the same keystroke — so the only way to change an earlier answer + * was to abandon setup and start over. + * + * A symbol rather than a sentinel string because a caller's value type is its + * own: `selectOne` could legitimately have "back" as a real choice. + */ +export const BACK: unique symbol = Symbol("failproofai.back"); +export type Back = typeof BACK; + +// Overloaded so `BACK` appears in the return type ONLY where it was asked for. +// Widening every caller to `T | Back | null` would make dozens of call sites +// handle a value they can never receive. +export function selectOne(opts: SelectOneOptions & { allowBack: true }): Promise; +export function selectOne(opts: SelectOneOptions): Promise; +export function selectOne(opts: SelectOneOptions): Promise { const stdin: TTYIn = opts.stdin ?? process.stdin; const stdout: TTYOut = opts.stdout ?? process.stdout; const choices = opts.choices; @@ -550,7 +577,10 @@ export function selectOne(opts: SelectOneOptions): Promise { const hint = choice.hint ? ` ${c.dim(ellipsize(choice.hint, budget))}` : ""; return `${dot} ${label}${hint}`; }, - footer: "↑/↓ navigate · enter to select · esc to cancel", + allowBack: opts.allowBack, + footer: opts.allowBack + ? "↑/↓ navigate · enter to select · ← back · esc to cancel" + : "↑/↓ navigate · enter to select · esc to cancel", onKey: (key, cursor) => key.name === "return" ? { done: choices[cursor].value } : undefined, summaryFor: (value) => @@ -562,7 +592,9 @@ export function selectOne(opts: SelectOneOptions): Promise { // ── multiSelect (checklist) ──────────────────────────────────────────────────── -export function multiSelect(opts: MultiSelectOptions): Promise { +export function multiSelect(opts: MultiSelectOptions & { allowBack: true }): Promise; +export function multiSelect(opts: MultiSelectOptions): Promise; +export function multiSelect(opts: MultiSelectOptions): Promise { const stdin: TTYIn = opts.stdin ?? process.stdin; const stdout: TTYOut = opts.stdout ?? process.stdout; const choices = opts.choices; @@ -604,7 +636,12 @@ export function multiSelect(opts: MultiSelectOptions): Promise return `${caret} ${box} ${label}${hint}`; }, warnLine: () => (warn ? c.warn(`Select at least ${minSelected}.`) : null), - footer: opts.hint ?? "↑/↓ move · space select · ctrl+a all · enter confirm", + allowBack: opts.allowBack, + footer: + opts.hint ?? + (opts.allowBack + ? "↑/↓ move · space select · ctrl+a all · ← back · enter confirm" + : "↑/↓ move · space select · ctrl+a all · enter confirm"), onKey: (key, cursor) => { if (key.name === "space") { if (choices[cursor]?.locked) return "redraw"; // always on — not a choice From 921b7f60ed44d662985503af7950899803c4c395 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 7 Aug 2026 19:19:01 +0530 Subject: [PATCH 04/11] rename: a generation is a deployment, a revision is a version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon half of the AgentEye rename. Both words were product vocabulary nobody outside the codebase used: a customer reads "deployment 7" and "version 3" without a glossary. Renamed through the wire format and the on-disk manifest, not just the labels — `DesiredState`, `DesiredPolicy`, `ActiveDeployment` (was `ActiveGeneration`), `ActivePolicy`, and every path and identifier behind them. NO COMPATIBILITY ALIAS, deliberately. Nothing is deployed against the old names, so a `serde(alias)` would be dead code guarding a case that cannot occur. The consequence is that this must ship with the server change: a daemon reading `generation` from a server emitting `deployment` parses nothing and stops reconciling, and `ActiveDeployment` carries `deny_unknown_fields`, so it fails hard rather than degrading. Two boundary bugs the first sweep missed, both silent-failure class: 1. Snake_case identifiers — `generation_dir`, `generation_path`, `generation_valid` — survived `\bgeneration\b`, because `_` is a word character so there is no boundary to match. Four Rust files. 2. `cloudRevision` / `cloudGeneration` are `#[serde(rename)]` keys in the hook-activity JSONL that the TypeScript writes and the Rust collector reads. The TS side was renamed and the reader was not, which would have dropped cloud-policy attribution from every hook decision — no error, just an empty column. Verified: 218 Rust tests, 3217 TypeScript tests, clippy and fmt clean. The one TypeScript failure (`refuses to half-install when it cannot elevate`) is proven pre-existing — it fails identically with this entire change stashed, because this machine has passwordless sudo and the test asserts `canElevate()` is false. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BDLTPtbQvUE62eCfQrUbf --- CHANGELOG.md | 3 + __tests__/hooks/builtin-policies.test.ts | 8 +- .../hooks/cloud-connect-permissions.test.ts | 2 +- __tests__/hooks/cloud-enrollment-cli.test.ts | 8 +- __tests__/hooks/cloud-enrollment.test.ts | 6 +- .../hooks/cloud-managed-policies.test.ts | 31 +-- __tests__/hooks/configure-wizard.test.ts | 2 +- .../hooks/fail-closed-force-decision.test.ts | 2 +- __tests__/hooks/handler.test.ts | 2 +- __tests__/hooks/manager-cloud-listing.test.ts | 6 +- __tests__/hooks/policy-attribution.test.ts | 22 +-- .../hooks/session-pause-enforcement.test.ts | 2 +- __tests__/hooks/worker-server.test.ts | 8 +- __tests__/integration-suite/is-error.test.ts | 2 +- app/policies/hooks-client.tsx | 8 +- crates/failproofaid/src/cloud_client.rs | 16 +- crates/failproofaid/src/cloud_policies.rs | 176 +++++++++--------- crates/failproofaid/src/main.rs | 14 +- crates/failproofaid/src/paths.rs | 8 +- crates/failproofaid/src/telemetry.rs | 8 +- .../tests/collector_reload_e2e.rs | 6 +- crates/fpai-collect/src/health.rs | 14 +- .../src/sources/hooks/transform.rs | 34 ++-- crates/fpai-collect/tests/hooks_source.rs | 18 +- src/hooks/cloud-connection.ts | 8 +- src/hooks/cloud-enrollment-cli.ts | 2 +- src/hooks/cloud-enrollment.ts | 6 +- src/hooks/cloud-managed-policies.ts | 32 ++-- src/hooks/custom-hooks-loader.ts | 2 +- src/hooks/daemon-service.ts | 2 +- src/hooks/fp-home.ts | 4 +- src/hooks/handler.ts | 22 +-- src/hooks/hook-activity-store.ts | 10 +- src/hooks/manager.ts | 4 +- 34 files changed, 251 insertions(+), 247 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb08e8b..d2905244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Features - Add `failproofai flush` — deliver what is already spooled, now. The collector is unhurried on purpose (a batch is swept once it is older than two minutes, at most 64 per pass, on a 60-second cadence), which is right for a backlog and exactly wrong for somebody standing at a dashboard waiting to see their own events: from there "not delivered yet" and "not working" look identical. The command asks the daemon for a pass with no minimum age and no cap, and `--wait` blocks until the spool drains so a script can flush and then assert. It re-sends nothing — for history the collector already read past, that is still `backfill`. (#PR) +### Features +- Rename the cloud-policy vocabulary: a **generation** is now a **deployment**, and a policy **revision** is a **version**. Both were words the product used nowhere else — a customer reads "deployment 7" and "version 3" without a glossary. The rename goes through the wire format and the on-disk manifest, not just the labels, and lands with the matching AgentEye change: a server and a daemon that disagree on these names means the fleet stops reconciling, silently. (#PR) + ### Fixes - Make `failproofai config` refuse setup on an unsupported platform (Windows, today) instead of completing it unenforced. The wizard used to skip the daemon requirement and finish anyway, leaving the machine reading as configured while enforcing in-process with no fail-closed guarantee — now it prints why and exits 1 before drawing a single prompt, writing nothing. (#664) - Cut roughly a second of dead wait out of installing the daemon. `waitForDaemonRunning` slept a flat 750ms and then read the service status once, which was wrong in both directions: the socket is up in ~13ms and answers a real hook in ~125ms, so a healthy machine sat there with the answer already in hand — twice, because the repair path uninstalls and reinstalls — while a daemon that died at 100ms went unnoticed until the sleep was over. The window is now polled: leaving `running` fails immediately, and the wait ends as soon as the daemon accepts a connection, which is stronger evidence it did not die at startup than "still active after an arbitrary sleep". (#PR) diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 877b39ea..a82d754d 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -2753,7 +2753,7 @@ describe("hooks/builtin-policies", () => { } // Base branch comparison: log {remote}/{baseBranch}..HEAD if (joined.includes("log") && joined.includes(`${remote}/${baseBranch}..HEAD`)) { - if (opts.baseRefExists === false) throw new Error("unknown revision"); + if (opts.baseRefExists === false) throw new Error("unknown version"); return opts.commitsAheadOfBase ?? "abc123 some commit\n"; } // Tracking branch comparison: log {remote}/{branch}..HEAD @@ -3001,11 +3001,11 @@ describe("hooks/builtin-policies", () => { vi.mocked(execFileSync).mockImplementation((_cmd: string, args?: readonly string[]) => { const joined = args?.join(" ") ?? ""; if (joined.includes("log") && joined.includes("..HEAD")) { - if (opts.baseRefExists === false) throw new Error("unknown revision"); + if (opts.baseRefExists === false) throw new Error("unknown version"); return opts.commitsAhead ?? "abc123 some commit\n"; } if (joined.includes("diff") && joined.includes("--stat")) { - if (opts.baseRefExists === false) throw new Error("unknown revision"); + if (opts.baseRefExists === false) throw new Error("unknown version"); return opts.fileDiff ?? " src/index.ts | 2 +-\n 1 file changed\n"; } return ""; @@ -3387,7 +3387,7 @@ describe("hooks/builtin-policies", () => { vi.mocked(execFileSync).mockImplementation((_cmd: string, args?: readonly string[]) => { const joined = args?.join(" ") ?? ""; if (joined.includes("rev-parse") && joined.includes("--verify")) { - if (opts.baseRefExists === false) throw new Error("unknown revision"); + if (opts.baseRefExists === false) throw new Error("unknown version"); return ""; } if (joined.includes("log") && joined.includes("..HEAD")) { diff --git a/__tests__/hooks/cloud-connect-permissions.test.ts b/__tests__/hooks/cloud-connect-permissions.test.ts index 80484b7d..88f2d776 100644 --- a/__tests__/hooks/cloud-connect-permissions.test.ts +++ b/__tests__/hooks/cloud-connect-permissions.test.ts @@ -49,7 +49,7 @@ function probes() { ran, verifyPolicy: async () => { ran.policy = true; - return { ok: true as const, policyCount: 3, generation: 7 }; + return { ok: true as const, policyCount: 3, deployment: 7 }; }, verifyIngest: async () => { ran.ingest = true; diff --git a/__tests__/hooks/cloud-enrollment-cli.test.ts b/__tests__/hooks/cloud-enrollment-cli.test.ts index 49beb672..ab4d44f2 100644 --- a/__tests__/hooks/cloud-enrollment-cli.test.ts +++ b/__tests__/hooks/cloud-enrollment-cli.test.ts @@ -11,7 +11,7 @@ import { readConfig } from "../../src/hooks/fp-config"; let dir: string; let realHome: string | undefined; -const ok = vi.fn(async () => ({ ok: true as const, policyCount: 3, generation: 12 })); +const ok = vi.fn(async () => ({ ok: true as const, policyCount: 3, deployment: 12 })); const ingestOk = vi.fn(async () => ({ ok: true as const })); // A key carrying both permissions, so the capability gating is transparent here // and each test exercises whatever `verify`/`verifyIngest` it injected. Reports @@ -64,7 +64,7 @@ describe("--connect", () => { expect(r.exitCode).toBe(0); // The label is the human name; the explicit id is shown in parentheses. expect(r.lines.join("\n")).toMatch(/Connected to https:\/\/be\.failproof\.ai as lab-1 \(m-1\)/); - expect(r.lines.join("\n")).toMatch(/3 policies assigned \(generation 12\)/); + expect(r.lines.join("\n")).toMatch(/3 policies assigned \(deployment 12\)/); expect(readCloudCredentials()).toEqual({ url: base.url, machineId: "m-1", @@ -349,13 +349,13 @@ describe("--disconnect means disconnect", () => { // Clearing the credential ends polling. Every artifact already on disk // stayed referenced by active.json and kept being loaded on every tool // call, so a machine that had deliberately left its organisation went on - // being governed by whatever generation was current when it left. + // being governed by whatever deployment was current when it left. await runConnectCommand({ ...base, machineId: "m-1" }); const managedRoot = resolve(dir, "home", "policies", "cloud-policies"); mkdirSync(managedRoot, { recursive: true }); writeFileSync( resolve(managedRoot, "active.json"), - JSON.stringify({ schemaVersion: 1, generation: 4, policies: [] }), + JSON.stringify({ schemaVersion: 1, deployment: 4, policies: [] }), ); runDisconnectCommand(); diff --git a/__tests__/hooks/cloud-enrollment.test.ts b/__tests__/hooks/cloud-enrollment.test.ts index 50a1d040..599b4449 100644 --- a/__tests__/hooks/cloud-enrollment.test.ts +++ b/__tests__/hooks/cloud-enrollment.test.ts @@ -170,7 +170,7 @@ describe("verifyCloudCredentials", () => { let respond: (path: string) => { status: number; body: string }; beforeEach(async () => { - respond = () => ({ status: 200, body: JSON.stringify({ schemaVersion: 1, generation: 4, policies: [] }) }); + respond = () => ({ status: 200, body: JSON.stringify({ schemaVersion: 1, deployment: 4, policies: [] }) }); server = createServer((req, res) => { lastAuth = req.headers.authorization; const { status, body } = respond(req.url ?? ""); @@ -192,10 +192,10 @@ describe("verifyCloudCredentials", () => { let seenUrl = ""; respond = (url) => { seenUrl = url; - return { status: 200, body: JSON.stringify({ generation: 9, policies: [{ id: "a" }, { id: "b" }] }) }; + return { status: 200, body: JSON.stringify({ deployment: 9, policies: [{ id: "a" }, { id: "b" }] }) }; }; const result = await verifyCloudCredentials(creds()); - expect(result).toEqual({ ok: true, policyCount: 2, generation: 9 }); + expect(result).toEqual({ ok: true, policyCount: 2, deployment: 9 }); expect(lastAuth).toBe("Bearer the-token"); expect(seenUrl).toContain("/enforcement/v1/desired-state?machineId=m-1"); }); diff --git a/__tests__/hooks/cloud-managed-policies.test.ts b/__tests__/hooks/cloud-managed-policies.test.ts index 27289589..265ef0da 100644 --- a/__tests__/hooks/cloud-managed-policies.test.ts +++ b/__tests__/hooks/cloud-managed-policies.test.ts @@ -16,7 +16,7 @@ function fixture(policyBytes = Buffer.from("export default 'managed';\n")) { roots.push(root); process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; const sha256 = createHash("sha256").update(policyBytes).digest("hex"); - const generationDir = join(root, "generations", "12"); + const generationDir = join(root, "deployments", "12"); mkdirSync(generationDir, { recursive: true }); const policyPath = join(generationDir, "guard.mjs"); writeFileSync(policyPath, policyBytes); @@ -24,8 +24,8 @@ function fixture(policyBytes = Buffer.from("export default 'managed';\n")) { join(root, "active.json"), JSON.stringify({ schemaVersion: 1, - generation: 12, - policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs" }], + deployment: 12, + policies: [{ id: "guard", version: 3, sha256, path: "deployments/12/guard.mjs" }], }), ); return { root, policyPath, sha256 }; @@ -36,17 +36,17 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -describe("cloud-managed policy active generation", () => { +describe("cloud-managed policy active deployment", () => { it("returns only hash-verified artifacts from active.json", () => { const { policyPath, sha256 } = fixture(); expect(readActiveCloudManagedPolicies()).toEqual([ // `effect` defaults to enforce: a manifest written before observe mode // existed must not silently downgrade a machine to observation. - { id: "guard", revision: 3, sha256, path: policyPath, generation: 12, effect: "enforce" }, + { id: "guard", version: 3, sha256, path: policyPath, deployment: 12, effect: "enforce" }, ]); }); - it("returns an empty set when no cloud generation is active", () => { + it("returns an empty set when no cloud deployment is active", () => { const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-empty-")); roots.push(root); process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; @@ -63,14 +63,14 @@ describe("cloud-managed policy active generation", () => { 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"); + const link = join(root, "deployments", "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" }], + deployment: 12, + policies: [{ id: "guard", version: 3, sha256, path: "deployments/12/escape.mjs" }], }), ); try { @@ -88,8 +88,8 @@ describe("policy effect", () => { join(root, "active.json"), JSON.stringify({ schemaVersion: 1, - generation: 12, - policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs", effect: "observe" }], + deployment: 12, + policies: [{ id: "guard", version: 3, sha256, path: "deployments/12/guard.mjs", effect: "observe" }], }), ); expect(readActiveCloudManagedPolicies()[0]).toMatchObject({ path: policyPath, effect: "observe" }); @@ -103,8 +103,8 @@ describe("policy effect", () => { join(root, "active.json"), JSON.stringify({ schemaVersion: 1, - generation: 12, - policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs", effect: "sometimes" }], + deployment: 12, + policies: [{ id: "guard", version: 3, sha256, path: "deployments/12/guard.mjs", effect: "sometimes" }], }), ); expect(() => readActiveCloudManagedPolicies()).toThrow(/unknown effect/); @@ -116,7 +116,7 @@ describe("clearActiveCloudManagedPolicies", () => { // `--disconnect` cleared the credential, which ends POLLING. Every artifact // already on disk stayed referenced by active.json and kept being loaded on // every tool call — so a machine that had deliberately left its - // organisation went on being governed by whatever generation was current + // organisation went on being governed by whatever deployment was current // when it left, indefinitely, while `--status` called it unconnected. const { policyPath } = fixture(); expect(readActiveCloudManagedPolicies()).toHaveLength(1); @@ -129,10 +129,11 @@ describe("clearActiveCloudManagedPolicies", () => { expect(existsSync(policyPath)).toBe(true); }); - it("reports nothing removed when no generation was active", () => { + it("reports nothing removed when no deployment was active", () => { const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-clear-")); roots.push(root); process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; expect(clearActiveCloudManagedPolicies()).toBe(false); }); }); + diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 6cf61997..d7c497cf 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -101,7 +101,7 @@ vi.mock("../../src/hooks/cloud-connection", async (importOriginal) => { return { ...actual, connectToCloud: vi.fn(async () => ({ - policy: { ok: true, policyCount: 2, generation: 7 }, + policy: { ok: true, policyCount: 2, deployment: 7 }, ingest: { ok: true }, anyConfigured: true, })), diff --git a/__tests__/hooks/fail-closed-force-decision.test.ts b/__tests__/hooks/fail-closed-force-decision.test.ts index f6173e13..efcfe66c 100644 --- a/__tests__/hooks/fail-closed-force-decision.test.ts +++ b/__tests__/hooks/fail-closed-force-decision.test.ts @@ -273,7 +273,7 @@ describe("hooks/handler with a corrupt cloud-managed manifest", () => { // look protected and be enforcing only its local set. writeFileSync( join(policyRoot, "active.json"), - JSON.stringify({ schemaVersion: 999, generation: 1, policies: [] }), + JSON.stringify({ schemaVersion: 999, deployment: 1, policies: [] }), ); const result = await evaluateHookEvent("PreToolUse", "claude", stdin({ diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 70776c15..08d6bdca 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -7,7 +7,7 @@ // policies off disk (`readActiveCloudManagedPolicies`), so a developer with a // real deployment saw its artifacts arrive as arguments the assertions never // expected — one failure read -// `["/home/…/cloud-policies/generations/4/block-curl-simple.mjs"]` where the +// `["/home/…/cloud-policies/deployments/4/block-curl-simple.mjs"]` where the // test wanted `undefined`. Nothing was broken; the test was reading their // laptop. // diff --git a/__tests__/hooks/manager-cloud-listing.test.ts b/__tests__/hooks/manager-cloud-listing.test.ts index 0355dc7f..a3b36be9 100644 --- a/__tests__/hooks/manager-cloud-listing.test.ts +++ b/__tests__/hooks/manager-cloud-listing.test.ts @@ -26,7 +26,7 @@ describe("failproofai policies — cloud-managed section", () => { it("lists a deployed policy, with its version and the deployment number", async () => { readActive.mockReturnValue([ - { id: "org-guard", revision: 3, effect: "enforce", sha256: "a", path: "p", generation: 7 }, + { id: "org-guard", version: 3, effect: "enforce", sha256: "a", path: "p", deployment: 7 }, ]); await listHooks(); expect(text()).toContain("Cloud-managed — deployment 7"); @@ -36,7 +36,7 @@ describe("failproofai policies — cloud-managed section", () => { it("marks an observe policy as OBS, never ON — its verdict is discarded", async () => { readActive.mockReturnValue([ - { id: "watch-only", revision: 1, effect: "observe", sha256: "a", path: "p", generation: 2 }, + { id: "watch-only", version: 1, effect: "observe", sha256: "a", path: "p", deployment: 2 }, ]); await listHooks(); const line = text().split("\n").find((l) => l.includes("watch-only")) ?? ""; @@ -46,7 +46,7 @@ describe("failproofai policies — cloud-managed section", () => { it("says these are not switchable locally, because --uninstall cannot touch them", async () => { readActive.mockReturnValue([ - { id: "org-guard", revision: 1, effect: "enforce", sha256: "a", path: "p", generation: 1 }, + { id: "org-guard", version: 1, effect: "enforce", sha256: "a", path: "p", deployment: 1 }, ]); await listHooks(); expect(text()).toContain("Managed from the dashboard"); diff --git a/__tests__/hooks/policy-attribution.test.ts b/__tests__/hooks/policy-attribution.test.ts index dd2767b4..a31621f3 100644 --- a/__tests__/hooks/policy-attribution.test.ts +++ b/__tests__/hooks/policy-attribution.test.ts @@ -3,7 +3,7 @@ * Attribution on the activity row. * * The design doc's requirement is that Failproof Cloud can tie a decision to - * the exact rollout that produced it. Until now the only trace of a revision + * the exact rollout that produced it. Until now the only trace of a version * was a substring of a display name ("cloud/org-guard@7/…"), which nothing can * query and which re-parsing our own label would be the only way to read. */ @@ -44,7 +44,7 @@ const hook = (name: string, extra: Record = {}) => extra, ); -const CLOUD = { id: "org-guard", revision: 7, sha256: "a".repeat(64), path: "/x.mjs", generation: 184 }; +const CLOUD = { id: "org-guard", version: 7, sha256: "a".repeat(64), path: "/x.mjs", deployment: 184 }; function decidedBy(policyName: string | null, decision: "allow" | "deny" = "deny") { vi.mocked(evaluatePolicies).mockReturnValue({ @@ -88,7 +88,7 @@ describe("policy attribution", () => { expect(row().policySource).toBe("convention"); }); - it("attributes a cloud decision to its exact policy id and revision", async () => { + it("attributes a cloud decision to its exact policy id and version", async () => { vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([CLOUD] as never); vi.mocked(loadAllCustomHooks).mockResolvedValue({ hooks: [hook("org-guard", { __cloudManaged: CLOUD })], conventionSources: [], @@ -97,11 +97,11 @@ describe("policy attribution", () => { await evaluateHookEvent("PreToolUse", "claude", stdin); expect(row().policySource).toBe("cloud"); expect(row().cloudPolicyId).toBe("org-guard"); - expect(row().cloudRevision).toBe(7); - expect(row().cloudGeneration).toBe(184); + expect(row().cloudVersion).toBe(7); + expect(row().cloudDeployment).toBe(184); }); - it("records the active generation even when a LOCAL policy decided", async () => { + it("records the active deployment even when a LOCAL policy decided", async () => { // "What was deployed here" is a different question from "what decided" — // and only the former separates a rollout that changed no outcomes from // one that never reached the machine. @@ -112,22 +112,22 @@ describe("policy attribution", () => { decidedBy("custom/local"); await evaluateHookEvent("PreToolUse", "claude", stdin); expect(row().policySource).toBe("custom"); - expect(row().cloudGeneration).toBe(184); + expect(row().cloudDeployment).toBe(184); expect(row().cloudPolicyId).toBeUndefined(); }); it("leaves attribution off entirely on a plain allow, where nothing decided", async () => { await evaluateHookEvent("PreToolUse", "claude", stdin); expect(row().policySource).toBeUndefined(); - expect(row().cloudRevision).toBeUndefined(); + expect(row().cloudVersion).toBeUndefined(); }); - it("omits the generation on an unmanaged machine rather than writing 0", async () => { - // A literal 0 would read as "generation zero is deployed"; absent reads as + it("omits the deployment on an unmanaged machine rather than writing 0", async () => { + // A literal 0 would read as "deployment zero is deployed"; absent reads as // "not managed", which is the truth. decidedBy("block-sudo"); await evaluateHookEvent("PreToolUse", "claude", stdin); - expect(row().cloudGeneration).toBeUndefined(); + expect(row().cloudDeployment).toBeUndefined(); }); }); diff --git a/__tests__/hooks/session-pause-enforcement.test.ts b/__tests__/hooks/session-pause-enforcement.test.ts index 990afa04..954acd01 100644 --- a/__tests__/hooks/session-pause-enforcement.test.ts +++ b/__tests__/hooks/session-pause-enforcement.test.ts @@ -67,7 +67,7 @@ function twoHooks() { { name: "org-guard", description: "", match: {}, fn: async () => ({ decision: "allow" }) }, { __policyId: "cloud:org-guard@7:org-guard", - __cloudManaged: { id: "org-guard", revision: 7, sha256: "a".repeat(64), path: "/x.mjs", generation: 4 }, + __cloudManaged: { id: "org-guard", version: 7, sha256: "a".repeat(64), path: "/x.mjs", deployment: 4 }, }, ), ], diff --git a/__tests__/hooks/worker-server.test.ts b/__tests__/hooks/worker-server.test.ts index 6fa24e9d..ac5d9f1e 100644 --- a/__tests__/hooks/worker-server.test.ts +++ b/__tests__/hooks/worker-server.test.ts @@ -244,7 +244,7 @@ customPolicies.add({ 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"); + const generationDir = join(managedRoot, "deployments", "42"); mkdirSync(generationDir, { recursive: true }); const policyPath = join(generationDir, "org-guard.mjs"); const policyBytes = `import { customPolicies, deny } from "failproofai"; @@ -260,13 +260,13 @@ customPolicies.add({ join(managedRoot, "active.json"), JSON.stringify({ schemaVersion: 1, - generation: 42, + deployment: 42, policies: [ { id: "org-guard", - revision: 8, + version: 8, sha256, - path: "generations/42/org-guard.mjs", + path: "deployments/42/org-guard.mjs", }, ], }), diff --git a/__tests__/integration-suite/is-error.test.ts b/__tests__/integration-suite/is-error.test.ts index 58a124d9..82bc0a49 100644 --- a/__tests__/integration-suite/is-error.test.ts +++ b/__tests__/integration-suite/is-error.test.ts @@ -8,7 +8,7 @@ * Its input is the agent's ENTIRE transcript, which is why the negative fixtures matter as * much as the positive ones: patterns loose enough to match ordinary model prose ("400 * tests passed", "that flag is not supported") turn a chatty refusal into a fake vendor - * outage, inverting exactly what the function exists to signal. An earlier revision of + * outage, inverting exactly what the function exists to signal. An earlier version of * this regex did precisely that with a bare `\b400\b` and a bare `not supported`. * * The function is read out of the shell script and executed by bash, so this test tracks diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index e6d2e0ea..040b0f5d 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -396,18 +396,18 @@ function DetailPanel({ Decided by: {item.policySource === "cloud" && item.cloudPolicyId - ? `cloud · ${item.cloudPolicyId} rev ${item.cloudRevision}` + ? `cloud · ${item.cloudPolicyId} rev ${item.cloudVersion}` : item.policySource} )} - {item.cloudGeneration !== undefined && ( + {item.cloudDeployment !== undefined && (
{/* Present on every row of a managed machine, not just cloud decisions — it is what separates a rollout that changed no outcomes from one that never arrived. */} - Cloud generation: - {item.cloudGeneration} + Cloud deployment: + {item.cloudDeployment}
)}
diff --git a/crates/failproofaid/src/cloud_client.rs b/crates/failproofaid/src/cloud_client.rs index 29fb22b3..7d8795fe 100644 --- a/crates/failproofaid/src/cloud_client.rs +++ b/crates/failproofaid/src/cloud_client.rs @@ -300,7 +300,7 @@ impl CloudClient { /// `--disconnect` take effect within one interval, with nothing to restart. /// /// Resolution failures degrade to integrity-only rather than killing the lane: -/// a machine that was pulling policy keeps its last known-good generation and +/// a machine that was pulling policy keeps its last known-good deployment and /// keeps repairing tampering while its credentials are broken. /// /// Two intervals, chosen per tick, so both documented knobs keep their meaning @@ -339,7 +339,7 @@ pub fn spawn_maintenance( } // Runs whether or not cloud is reachable: poll failures never - // discard the last known-good generation, and local tampering is + // discard the last known-good deployment, and local tampering is // still repaired while the cloud is offline or unconfigured. if let Err(err) = store.repair_active_from_cache() { eprintln!("[failproofaid] cloud policy integrity error: {err}"); @@ -364,8 +364,8 @@ fn poll_once(store: &PolicyStore, cloud: &CloudClient) { if outcome.activated || outcome.downloaded > 0 || outcome.repaired > 0 => { eprintln!( - "[failproofaid] cloud policy generation {} active (downloaded {}, repaired {})", - outcome.generation, outcome.downloaded, outcome.repaired + "[failproofaid] cloud policy deployment {} active (downloaded {}, repaired {})", + outcome.deployment, outcome.downloaded, outcome.repaired ); } Ok(_) => {} @@ -562,7 +562,7 @@ mod tests { 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() + format!(r#"{{"schemaVersion":1,"deployment":7,"policies":[{{"id":"guard","version":2,"sha256":"{expected_sha}","artifactUrl":"/enforcement/v1/artifacts/{expected_sha}"}}]}}"#).into_bytes() } else { expected_artifact.clone() }; @@ -590,10 +590,10 @@ mod tests { let outcome = store .reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy)) .unwrap(); - assert_eq!(outcome.generation, 7); + assert_eq!(outcome.deployment, 7); assert_eq!(outcome.downloaded, 1); assert_eq!( - fs::read(root.join("generations/7/guard.mjs")).unwrap(), + fs::read(root.join("deployments/7/guard.mjs")).unwrap(), artifact ); server.join().unwrap(); @@ -606,7 +606,7 @@ mod tests { CloudClient::new("https://cloud.example", "secret".into(), "machine".into()).unwrap(); let policy = DesiredPolicy { id: "guard".into(), - revision: 1, + version: 1, sha256: "0".repeat(64), artifact_url: "https://evil.example/artifact".into(), effect: PolicyEffect::Enforce, diff --git a/crates/failproofaid/src/cloud_policies.rs b/crates/failproofaid/src/cloud_policies.rs index d117297c..1d93e837 100644 --- a/crates/failproofaid/src/cloud_policies.rs +++ b/crates/failproofaid/src/cloud_policies.rs @@ -3,7 +3,7 @@ //! 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` +//! objects, materialize a complete deployment, then switch `active.json` //! atomically. The hook hot path never downloads or partially activates policy. use serde::{Deserialize, Serialize}; @@ -27,13 +27,13 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// manifest below. This is parsed from a SERVER response, and daemons update on /// their own schedule — so strictness here means the first field cloud adds /// makes every older daemon reject desired-state and silently stop pulling, -/// stranding fleets on whatever generation they happened to hold. Strictness +/// stranding fleets on whatever deployment they happened to hold. Strictness /// belongs on files we write ourselves, not on a remote payload. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DesiredState { pub schema_version: u32, - pub generation: u64, + pub deployment: u64, pub policies: Vec, } @@ -41,7 +41,7 @@ pub struct DesiredState { #[serde(rename_all = "camelCase")] pub struct DesiredPolicy { pub id: String, - pub revision: u64, + pub version: u64, pub sha256: String, /// Opaque locator interpreted only by the cloud transport implementation. pub artifact_url: String, @@ -68,9 +68,9 @@ pub enum PolicyEffect { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ActiveGeneration { +pub struct ActiveDeployment { pub schema_version: u32, - pub generation: u64, + pub deployment: u64, pub policies: Vec, } @@ -78,7 +78,7 @@ pub struct ActiveGeneration { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ActivePolicy { pub id: String, - pub revision: u64, + pub version: u64, pub sha256: String, /// Relative to the cloud-managed root. Never supplied by the server. pub path: String, @@ -90,7 +90,7 @@ pub struct ActivePolicy { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReconcileOutcome { - pub generation: u64, + pub deployment: u64, pub downloaded: usize, pub repaired: usize, pub activated: bool, @@ -136,7 +136,7 @@ impl std::fmt::Display for ReconcileError { } Self::NoVerifiedCopy { policy_id } => write!( f, - "cloud policy {policy_id} has no verified artifact or generation copy" + "cloud policy {policy_id} has no verified artifact or deployment copy" ), } } @@ -174,9 +174,9 @@ where #[derive(Debug, Clone)] pub struct PolicyStore { root: PathBuf, - /// Highest generation this process has seen the SERVER offer. + /// Highest deployment this process has seen the SERVER offer. /// - /// The rollback guard used to compare against `active.json`'s generation. + /// The rollback guard used to compare against `active.json`'s deployment. /// That file is a derived local pointer owned by the user — which this /// module's own comment says — so on the product's stated threat model (a /// rogue agent running as the user) it was an attacker-controlled veto over @@ -220,7 +220,7 @@ impl PolicyStore { self.root.join("desired-state.json") } - pub fn read_active(&self) -> Result, ReconcileError> { + pub fn read_active(&self) -> Result, ReconcileError> { let path = self.active_manifest_path(); if !path.exists() { return Ok(None); @@ -240,8 +240,8 @@ impl PolicyStore { Ok(Some(desired)) } - /// Installs a complete desired generation. Any error before the final - /// `active.json` rename leaves the previous generation authoritative. + /// Installs a complete desired deployment. Any error before the final + /// `active.json` rename leaves the previous deployment authoritative. pub fn reconcile( &self, desired: &DesiredState, @@ -260,36 +260,36 @@ impl PolicyStore { // Rollback guard, anchored on what the SERVER has said this session — // never on the local pointer. See `server_high_water`. let floor = self.server_high_water.load(Ordering::Relaxed); - if desired.generation < floor { + if desired.deployment < floor { return Err(ReconcileError::InvalidDesiredState(format!( - "generation rollback from {} to {} is not allowed", - floor, desired.generation + "deployment rollback from {} to {} is not allowed", + floor, desired.deployment ))); } // Recorded before the work below so a mid-reconcile failure cannot let - // an immediately-following lower generation through. + // an immediately-following lower deployment through. self.server_high_water - .fetch_max(desired.generation, Ordering::Relaxed); + .fetch_max(desired.deployment, Ordering::Relaxed); // A local pointer AHEAD of the server is not authority, but it is worth // saying out loud: it means either a restored/re-registered control // plane, or that something edited this machine's state. if let Some(active) = &previous - && active.generation > desired.generation + && active.deployment > desired.deployment { eprintln!( - "[failproofaid] local active generation {} is ahead of the server's {}; \ + "[failproofaid] local active deployment {} is ahead of the server's {}; \ taking the server's state (the local pointer is not authority)", - active.generation, desired.generation + active.deployment, desired.deployment ); } fs::create_dir_all(self.root.join("artifacts"))?; - let generation_dir = self + let deployment_dir = self .root - .join("generations") - .join(desired.generation.to_string()); - fs::create_dir_all(&generation_dir)?; + .join("deployments") + .join(desired.deployment.to_string()); + fs::create_dir_all(&deployment_dir)?; let mut downloaded = 0; let mut repaired = 0; @@ -297,15 +297,15 @@ impl PolicyStore { for policy in &desired.policies { let artifact_path = self.artifact_path(&policy.sha256); - let generation_path = generation_dir.join(format!("{}.mjs", policy.id)); + let deployment_path = deployment_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 deployment_valid = file_matches_hash(&deployment_path, &policy.sha256)?; let bytes = if artifact_valid { fs::read(&artifact_path)? - } else if generation_valid { - let bytes = fs::read(&generation_path)?; + } else if deployment_valid { + let bytes = fs::read(&deployment_path)?; write_atomic(&artifact_path, &bytes)?; repaired += 1; bytes @@ -322,42 +322,42 @@ impl PolicyStore { bytes }; - if !generation_valid { - write_atomic(&generation_path, &bytes)?; + if !deployment_valid { + write_atomic(&deployment_path, &bytes)?; if artifact_valid { repaired += 1; } } - let relative_path = generation_path + let relative_path = deployment_path .strip_prefix(&self.root) .map_err(|_| { ReconcileError::InvalidDesiredState( - "generation path escaped policy root".into(), + "deployment path escaped policy root".into(), ) })? .to_string_lossy() .into_owned(); active_policies.push(ActivePolicy { id: policy.id.clone(), - revision: policy.revision, + version: policy.version, effect: policy.effect, sha256: policy.sha256.clone(), path: relative_path, }); } - let active = ActiveGeneration { + let active = ActiveDeployment { schema_version: DESIRED_STATE_SCHEMA_VERSION, - generation: desired.generation, + deployment: desired.deployment, policies: active_policies, }; let manifest_bytes = serde_json::to_vec_pretty(&active)?; - write_atomic(&generation_dir.join("manifest.json"), &manifest_bytes)?; + write_atomic(&deployment_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. + // pointer from this snapshot and the fully staged deployment. write_atomic( &self.desired_state_path(), &serde_json::to_vec_pretty(desired)?, @@ -369,26 +369,26 @@ impl PolicyStore { } Ok(ReconcileOutcome { - generation: desired.generation, + deployment: desired.deployment, downloaded, repaired, activated, }) } - /// Verifies the active generation and repairs one bad copy from the other + /// Verifies the active deployment 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. + /// already-loaded deployment remains the last known good decision set. pub fn repair_active_from_cache(&self) -> Result { // A corrupted `desired-state.json` must not disable repair. // // `self.read_desired()?` propagated any parse error straight out, // short-circuiting before the `active.json`-driven branch below — the - // one that rebuilds a tampered `generations//.mjs` from the + // one that rebuilds a tampered `deployments//.mjs` from the // still-valid, content-addressed `artifacts/.mjs` copy. So one bad // byte in a file this branch does not even need permanently disabled - // generation-copy self-healing, and per `CLOUD_POLICIES.md` the only + // deployment-copy self-healing, and per `CLOUD_POLICIES.md` the only // thing that rewrites it is a successful cloud poll — which never // happens on an unenrolled or unreachable machine. // @@ -429,19 +429,19 @@ impl PolicyStore { 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 deployment_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)?; + let deployment_valid = file_matches_hash(&deployment_path, &policy.sha256)?; - match (artifact_valid, generation_valid) { + match (artifact_valid, deployment_valid) { (true, true) => {} (true, false) => { - write_atomic(&generation_path, &fs::read(&artifact_path)?)?; + write_atomic(&deployment_path, &fs::read(&artifact_path)?)?; repaired += 1; } (false, true) => { - write_atomic(&artifact_path, &fs::read(&generation_path)?)?; + write_atomic(&artifact_path, &fs::read(&deployment_path)?)?; repaired += 1; } (false, false) => { @@ -624,14 +624,14 @@ mod tests { // Daemons update on their own schedule. If this struct rejected unknown // fields, the first thing cloud added would make every older daemon // fail to parse desired-state and silently stop pulling — a fleet - // stranded on whatever generation it happened to hold, with no error + // stranded on whatever deployment it happened to hold, with no error // anyone would look for. - let json = r#"{"schemaVersion":1,"generation":4,"policies":[ - {"id":"guard","revision":2,"sha256":"aa","artifactUrl":"/a","effect":"observe", + let json = r#"{"schemaVersion":1,"deployment":4,"policies":[ + {"id":"guard","version":2,"sha256":"aa","artifactUrl":"/a","effect":"observe", "someFutureField":{"nested":true}} ],"anotherFutureField":42}"#; let parsed: DesiredState = serde_json::from_str(json).expect("must parse"); - assert_eq!(parsed.generation, 4); + assert_eq!(parsed.deployment, 4); assert_eq!(parsed.policies[0].effect, PolicyEffect::Observe); } @@ -640,8 +640,8 @@ mod tests { // The default has to be the one that keeps enforcing: a server that // predates observe mode must not silently downgrade a fleet to // observation. - let json = r#"{"schemaVersion":1,"generation":1,"policies":[ - {"id":"g","revision":1,"sha256":"aa","artifactUrl":"/a"}]}"#; + let json = r#"{"schemaVersion":1,"deployment":1,"policies":[ + {"id":"g","version":1,"sha256":"aa","artifactUrl":"/a"}]}"#; let parsed: DesiredState = serde_json::from_str(json).unwrap(); assert_eq!(parsed.policies[0].effect, PolicyEffect::Enforce); } @@ -650,9 +650,9 @@ mod tests { fn an_unreadable_effect_is_rejected_rather_than_guessed() { // Guessing would mean choosing between enforcing something cloud did // not ask to enforce, or observing something it wanted enforced. Both - // are worse than refusing the generation. - let json = r#"{"schemaVersion":1,"generation":1,"policies":[ - {"id":"g","revision":1,"sha256":"aa","artifactUrl":"/a","effect":"maybe"}]}"#; + // are worse than refusing the deployment. + let json = r#"{"schemaVersion":1,"deployment":1,"policies":[ + {"id":"g","version":1,"sha256":"aa","artifactUrl":"/a","effect":"maybe"}]}"#; assert!(serde_json::from_str::(json).is_err()); } @@ -661,18 +661,18 @@ mod tests { // active.json is what the evaluator reads. If the effect were not // carried here, an observe-mode policy would enforce the moment the // daemon restarted and re-read its own manifest. - let manifest = ActiveGeneration { + let manifest = ActiveDeployment { schema_version: 1, - generation: 9, + deployment: 9, policies: vec![ActivePolicy { id: "g".into(), - revision: 1, + version: 1, sha256: "aa".into(), - path: "generations/9/g.mjs".into(), + path: "deployments/9/g.mjs".into(), effect: PolicyEffect::Observe, }], }; - let round_tripped: ActiveGeneration = + let round_tripped: ActiveDeployment = serde_json::from_str(&serde_json::to_string(&manifest).unwrap()).unwrap(); assert_eq!(round_tripped.policies[0].effect, PolicyEffect::Observe); assert!( @@ -692,22 +692,22 @@ mod tests { PolicyStore::new(root) } - fn desired(generation: u64, id: &str, bytes: &[u8]) -> DesiredState { + fn desired(deployment: u64, id: &str, bytes: &[u8]) -> DesiredState { DesiredState { schema_version: DESIRED_STATE_SCHEMA_VERSION, - generation, + deployment, policies: vec![DesiredPolicy { id: id.to_string(), - revision: generation, + version: deployment, sha256: sha256_hex(bytes), - artifact_url: format!("https://cloud.invalid/{id}/{generation}"), + artifact_url: format!("https://cloud.invalid/{id}/{deployment}"), effect: PolicyEffect::Enforce, }], } } #[test] - fn activates_a_complete_verified_generation() { + fn activates_a_complete_verified_deployment() { let store = temp_store("activate"); let bytes = b"export default 'cloud policy';\n"; let state = desired(7, "block-secrets", bytes); @@ -723,7 +723,7 @@ mod tests { 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.deployment, 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); @@ -750,13 +750,13 @@ mod tests { .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); + assert_eq!(store.read_active().unwrap().unwrap().deployment, 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"); + fn repairs_a_tampered_deployment_copy_from_the_verified_artifact() { + let store = temp_store("repair-deployment"); let bytes = b"export default 'verified';\n"; store .reconcile(&desired(3, "guard", bytes), &|_: &DesiredPolicy| { @@ -764,16 +764,16 @@ mod tests { }) .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 deployment_path = store.root().join(&active.policies[0].path); + fs::write(&deployment_path, b"tampered").unwrap(); assert_eq!(store.repair_active_from_cache().unwrap(), 1); - assert_eq!(fs::read(generation_path).unwrap(), bytes); + assert_eq!(fs::read(deployment_path).unwrap(), bytes); fs::remove_dir_all(store.root()).ok(); } #[test] - fn repairs_a_tampered_artifact_from_the_verified_generation_copy() { + fn repairs_a_tampered_artifact_from_the_verified_deployment_copy() { let store = temp_store("repair-artifact"); let bytes = b"export default 'verified';\n"; let state = desired(4, "guard", bytes); @@ -804,7 +804,7 @@ mod tests { store.repair_active_from_cache(), Err(ReconcileError::Fetch { .. }) )); - assert_eq!(store.read_active().unwrap().unwrap().generation, 5); + assert_eq!(store.read_active().unwrap().unwrap().deployment, 5); fs::remove_dir_all(store.root()).ok(); } @@ -820,7 +820,7 @@ mod tests { fs::write( store.active_manifest_path(), - br#"{"schemaVersion":1,"generation":11,"policies":[]}"#, + br#"{"schemaVersion":1,"deployment":11,"policies":[]}"#, ) .unwrap(); assert_eq!(store.repair_active_from_cache().unwrap(), 1); @@ -836,7 +836,7 @@ mod tests { } #[test] - fn rejects_traversal_duplicate_ids_and_generation_rollback() { + fn rejects_traversal_duplicate_ids_and_deployment_rollback() { let store = temp_store("validation"); let bytes = b"policy"; let mut traversal = desired(1, "../escape", bytes); @@ -866,7 +866,7 @@ mod tests { fs::remove_dir_all(store.root()).ok(); } - /// A tampered local generation must not be able to veto the control plane. + /// A tampered local deployment must not be able to veto the control plane. /// /// The guard used to compare against `active.json`, a 0600 file owned by /// the very user the product's threat model treats as compromised. Writing @@ -876,8 +876,8 @@ mod tests { /// closed on every tool call. A permanent denial of service for one file /// write. #[test] - fn a_tampered_local_generation_cannot_permanently_veto_the_server() { - let store = temp_store("tampered-generation"); + fn a_tampered_local_deployment_cannot_permanently_veto_the_server() { + let store = temp_store("tampered-deployment"); let bytes = b"policy"; store @@ -890,7 +890,7 @@ mod tests { let manifest = store.active_manifest_path(); let raw = fs::read_to_string(&manifest).unwrap(); let mut active: serde_json::Value = serde_json::from_str(&raw).unwrap(); - active["generation"] = serde_json::json!(u64::MAX); + active["deployment"] = serde_json::json!(u64::MAX); fs::write(&manifest, serde_json::to_vec(&active).unwrap()).unwrap(); // A fresh process, as after any restart. It must take the server's @@ -902,7 +902,7 @@ mod tests { }) .expect("the server's state must win over a local pointer"); assert!(outcome.activated); - assert_eq!(restarted.read_active().unwrap().unwrap().generation, 6); + assert_eq!(restarted.read_active().unwrap().unwrap().deployment, 6); // And replay protection still holds WITHIN the session, which is the // transport failure the guard actually exists for. @@ -926,20 +926,20 @@ mod tests { }) .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 deployment_path = store.root().join(&active.policies[0].path); + fs::write(&deployment_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 { + while fs::read(&deployment_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); + assert_eq!(fs::read(deployment_path).unwrap(), bytes); fs::remove_dir_all(store.root()).ok(); } } diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 5e43d89e..dc9fe689 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -90,7 +90,7 @@ fn run() -> Result<(), Box> { // 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 + // it keeps the active deployment 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()?); // One lane, resolving enrolment per tick rather than once at startup. @@ -290,10 +290,10 @@ fn spawn_collector_manager( // machine. telemetry::set_collector_metrics(collector.metrics()); // `Option` because `join_with_flush` CONSUMES the handle: the loop - // has to be able to give a generation away and hold nothing until it + // has to be able to give a deployment away and hold nothing until it // has a replacement. let mut collector = Some(collector); - // The config this generation was built from. Comparing the whole + // The config this deployment was built from. Comparing the whole // `CollectorConfig` rather than just the credential is deliberate: // it also covers a stream being switched off, a verbosity change and // a redaction change, all of which are baked into the tasks at build @@ -374,7 +374,7 @@ fn spawn_collector_manager( continue; } - // Drain what the old generation already spooled BEFORE starting + // Drain what the old deployment already spooled BEFORE starting // the new one. Two collectors sharing a spool directory would // both claim the same batch files. tracing::info!("collector configuration changed; cycling the collector"); @@ -398,7 +398,7 @@ fn spawn_collector_manager( } // Control falls through to the spawn below. `next_cfg` is // refreshed to whatever re-enabled collection, because THAT - // is what the new generation will be built from. + // is what the new deployment will be built from. } let next_cfg = current_collector_config().unwrap_or(next_cfg); @@ -411,10 +411,10 @@ fn spawn_collector_manager( running_cfg = Some(next_cfg); continue; }; - // Replaces the previous generation's counters. The registry is a + // Replaces the previous deployment's counters. The registry is a // RwLock rather than a OnceLock for exactly this — a set-once // slot left telemetry polling a collector that had been joined, - // reporting a dead generation's totals as current. + // reporting a dead deployment's totals as current. telemetry::set_collector_metrics(next_collector.metrics()); collector = Some(next_collector); // What was BUILT FROM, not a fresh read. Re-reading here loses diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs index 7a113e92..81767c6c 100644 --- a/crates/failproofaid/src/paths.rs +++ b/crates/failproofaid/src/paths.rs @@ -55,14 +55,14 @@ pub fn worker_socket_path() -> io::Result { Ok(run_dir()?.join("worker.sock")) } -/// `~/.failproofai/policies/cloud-policies` — where pulled generations land. +/// `~/.failproofai/policies/cloud-policies` — where pulled deployments land. /// The override keeps tests and development runs away from a user's real /// policy directory. /// /// The directory name is `cloud-policies`, matching `fp-home.ts`'s /// `cloudPoliciesDir`, which is what the hook path actually reads. Layout 2 /// renamed it from `cloud-managed` and this function kept writing the old -/// name — so the daemon downloaded every generation, verified it, wrote it to +/// name — so the daemon downloaded every deployment, verified it, wrote it to /// disk, and the CLI read an empty directory and enforced nothing. Both halves /// looked healthy; only the combination was broken. pub fn cloud_managed_policy_dir() -> io::Result { @@ -285,7 +285,7 @@ mod tests { fn pulled_policies_land_where_the_cli_reads_them() { // `fp-home.ts`: `cloudPoliciesDir = policies/cloud-policies`. This // wrote layout 1's `policies/cloud-managed`, so the daemon downloaded - // every generation, verified its hashes, wrote it to disk — and the CLI + // every deployment, verified its hashes, wrote it to disk — and the CLI // read an empty directory and enforced nothing. Both halves logged // success; only the combination was broken. let _guard = ENV_LOCK.lock().unwrap(); @@ -439,7 +439,7 @@ mod tests { /// were live bugs at once: `run_dir` read `$HOME` while the CLI honoured /// `FAILPROOFAI_HOME` (so a healthy daemon denied every tool call on a /// fail-closed machine), `cloud_managed_policy_dir` still wrote layout 1's - /// `cloud-managed` (so every verified generation landed in a directory + /// `cloud-managed` (so every verified deployment landed in a directory /// nothing opened), and the credential moved to `credentials.toml` on one /// side only (so `--connect` wrote a token the daemon never read). Each was /// fixed by hand; nothing stopped the next one, and BOTH files cited a test diff --git a/crates/failproofaid/src/telemetry.rs b/crates/failproofaid/src/telemetry.rs index c6316157..69e01711 100644 --- a/crates/failproofaid/src/telemetry.rs +++ b/crates/failproofaid/src/telemetry.rs @@ -148,7 +148,7 @@ static LANE: OnceLock> = OnceLock::new(); /// REPLACEABLE, not set-once. The collector is cycled whenever its configuration /// changes, and a `OnceLock` silently dropped every set after the first — so /// after a credential rotation this lane went on polling the counters of a -/// collector that had already been joined, reporting a dead generation's totals +/// collector that had already been joined, reporting a dead deployment's totals /// as if they were current. Nothing errored; the numbers simply stopped moving, /// which is indistinguishable from a healthy idle machine. static COLLECTOR_METRICS: RwLock>> = RwLock::new(None); @@ -812,7 +812,7 @@ impl Runner { fn poll_collector(&mut self) { // Cloned out of the lock rather than read through it: `poll_collector` // does real work below, and holding a read guard across it would block - // the manager thread mid-cycle when it swaps in a new generation. + // the manager thread mid-cycle when it swaps in a new deployment. let Some(metrics) = COLLECTOR_METRICS .read() .unwrap_or_else(|e| e.into_inner()) @@ -1411,8 +1411,8 @@ mod tests { "failures", "panics", "restarts", - "generation", - "generation_changed", + "deployment", + "deployment_changed", "downloaded", "repaired", ]; diff --git a/crates/failproofaid/tests/collector_reload_e2e.rs b/crates/failproofaid/tests/collector_reload_e2e.rs index acdcc686..9cd28311 100644 --- a/crates/failproofaid/tests/collector_reload_e2e.rs +++ b/crates/failproofaid/tests/collector_reload_e2e.rs @@ -176,9 +176,9 @@ fn it_keeps_noticing_changes_rather_than_reloading_once() { // A one-shot reload would pass the test above and still strand the second // rotation, so the loop is asserted rather than the first iteration. // - // Each edit waits for the previous generation to be RUNNING, not merely for + // Each edit waits for the previous deployment to be RUNNING, not merely for // the cycle to be announced. "cycling the collector" is logged before - // `join_with_flush` drains the old generation, so an edit made on that log + // `join_with_flush` drains the old deployment, so an edit made on that log // line lands mid-cycle and is picked up by the same rebuild — the daemon // coalesces to the latest config, correctly, and no second cycle is ever // logged. Asserting the count without this sync tests the timing of the @@ -202,7 +202,7 @@ fn it_keeps_noticing_changes_rather_than_reloading_once() { fn an_unchanged_config_does_not_cycle_anything() { // Re-reading the file every tick must not look like a change, or the // collector would be torn down and rebuilt twice a second — losing the - // in-flight batches of every generation. + // in-flight batches of every deployment. let home = unique_home("stable"); make_home(&home, "a-stable-key"); let daemon = spawn_daemon(&home); diff --git a/crates/fpai-collect/src/health.rs b/crates/fpai-collect/src/health.rs index 1b45bc9d..28129933 100644 --- a/crates/fpai-collect/src/health.rs +++ b/crates/fpai-collect/src/health.rs @@ -153,11 +153,11 @@ impl Health { /// `COLLECTOR_METRICS`, for the same reason and with the same consequence. /// /// This was a `OnceLock`, so only the FIRST `install()` in a process took -/// effect. That was true for exactly one generation of the collector: it is now +/// effect. That was true for exactly one deployment of the collector: it is now /// cycled whenever its configuration changes (a rotated credential, a stream /// switched off) and rebuilt by `failproofai backfill`, and `collector_tasks()` /// installs a fresh `Health` each time. Every later install was silently -/// dropped, so sources reported into the ORPHANED first-generation registry +/// dropped, so sources reported into the ORPHANED first-deployment registry /// while the live `writer_task` published the one nobody was writing to. /// `collector-health.json` kept being rewritten every 30s with frozen numbers, /// and a source that had gone completely dark read exactly like a healthy one — @@ -167,7 +167,7 @@ static GLOBAL: std::sync::RwLock>> = std::sync::Rw /// Install the process health registry, replacing any previous one. /// /// The caller installs before starting any source, so no poll ever reports into -/// a registry that does not exist yet, and the generation being replaced is one +/// a registry that does not exist yet, and the deployment being replaced is one /// whose tasks have already been joined. pub fn install(health: std::sync::Arc) { // A poisoned lock is not a reason to stop recording health: recover the @@ -388,7 +388,7 @@ mod tests { /// `GLOBAL` was a `OnceLock`, so the second `install()` — which happens on /// every credential rotation, every `[collector]` config change and every /// `failproofai backfill` — was silently dropped. Sources then reported - /// through the free functions into the first generation, which had already + /// through the free functions into the first deployment, which had already /// been joined, while the live `writer_task` published the second one. /// `collector-health.json` froze, and a dead source became /// indistinguishable from an idle one. @@ -415,17 +415,17 @@ mod tests { assert_eq!(entry.events, 7); assert_eq!(entry.errors, 1); - // And the replaced generation must go quiet rather than keep absorbing + // And the replaced deployment must go quiet rather than keep absorbing // them — that silent absorption is what made the file freeze. let gen1 = first.snapshot(); assert!( !gen1.sources.contains_key("cycle-test-gen2"), - "reports leaked into the orphaned generation: {:?}", + "reports leaked into the orphaned deployment: {:?}", gen1.sources.keys().collect::>() ); assert!( gen1.sources.contains_key("cycle-test-gen1"), - "the first generation should still hold what it recorded while it was live" + "the first deployment should still hold what it recorded while it was live" ); } diff --git a/crates/fpai-collect/src/sources/hooks/transform.rs b/crates/fpai-collect/src/sources/hooks/transform.rs index 03fceb1c..de4566d3 100644 --- a/crates/fpai-collect/src/sources/hooks/transform.rs +++ b/crates/fpai-collect/src/sources/hooks/transform.rs @@ -70,14 +70,14 @@ pub struct HookRow { pub policy_source: Option, #[serde(rename = "cloudPolicyId")] pub cloud_policy_id: Option, - #[serde(rename = "cloudRevision")] - pub cloud_revision: Option, + #[serde(rename = "cloudVersion")] + pub cloud_version: Option, /// Present on EVERY row of a managed machine, not just cloud-decided ones: /// "what was deployed here" is a different question from "what decided", /// and only the former separates a rollout that changed no outcomes from /// one that never reached the machine. - #[serde(rename = "cloudGeneration")] - pub cloud_generation: Option, + #[serde(rename = "cloudDeployment")] + pub cloud_deployment: Option, // ---- Suspension ------------------------------------------------------ /// Set while `failproofai config --pause` is in effect. An `allow` on such @@ -103,8 +103,8 @@ pub struct HookRow { pub struct Attribution { pub policy_source: Option, pub cloud_policy_id: Option, - pub cloud_revision: Option, - pub cloud_generation: Option, + pub cloud_version: Option, + pub cloud_deployment: Option, pub paused: bool, } @@ -113,8 +113,8 @@ impl Attribution { Self { policy_source: row.policy_source.clone(), cloud_policy_id: row.cloud_policy_id.clone(), - cloud_revision: row.cloud_revision, - cloud_generation: row.cloud_generation, + cloud_version: row.cloud_version, + cloud_deployment: row.cloud_deployment, paused: row.paused_by.is_some(), } } @@ -131,11 +131,11 @@ impl Attribution { if let Some(id) = &self.cloud_policy_id { m.insert("cloud_policy_id".into(), json!(id)); } - if let Some(r) = self.cloud_revision { - m.insert("cloud_revision".into(), json!(r)); + if let Some(r) = self.cloud_version { + m.insert("cloud_version".into(), json!(r)); } - if let Some(g) = self.cloud_generation { - m.insert("cloud_generation".into(), json!(g)); + if let Some(g) = self.cloud_deployment { + m.insert("cloud_deployment".into(), json!(g)); } // Always emitted, never conditionally: an absent key and `false` must // not be distinguishable to a reader counting unenforced calls. @@ -320,7 +320,7 @@ pub fn to_events(row: &HookRow, offset: u64, environment: &str) -> Vec { } if row.has_observation() { // Carried whole rather than flattened: a row can observe several - // policies at once, and the id/revision/decision only mean anything + // policies at once, and the id/version/decision only mean anything // together. end.insert( "failproofai_observed".into(), @@ -427,8 +427,8 @@ impl AllowBucket { // dedups on `hook_id`, so the split was undone downstream and the two // rows collapsed back into one. That happened in exactly the two cases // the split was built for: the minute a pause starts, and the minute a - // cloud generation flips during a rollout, which is the measurement - // `cloud_generation` exists to enable. + // cloud deployment flips during a rollout, which is the measurement + // `cloud_deployment` exists to enable. let a = &self.attribution; m.insert( "hook_id".into(), @@ -440,10 +440,10 @@ impl AllowBucket { self.tool_name.as_deref().unwrap_or("-"), a.policy_source.as_deref().unwrap_or("-"), a.cloud_policy_id.as_deref().unwrap_or("-"), - a.cloud_revision + a.cloud_version .map(|v| v.to_string()) .unwrap_or_else(|| "-".into()), - a.cloud_generation + a.cloud_deployment .map(|v| v.to_string()) .unwrap_or_else(|| "-".into()), if a.paused { "paused" } else { "-" }, diff --git a/crates/fpai-collect/tests/hooks_source.rs b/crates/fpai-collect/tests/hooks_source.rs index abe5c293..5c94ab06 100644 --- a/crates/fpai-collect/tests/hooks_source.rs +++ b/crates/fpai-collect/tests/hooks_source.rs @@ -454,8 +454,8 @@ fn attributed_deny_row() -> String { "cwd": "/home/sidd/work/failproofai", "policySource": "cloud", "cloudPolicyId": "org-blocks-curl", - "cloudRevision": 3, - "cloudGeneration": 8, + "cloudVersion": 3, + "cloudDeployment": 8, "pausedBy": "session", "pauseExpiresAt": 1785742712184i64 }) @@ -480,8 +480,8 @@ async fn a_decision_carries_its_attribution_to_the_server() { // Exactly the keys the server's queries extract. assert_eq!(end["policy_source"], "cloud"); assert_eq!(end["cloud_policy_id"], "org-blocks-curl"); - assert_eq!(end["cloud_revision"], 3); - assert_eq!(end["cloud_generation"], 8); + assert_eq!(end["cloud_version"], 3); + assert_eq!(end["cloud_deployment"], 8); assert_eq!(end["paused"], true, "must be a bool, not a string"); assert_eq!(end["paused_by"], "session"); @@ -532,7 +532,7 @@ async fn an_allow_rollup_never_mixes_two_policy_sources() { "timestamp": 1785740912000i64, "eventType": "PreToolUse", "integration": "claude", "toolName": "Bash", "decision": "allow", "durationMs": 2, "sessionId": "s1", "cwd": "/w", "policySource": "cloud", - "cloudPolicyId": "org-guard", "cloudRevision": 2, "cloudGeneration": 8 + "cloudPolicyId": "org-guard", "cloudVersion": 2, "cloudDeployment": 8 }) .to_string(); let plain_allow = serde_json::json!({ @@ -571,7 +571,7 @@ async fn an_allow_rollup_never_mixes_two_policy_sources() { // so two events carrying the SAME id collapse back into one row in the // product — undoing this split downstream, silently, in exactly the two // cases it exists for: the minute a pause starts, and the minute a cloud - // generation flips during a rollout. The aggregate id was built from + // deployment flips during a rollout. The aggregate id was built from // session/minute/event/tool only, all four of which are identical here by // construction, so both events shipped with byte-identical ids and this // test passed anyway. @@ -597,14 +597,14 @@ async fn an_observed_verdict_survives_the_allow_rollup() { let observed = serde_json::json!({ "timestamp": 1785740912000i64, "eventType": "PreToolUse", "integration": "claude", "toolName": "Bash", "decision": "allow", "durationMs": 3, - "sessionId": "s1", "cwd": "/w", "cloudGeneration": 8, - "observed": [{"policyId": "org-trials-git-push", "revision": 1, "decision": "deny"}] + "sessionId": "s1", "cwd": "/w", "cloudDeployment": 8, + "observed": [{"policyId": "org-trials-git-push", "version": 1, "decision": "deny"}] }) .to_string(); let ordinary = serde_json::json!({ "timestamp": 1785740912100i64, "eventType": "PreToolUse", "integration": "claude", "toolName": "Bash", "decision": "allow", "durationMs": 1, - "sessionId": "s1", "cwd": "/w", "cloudGeneration": 8 + "sessionId": "s1", "cwd": "/w", "cloudDeployment": 8 }) .to_string(); diff --git a/src/hooks/cloud-connection.ts b/src/hooks/cloud-connection.ts index 8a945a0e..52fedbde 100644 --- a/src/hooks/cloud-connection.ts +++ b/src/hooks/cloud-connection.ts @@ -137,7 +137,7 @@ export interface CapabilityOutcome { } export interface ConnectOutcome { - policy: CapabilityOutcome & { policyCount?: number; generation?: number }; + policy: CapabilityOutcome & { policyCount?: number; deployment?: number }; ingest: CapabilityOutcome; /** Which organisation the key belongs to. Absent on a pre-introspect server. */ org?: { id?: string; slug?: string; name?: string }; @@ -232,7 +232,7 @@ export async function connectToCloud(input: ConnectInput): Promise/ per-source collector watermarks * audit/ audit report + per-session cache @@ -135,7 +135,7 @@ export const localPoliciesDir = (home?: string) => atHome(home, "policies", "loc export const globalPolicyConfigFile = (home?: string) => resolve(localPoliciesDir(home), "policies-config.json"); -/** Cloud-managed generations: `active.json` plus content-addressed artifacts. */ +/** Cloud-managed deployments: `active.json` plus content-addressed artifacts. */ export const cloudPoliciesDir = (home?: string) => resolve(policiesDir(home), "cloud-policies"); /** User convention policies (`*.mjs`) that load without any flag. */ diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index c9afc5ce..c093afd5 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -254,13 +254,13 @@ export async function evaluateHookEvent( /** Registered policy name → where it came from. See the set() below. */ const policyAttribution = new Map< string, - { source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudRevision?: number } + { source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudVersion?: number } >(); - let cloudGeneration: number | undefined; + let cloudDeployment: number | undefined; /** What observe-mode policies WOULD have done, had they been enforcing. */ const observedResults: Array<{ policyId: string; - revision: number; + version: number; decision: "deny" | "instruct"; reason: string | null; }> = []; @@ -293,7 +293,7 @@ export async function evaluateHookEvent( // Cloud-managed policies are daemon-reconciled artifacts, but they use // the same public JS policy API as local custom policies. Verify and add - // only the paths referenced by the atomically active generation. + // only the paths referenced by the atomically active deployment. // // Wrapped, like `readConfigAt` and `readActivePause` above it. This call // has fourteen throw sites — a malformed manifest, an unsafe id, an @@ -332,7 +332,7 @@ export async function evaluateHookEvent( // policy decided this event: "what was deployed here at the time" is a // separate question from "what decided", and only the former can tell a // rollout that changed nothing from one that never reached the machine. - cloudGeneration = cloudManagedPolicies[0]?.generation; + cloudDeployment = cloudManagedPolicies[0]?.deployment; const configuredCustomPaths = config.customPoliciesPaths ?? config.customPoliciesPath; const allExplicitPaths = cloudManagedPolicies.length === 0 @@ -368,7 +368,7 @@ export async function evaluateHookEvent( const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope; const isConvention = !!conventionScope; const prefix = cloudManaged - ? `cloud/${cloudManaged.id}@${cloudManaged.revision}` + ? `cloud/${cloudManaged.id}@${cloudManaged.version}` : isConvention ? `.failproofai-${conventionScope}` : "custom"; @@ -383,7 +383,7 @@ export async function evaluateHookEvent( if (shadow.decision !== "allow") { observedResults.push({ policyId: cloudManaged!.id, - revision: cloudManaged!.revision, + version: cloudManaged!.version, decision: shadow.decision, reason: shadow.reason ?? null, }); @@ -419,7 +419,7 @@ export async function evaluateHookEvent( // this decision", could only be answered by re-parsing our own label. policyAttribution.set(registeredName, { source: cloudManaged ? "cloud" : isConvention ? "convention" : "custom", - ...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudRevision: cloudManaged.revision } : {}), + ...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudVersion: cloudManaged.version } : {}), }); registerPolicy( registeredName, @@ -497,13 +497,13 @@ export async function evaluateHookEvent( return { policySource: attribution?.source ?? ("builtin" as const), ...(attribution?.cloudPolicyId ? { cloudPolicyId: attribution.cloudPolicyId } : {}), - ...(attribution?.cloudRevision !== undefined - ? { cloudRevision: attribution.cloudRevision } + ...(attribution?.cloudVersion !== undefined + ? { cloudVersion: attribution.cloudVersion } : {}), }; })() : {}), - ...(cloudGeneration !== undefined ? { cloudGeneration } : {}), + ...(cloudDeployment !== undefined ? { cloudDeployment } : {}), // The point of observe mode is this record. Without it the rollout is // unmeasurable and the row is indistinguishable from one where the policy // never matched at all. diff --git a/src/hooks/hook-activity-store.ts b/src/hooks/hook-activity-store.ts index 2a958726..07c875fa 100644 --- a/src/hooks/hook-activity-store.ts +++ b/src/hooks/hook-activity-store.ts @@ -99,16 +99,16 @@ export interface HookActivityEntry { policySource?: "builtin" | "custom" | "convention" | "cloud"; /** Cloud policy id of the decider. Present only when `policySource` is "cloud". */ cloudPolicyId?: string; - /** Immutable revision of that policy — the half of attribution that identifies WHICH version ran. */ - cloudRevision?: number; + /** Immutable version of that policy — the half of attribution that identifies WHICH version ran. */ + cloudVersion?: number; /** - * The cloud generation active when this event was evaluated, recorded on + * The cloud deployment active when this event was evaluated, recorded on * every row of a managed machine regardless of what decided. "What was * deployed here" is a different question from "what decided", and only this * distinguishes a rollout that changed no outcomes from one that never * arrived. */ - cloudGeneration?: number; + cloudDeployment?: number; /** * What observe-mode policies WOULD have done, had they been enforcing. This * record is the entire point of observe mode: without it the row is @@ -117,7 +117,7 @@ export interface HookActivityEntry { */ observed?: Array<{ policyId: string; - revision: number; + version: number; decision: "deny" | "instruct"; reason: string | null; }>; diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 1b7db92e..96abc3c2 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -852,7 +852,7 @@ export async function listHooks(cwd?: string): Promise { try { const cloud = readActiveCloudManagedPolicies(); if (cloud.length > 0) { - const gen = cloud[0].generation; + const gen = cloud[0].deployment; console.log( `\n \u2500\u2500 Cloud-managed \u2014 deployment ${gen} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`, ); @@ -863,7 +863,7 @@ export async function listHooks(cwd?: string): Promise { // not doing. const status = artifact.effect === "observe" ? "\x1B[33m\u25D0 OBS\x1B[0m" : "\x1B[32m\u2713 ON\x1B[0m"; - console.log(` ${status} ${artifact.id.padEnd(colWidth)}v${artifact.revision}`); + console.log(` ${status} ${artifact.id.padEnd(colWidth)}v${artifact.version}`); } console.log("\n Managed from the dashboard \u2014 not switchable with `failproofai policies`."); console.log(); From 0e3045bf65419b47c543c0e38a9779e5c5ef5d81 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 7 Aug 2026 19:52:24 +0530 Subject: [PATCH 05/11] fix(test): complete the promptText stdin mock so vitest stops reporting an unhandled rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI went red on a run where every test passed: `190 files passed, 3218 tests passed`, then `Errors 2 errors` and exit 1. `readline.emitKeypressEvents()` calls `listenerCount` on the stream it is given, and the mock in the new promptText tests did not have it. The call threw, and because the test deliberately does not await the prompt — `void promptText(...)`, since the prompt only resolves on a keypress — the throw surfaced as an UNHANDLED REJECTION rather than a failing assertion. That is why it passed locally and failed in CI: an unhandled rejection is a warning in a terminal and a job failure in the pipeline. The tests were testing the right thing; the mock was incomplete. Adds `listenerCount`, `once`, `off`, `emit` and `addListener` — the surface `emitKeypressEvents` actually touches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BDLTPtbQvUE62eCfQrUbf --- __tests__/hooks/configure-wizard.test.ts | 2 +- __tests__/hooks/tui.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index d7c497cf..5285e0d1 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -370,7 +370,7 @@ describe("configure-wizard pure builders", () => { it("omits every optional note when nothing is present", () => { const message = buildCompletionSummary(2, 1, undefined, false, false); - expect(message).toBe("Setup complete — 2 policies · 1 assistant"); + expect(message).toBe("Setup complete — 2 policies · 1 harness"); }); }); diff --git a/__tests__/hooks/tui.test.ts b/__tests__/hooks/tui.test.ts index f0c657d2..4993a633 100644 --- a/__tests__/hooks/tui.test.ts +++ b/__tests__/hooks/tui.test.ts @@ -161,6 +161,16 @@ describe("promptText redraw stays on one physical row", () => { pause: vi.fn(), on: vi.fn((ev: string, fn: never) => { if (ev === "keypress") onKey = fn; }), removeListener: vi.fn(), + // `readline.emitKeypressEvents` calls these on the stream it is given. + // Without them the promise rejects, and because this test deliberately + // does not await it (`void promptText(...)`), the rejection surfaces as an + // UNHANDLED error — tests all green, job red, which is exactly how it + // reached CI. + listenerCount: vi.fn(() => 0), + once: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + addListener: vi.fn(), } as unknown as TTYIn; void promptText({ message, hint, mask: true, stdin, stdout }); From 3ecab8272020bf0110984622c1d4eb6805b44ba0 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 7 Aug 2026 19:37:09 +0530 Subject: [PATCH 06/11] Capture sessions from more than one location per agent CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every source watched exactly the place its own installer puts it — ~/.claude/projects, ~/.hermes/state.db. That is right for one machine and wrong for every other arrangement: a second profile, a mounted team share, a container's home beside the host's, an agent an operator relocated. Those hold real sessions and nothing collected them. `failproofai harness add-path [
diff --git a/crates/failproofaid/src/cloud_policies.rs b/crates/failproofaid/src/cloud_policies.rs index 1d93e837..15a92d7e 100644 --- a/crates/failproofaid/src/cloud_policies.rs +++ b/crates/failproofaid/src/cloud_policies.rs @@ -33,6 +33,11 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); #[serde(rename_all = "camelCase")] pub struct DesiredState { pub schema_version: u32, + /// `generation` is the pre-rename spelling. Accepted as an alias because + /// this value is BOTH received from the server and persisted to + /// `desired-state.json` — so an upgraded daemon meets the old name on disk + /// even against a server that has already moved on. + #[serde(alias = "generation")] pub deployment: u64, pub policies: Vec, } @@ -41,6 +46,8 @@ pub struct DesiredState { #[serde(rename_all = "camelCase")] pub struct DesiredPolicy { pub id: String, + /// `revision` is the pre-rename spelling. See `DesiredState::deployment`. + #[serde(alias = "revision")] pub version: u64, pub sha256: String, /// Opaque locator interpreted only by the cloud transport implementation. @@ -70,6 +77,16 @@ pub enum PolicyEffect { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ActiveDeployment { pub schema_version: u32, + /// `generation` is what every daemon before the rename wrote here. + /// + /// The alias is load-bearing rather than tidy: this struct carries + /// `deny_unknown_fields`, so without it an upgraded daemon fails to parse + /// its OWN `active.json` on three counts at once — `generation` unrecognised, + /// `deployment` missing, and the same again for every policy's `revision`. + /// A machine would silently lose the deployment it was enforcing until a + /// poll succeeded, which on a fail-closed machine is the gap this whole + /// subsystem exists to prevent. + #[serde(alias = "generation")] pub deployment: u64, pub policies: Vec, } @@ -78,6 +95,8 @@ pub struct ActiveDeployment { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ActivePolicy { pub id: String, + /// `revision` is the pre-rename spelling. See `ActiveDeployment::deployment`. + #[serde(alias = "revision")] pub version: u64, pub sha256: String, /// Relative to the cloud-managed root. Never supplied by the server. @@ -943,3 +962,87 @@ mod tests { fs::remove_dir_all(store.root()).ok(); } } + +#[cfg(test)] +mod pre_rename_state_tests { + use super::*; + + /// Byte-exact `active.json` written by a daemon before the + /// generation→deployment / revision→version rename, captured from a live + /// machine rather than hand-written. + const PRE_RENAME_ACTIVE: &str = r#"{ + "schemaVersion": 1, + "generation": 1, + "policies": [ + { + "id": "e2e-block-curl", + "revision": 1, + "sha256": "732c6e780e183a15259688d858e4ec0db20c7dd13352601c73db5540122e2c30", + "path": "generations/1/e2e-block-curl.mjs", + "effect": "enforce" + } + ] +}"#; + + /// The upgrade case. `ActiveDeployment` carries `deny_unknown_fields`, so + /// without the aliases this fails on three counts at once: `generation` + /// unrecognised, `deployment` missing, and `revision`/`version` likewise per + /// policy. The machine would lose the deployment it was already enforcing + /// until a poll succeeded — on a fail-closed machine, exactly the gap this + /// subsystem exists to close. + #[test] + fn active_json_written_before_the_rename_still_parses() { + let parsed: ActiveDeployment = serde_json::from_str(PRE_RENAME_ACTIVE) + .expect("a pre-rename active.json must still be readable after an upgrade"); + assert_eq!(parsed.schema_version, 1); + assert_eq!(parsed.deployment, 1); + assert_eq!(parsed.policies.len(), 1); + assert_eq!(parsed.policies[0].version, 1); + assert_eq!(parsed.policies[0].id, "e2e-block-curl"); + assert_eq!(parsed.policies[0].effect, PolicyEffect::Enforce); + } + + /// `desired-state.json` is persisted too, and the server may still be + /// sending the old spelling while a machine has already upgraded. + #[test] + fn desired_state_accepts_the_pre_rename_spelling() { + let parsed: DesiredState = serde_json::from_str( + r#"{"schemaVersion":1,"generation":184, + "policies":[{"id":"p","revision":7,"sha256":"a", + "artifactUrl":"/enforcement/v1/artifacts/a"}]}"#, + ) + .expect("a pre-rename desired state must still be readable"); + assert_eq!(parsed.deployment, 184); + assert_eq!(parsed.policies[0].version, 7); + } + + /// The new spelling is what we WRITE, and must keep round-tripping — an + /// alias that quietly became the canonical name would be its own bug. + #[test] + fn the_current_spelling_round_trips() { + let state = ActiveDeployment { + schema_version: 1, + deployment: 9, + policies: vec![ActivePolicy { + id: "p".into(), + version: 3, + sha256: "a".into(), + path: "deployments/9/p.mjs".into(), + effect: PolicyEffect::Observe, + }], + }; + let text = serde_json::to_string(&state).unwrap(); + assert!( + text.contains("\"deployment\":9"), + "must serialize the NEW name: {text}" + ); + assert!( + text.contains("\"version\":3"), + "must serialize the NEW name: {text}" + ); + assert_eq!( + serde_json::from_str::(&text).unwrap(), + state + ); + } +} diff --git a/src/hooks/flush-cli.ts b/src/hooks/flush-cli.ts index 01595809..1c413bcd 100644 --- a/src/hooks/flush-cli.ts +++ b/src/hooks/flush-cli.ts @@ -106,18 +106,35 @@ export async function runFlushCommand(opts: FlushOptions = {}): Promise(p: PromptSpec): Promise { }; const collapse = (result: R | null): void => { + // BACK is handled HERE, not in each `summaryFor`, because it is not a value + // of `R` at all — it is a sentinel the shared key handler injects, so every + // prompt would otherwise have to know about a symbol it never declared. + // + // Both existing callers got it wrong in different ways, and one of them + // hung: `multiSelect`'s summary calls `values.includes(...)`, which throws + // `TypeError` on a symbol — and it throws INSIDE `finish`, before + // `resolve(result)`, so pressing ← never settled the promise and the wizard + // stopped responding entirely. `selectOne` fell through to `String(value)` + // and rendered the literal text `Symbol(failproofai.back)`. + const summary = (result as unknown) === BACK ? "back" : p.summaryFor(result); repaint(stdout, region, [ c.dim(BAR), `${c.dim(STEP_DONE)} ${p.message}`, - `${c.dim(BAR)} ${c.dim(p.summaryFor(result))}`, + `${c.dim(BAR)} ${c.dim(summary)}`, ]); }; From 76acc68f516f3004337c1fdf9abcd2a4c4bada28 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 7 Aug 2026 23:29:26 +0530 Subject: [PATCH 10/11] Move cloud policy to schemaVersion 2, and accept 1 only from disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 desired-state payload named its fields `generation` and `revision`. After the rename it carried neither, at the same version number — same endpoint, same version, different shape, which is the one thing a schema version exists to prevent. AgentEye#559 now emits 2; this accepts both. 1 is accepted ONLY for files already on disk. A machine that ran an earlier beta has a `desired-state.json` and an `active.json` written at version 1, and both structs carry `deny_unknown_fields` — so refusing that version would leave the daemon unable to read its own persisted state, silently not enforcing cloud policy until a poll re-materialised everything. That same asymmetry decides where the field aliases live. They stay on the persisted `ActiveDeployment`/`ActivePolicy`, whose bytes may have been written by an older daemon than the one now reading them. They are REMOVED from the wire `DesiredState`/`DesiredPolicy`, because no server can emit the old spelling — an alias there is dead code, and a silently-tolerated stale field is exactly how two sides drift back apart. A test pins each half, including that the wire now REFUSES the old spelling rather than quietly taking it. The TypeScript hook reader had the same constant and accepted only 1. That is the worst-shaped version of this bug: the daemon reconciles, writes a correct `active.json`, reports "deployment 1 active" — and the hook path alone refuses it, so cloud policy stops being enforced while every other signal says the machine is healthy. Reproduced exactly that way while syncing against a live #559 server, before this fix. Verified end to end against the real stack: publish a policy, deploy it, the daemon pulls schemaVersion 2, verifies every digest, activates, and a matching tool call is denied with that policy's reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CCSFM55BcUEAHECabpeSr4 --- CHANGELOG.md | 1 + crates/failproofaid/src/cloud_policies.rs | 99 ++++++++++++++++++----- src/hooks/cloud-managed-policies.ts | 23 +++++- 3 files changed, 99 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7011cc9..b50ec765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Capture sessions from more than one location per agent CLI. Every source watched exactly the place its own installer puts it — `~/.claude/projects`, `~/.hermes/state.db` — which is right for one machine and wrong for every other arrangement: a second profile, a mounted team share, a container's home beside the host's, an agent an operator relocated. Those hold real sessions and nothing collected them. `failproofai harness add-path [