diff --git a/CHANGELOG.md b/CHANGELOG.md index e1576b070..f09f212b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## 1.0.11-beta.1 — 2026-10-13 + +### Features + +- New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#881) +- Pickers in the new look: an UPPERCASE heading with a live "n of m selected", a `›` cursor and `■ □` boxes, twelve rows before scrolling, plain `a` to toggle all, and the key hints at the foot. The `policies -i` agent menu now uses the shared picker (#881) +- Every `--help` page in the new look, cut to usage, options and examples, with every real flag and its real default. The index opens with three getting-started steps, then lists every command by what it is for. `policies add|remove|show --help` now prints the one policies page (#881) +- Every error in the new look: `✕` and what went wrong, then the fix on its own line with the command in pink, instead of `Error: …`. An unknown word gets "There is no command called 'status'." and "Did you mean failproofai config --status?": words people type (`status`, `login`, `install`, `pause` and more) map to the command that does it, a typo finds the nearest command, and a word close to none gets no guess and points at `failproofai help`. Exit codes are unchanged (#881) +- The notices printed above a command — a daemon that does not match the CLI, the daemon self-heal, a reorganised or newer `~/.failproofai`, and the `--token` warning — are each one state line and one fix line (#881) +- New look for `failproofai policies`, `policies add ` and `policies show `: every policy is ● on or ○ off, packs are named once at the top, off policies fold into one line per category, and the new `failproofai policies --all` lists every one. At most one line asks for attention, most severe first: a pack that will not load (and so denies what it covers), then "Policies are not enforcing yet." (#881) +- `policies show / --releases` and the empty `policies add` picker are drawn in the new look (#881) +- `policies --install` / `--uninstall` results in the new look: one ✓ per agent with where it was written, and the duplicate-scope warning as one ▲ line with its fix. It no longer prints a hard-coded colour code into pipes or under NO_COLOR (#881) +- `failproofai config --status` in the new look: THIS MACHINE (the daemon, pauses), CLOUD and ENFORCEMENT (agents traced, policies on) on one column, now with delivery health from the daemon's own record (last event, backlog, refused batches) and at most one line that needs attention. Per-agent rows, such as one per Hermes profile, are gone: the screen is about the machine. `--pause` and `--resume` confirm with one ✓ sentence, and a pause states the time actually granted, which the 8-hour limit can make shorter than asked (#881) +- New terminal look for the CLI, the dashboard launch screen: bare `failproofai` prints it once the dashboard is actually listening, with its address, the policies that are on, the agents traced and the cloud connection, plus one warning when policies are not enforcing or the dashboard is reachable from other machines. A server that fails to start says why in one line (a taken port is named as such), Next.js's own startup lines are gone, piped output carries no colour codes, and `failproofai audit` hands off to the dashboard without the logomark or the screen (#881) +- `failproofai audit` shows a progress bar per agent and running counts of what would be blocked or warned, redrawn at most every 100 ms in one write per frame; piped or under `NO_COLOR` it prints the finished screen once. Its sign-in, `--schedule`, `--no-schedule` and `--status` screens move to the new look, and audit no longer honours `FORCE_COLOR`, like every other screen (#881) +- `flush`, `migrate`, `update`, `uninstall`, `config --connect`, `--disconnect` and a machine rename print their results in the new look: the header names the command, and a failure opens with ✕ and its fix indented under it (#881) +- `failproofai uninstall` asks one question, `Continue? y/N`, under a plan that says exactly what goes: the hooks and which agents they are in, the failproofaid service, the policy settings in policies-config.json, and `~/.failproofai` with `--purge`. Yes removes all of it, the service included, as `--yes` does; the second "remove the service too?" question is gone (`policies --uninstall` removes only the hooks). `--yes` and the refusal without a terminal are unchanged (#881) +- `failproofai publish` ends on the new done screen: a ✓ per side effect (a commit, a bundle, a new repository), then "Published @ with N policies." and the command to install it. A private repository gets a ▲ in the install line's place (#881) +- `failproofai jev status` in the new look: a status row, then provider and model, endpoint, mode, timeout, key and config, the activity block, and the next command last. There is no "last test" row, because nothing records one (#881) +- `jev setup`, `jev test`, `jev models` and `jev remove` results in the new look: the header names the subcommand and its outcome, with kv rows, grey notes, ▲ warnings and one-line next steps (#881) +- A new API-key prompt for `failproofai config`: one masked field, Tab for open source, a saved key shown at once and checked in the background, a spinner while a key is checked, and a refused key kept in the field to retry. A tab or newline inside a paste no longer switches mode or submits halfway (#881) +- `failproofai config` v2: after sudo, one API-key field (Tab for open source; Enter keeps a saved key, which is checked in the background), then which agents failproofai traces. That one remembered list drives hooks, collection and backfill. Every agent is listed: the detected ones are ticked on a first run, a re-run opens on the last answer with newly detected agents ticked, and an upgraded machine opens on what is protected today. Nothing is written until the last question is answered, and the run ends with what was written and whether anything is enforcing. Tab never disconnects a connected machine; `--disconnect` does. An agent traced again starts fresh in the collector instead of resuming old cursors (#881) +- Installing an agent's hooks (`policies --install`, `policies add`, the dashboard's agent panel) now adds it to the agents `failproofai config` traces, at any scope, and removing them at user scope or `--scope all` (`policies --uninstall`, `policies remove all`, the panel) takes it out, each with a one-line note. So the next `failproofai config` neither strips those hooks nor puts them back, and the daemon stops collecting an agent you unhooked. An agent added back starts fresh in the collector, as one re-ticked in `failproofai config` does, instead of resuming its old cursors. With no saved selection, a removal saves one listing this machine's agents (detected, or hooked at user scope) but those removed; project- and local-scope removals, `failproofai config` itself and `failproofai uninstall` leave the selection alone (#881) +- `failproofai backfill` re-sends only the agents failproofai traces, counts sessions with the same readers the audit uses (all twelve agents, where the old survey knew seven), and takes `--agents` for a subset. A name that is not an agent, an untraced agent, or a daemon of another version is refused before anything is written. `config` takes `--oss` and `--agents`, `config --status` counts agents found but not traced, and `harness` says when an agent's extra paths are idle because it is not traced (#881) +- The daemon honours `agents.selected` in `config.json`: a harness that is not selected starts no collector task (its extra capture paths and every Hermes profile included), while hook decisions and the SDK spool are always collected. A change takes effect at the next config poll with no restart; no `agents` key means every harness, as before. An unknown agent id, and an untraced harness that still has extra paths, each get a journal line (#881) +- Backfill requests can name `agents` and a `kind`. `user` (what `failproofai backfill` writes, and what a request without `kind` means) re-sends those agents' files modified since `sinceMs`, now reaching their extra-path stores too; `added` forgets every cursor of the agents `failproofai config` re-adds, Hermes profiles included, and gives them the default 7-day window. A pending request is applied before the collector first starts, and an `added` request older than 10 minutes is dropped (#881) + +### Fixes + +- `failproofai policies` showed nothing for a machine still on the pre-pack builtins, while those builtins were enforcing. It now says how many under BUILT IN, with the command that moves them into a pack (#881) +- `failproofai config --disconnect` told you to restart the daemon to stop it sending. It stops on its own within a few seconds, because it polls the credential, and now says so (#881) +- A config change that landed while the daemon was restarting its collector after a backfill was recorded as already running and never applied. `failproofai config` re-adding an agent hits exactly that, writing its backfill request and then the selection, so the agent stayed off until some unrelated edit (#881) +- `failproofai backfill 6m` (no `--since`) silently ran the default 30 days. Stray words are refused now, with the option they probably meant (#881) +- `failproofai config --token --machine-label ` ran a rename and ignored the key. It now sets the machine up under that name; with `--agents` on a connected machine the name is applied as a rename, and where the machine cannot connect (`--oss`, or no key) it is refused rather than dropped (#881) +- `failproofai config` no longer runs setup with an option it does not know quietly ignored: an unknown option or a stray word is an error (#881) +- `backfill` on an unconnected machine suggested `config --token `, which puts the key in shell history. It points at `failproofai config` (#881) +- `harness add-path` said `harness list` shows what is captured. It shows what is configured; the daemon's log says what it rejected (#881) +- `--cli` in `policies --install`, `--uninstall`, `add` and `remove` named only 8 of the 12 agents and said "Missing value(s)" for a typo; it now says "Not an agent: claud (did you mean claude?)" and lists all 12 (#881) +- The did-you-mean for an unknown command answered `install` with `uninstall`, `status` with `flush` and `-i` with `audit`, and suggested flags in a form that could not be run (#881) +- `failproofai update` without sudo said to re-run `failproofai config` and printed a whole unit file to paste; it now says to run `sudo -v`, then `failproofai update` again. `uninstall` names the commands to run as root when it cannot remove the service (#881) +- `failproofai uninstall` said settings "survive a reinstall" while it reset enabled policies, custom policy paths and policy parameters in policies-config.json; its question now says so (#881) +- `policies remove --scope` with no value left `all` out of the valid scopes, and `policies add|remove` with an unknown flag pointed at `failproofai policy --help` (#881) +- A new backfill request no longer replaces one still waiting for the daemon: requests merge, keeping the wider window, and are written atomically (#881) +- A hook from an agent that `failproofai config` does not trace is logged once per session and evaluated as usual, never allowed because of the selection (#881) +- A backfill's first-sight window now applies only to the agents it names and lasts until a later request for that agent: a scoped backfill no longer widens every other source's window, and an agent added after a backfill starts on 7 days instead of inheriting the backfill's (#881) +- Renaming an unconnected machine suggested `config --connect --token `, a key on the command line; it points at `failproofai config` (#881) +- After every agent's hooks were removed, an unattended `failproofai config` re-run hooked all twelve again; a saved empty selection now stands (#881) + +### Dependencies + +- Bump `next` to 16.3.8, `sharp` to 0.35.5 and `source-map-js` to 1.2.2, which OSV-Scanner flagged; the dashboard ships `next` and `sharp`. In the SDKs' dev lockfiles: `source-map-js` (TypeScript) and `banks`, `langgraph-sdk`, `multidict` and `pydantic-ai-slim` (Python). `quinn-proto` 0.11.18 (RUSTSEC-2026-0338 to -0342) and `ruzstd` 0.9.1 (RUSTSEC-2026-0344) in `Cargo.lock` (#881) + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/Cargo.lock b/Cargo.lock index 943a3344c..594f924ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "failproofaid" -version = "1.0.11-beta.0" +version = "1.0.11-beta.1" dependencies = [ "fpai-collect", "fpai-ipc", @@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "fpai-collect" -version = "1.0.11-beta.0" +version = "1.0.11-beta.1" dependencies = [ "getrandom 0.3.4", "notify", @@ -300,7 +300,7 @@ dependencies = [ [[package]] name = "fpai-ipc" -version = "1.0.11-beta.0" +version = "1.0.11-beta.1" dependencies = [ "libc", "proptest", @@ -1024,9 +1024,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "bytes", "getrandom 0.4.3", @@ -1338,9 +1338,9 @@ dependencies = [ [[package]] name = "ruzstd" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" dependencies = [ "twox-hash", ] @@ -1568,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 3717dc7ee..ab64725e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "1.0.11-beta.0" +version = "1.0.11-beta.1" edition = "2024" license-file = "LICENSE" repository = "https://github.com/FailproofAI/failproofai" diff --git a/__tests__/audit/audit-cli-help.test.ts b/__tests__/audit/audit-cli-help.test.ts index 4cba1acb0..d43200517 100644 --- a/__tests__/audit/audit-cli-help.test.ts +++ b/__tests__/audit/audit-cli-help.test.ts @@ -43,8 +43,8 @@ describe("audit --help", () => { // it. Repeating the prefix on every row cost 17 of the 80 columns and was // what forced the descriptions down to four words a line. expect(text).toContain("failproofai audit"); + expect(text).toContain("Scan your agents' history and open the results"); for (const command of [ - "(bare)", "--schedule [days]", "--no-schedule", "--status", @@ -66,8 +66,13 @@ describe("audit --help", () => { expect(plain(render(false))).not.toContain("--scheduled"); }); - it("keeps the local-only promise the docs also make", () => { - expect(plain(render(false))).toMatch(/runs on this machine/i); + it("is usage, options and examples, and nothing else", () => { + // Decision D6 of the 2026-10 redesign removed explanatory and privacy + // lines from help pages; the audit's local-only promise went with them. + const headings = plain(render(false)) + .split("\n") + .filter((l) => /^[A-Z][A-Z ]+$/.test(l)); + expect(headings).toEqual(["USAGE", "OPTIONS", "EXAMPLES"]); }); it.each([true, false])("aligns and fits 80 columns with color=%s", (color) => { @@ -77,27 +82,14 @@ describe("audit --help", () => { expect(line.length).toBeLessThanOrEqual(80); } - // Every command row and every continuation line shares one description - // column. Derive it from the first row rather than restating a constant, - // so this fails on drift instead of being updated to match it. - const first = lines.find((l) => l.trim().startsWith("(bare)")); - expect(first).toBeDefined(); - const descCol = first!.indexOf("Scan your session history"); - expect(descCol).toBeGreaterThan(0); - - const continuations = lines.filter( - (l) => l.startsWith(" ".repeat(descCol)) && l.trim().length > 0, - ); - // A NON-VACUITY floor, not a layout assertion. The real check is the loop - // below — every continuation starts exactly at `descCol` — and this only - // proves it ran over something. Deliberately well under the count the - // current copy produces: pinning it to the exact number is what made this - // line fail twice for wording changes that improved the screen, once when - // dropping the `failproofai audit` prefix widened the column and again - // when `--email
` moved to a row of its own. - expect(continuations.length).toBeGreaterThanOrEqual(2); - for (const line of continuations) { - expect(line[descCol]).not.toBe(" "); - } + // Every option row shares one description column. Measured per row rather + // than against a constant, so this fails on drift instead of being updated + // to match it. + const optionRows = lines.filter((l) => /^ {2}-/.test(l)); + // A non-vacuity floor: the loop below must have run over the real rows. + expect(optionRows.length).toBeGreaterThanOrEqual(4); + // The first character after the name's two-space gap. + const columns = new Set(optionRows.map((l) => l.search(/(?<=\S {2,})\S/))); + expect(columns.size).toBe(1); }); }); diff --git a/__tests__/audit/audit-cli-telemetry.test.ts b/__tests__/audit/audit-cli-telemetry.test.ts index 8039c7bc3..feeb895ec 100644 --- a/__tests__/audit/audit-cli-telemetry.test.ts +++ b/__tests__/audit/audit-cli-telemetry.test.ts @@ -113,7 +113,8 @@ describe("failproofai audit telemetry", () => { total_hits: 2, findings: 0, }); - expect(h.launch).toHaveBeenCalledWith("start"); + // Audit's hand-off prints its own lines, so the dashboard starts with no launch screen. + expect(h.launch).toHaveBeenCalledWith("start", { screen: false }); expect(exitInfo).toBeNull(); // happy path never exits — launch() keeps the process alive }); diff --git a/__tests__/audit/audit-cli.test.ts b/__tests__/audit/audit-cli.test.ts index 40405eb87..232be28b9 100644 --- a/__tests__/audit/audit-cli.test.ts +++ b/__tests__/audit/audit-cli.test.ts @@ -1,13 +1,16 @@ // @vitest-environment node /** * Unit coverage for the `failproofai audit` command's pure pieces: - * • AUDIT_STAGES stays in lockstep with the dashboard's RunProgress STAGES - * (the CLI and dashboard must tell the same four-step story). + * • AUDIT_STAGES stays in lockstep with the dashboard's RunProgress STAGES. + * The CLI no longer animates those four stages — by decision D16 it draws + * real per-agent bars — but it still names its discovery phase with the + * first one, so the two must keep using the same words. * • buildSummary renders the right post-run line(s) for clean / mixed / - * singular results. + * singular results, in the 2026-10 screen language: a `✓` sentence with a + * full stop, then one indented sentence about what your policies cover. * - * The server-launch + browser-open path is a thin wrapper over the existing - * `launch()` and is exercised by the build/smoke test, not here. + * The screen itself, and the hand-off to the dashboard, are covered in + * audit-screen.test.ts. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -71,9 +74,9 @@ describe("buildSummary", () => { totals: { hits: 0, projectsWithHits: 0 }, }), ).map(stripAnsi); - expect(lines[0]).toContain("audit complete"); - expect(lines[0]).toContain("1,240 tool calls across 18 sessions"); - expect(lines[0]).toContain("4 projects"); + // Was "✓ audit complete · 1,240 tool calls across 18 sessions · 4 projects" + // before the 2026-10 screen language made results sentences. + expect(lines[0]).toBe("✓ Scanned 1,240 tool calls across 18 sessions in 4 projects."); }); it("shows a clean-run line when something was scanned but nothing flagged", () => { @@ -83,17 +86,18 @@ describe("buildSummary", () => { transcripts: { scanned: 2, skipped: 0, errors: 0, durationMs: 0 }, }), ).map(stripAnsi); - expect(lines.join("\n")).toContain("clean run"); + // Was "clean run — nothing flagged. nice." before the redesign. + expect(lines).toEqual(["✓ Scanned 10 tool calls across 2 sessions.", " Nothing was flagged."]); expect(lines.join("\n")).not.toContain("slipping through"); }); it("does NOT claim a clean run when zero events were scanned", () => { const lines = buildSummary(result({ eventsScanned: 0 })).map(stripAnsi); - expect(lines.join("\n")).not.toContain("clean run"); + expect(lines.join("\n")).not.toContain("Nothing was flagged"); expect(lines[0]).toContain("0 tool calls across 0 sessions"); }); - it("splits slipping-through vs already-blocked findings", () => { + it("splits slipping-through vs already-covered findings", () => { const lines = buildSummary( result({ eventsScanned: 500, @@ -108,9 +112,29 @@ describe("buildSummary", () => { ], }), ).map(stripAnsi); - const joined = lines.join("\n"); - expect(joined).toContain("3 patterns slipping through"); - expect(joined).toContain("2 already blocked by your policies"); + // "Covered", not the old "already blocked": a policy that is on may warn + // rather than block, so "blocked" over-claimed for every enabled warn-*. + expect(lines[1]).toBe(" 3 patterns are slipping through, and 2 are already covered by your policies."); + }); + + it("says only the half that applies", () => { + const slippingOnly = buildSummary( + result({ + eventsScanned: 5, + totals: { hits: 2, projectsWithHits: 1 }, + results: [count({ source: "builtin", enabledInConfig: false }), count({ source: "audit-detector" })], + }), + ).map(stripAnsi); + expect(slippingOnly[1]).toBe(" 2 patterns are slipping through."); + + const coveredOnly = buildSummary( + result({ + eventsScanned: 5, + totals: { hits: 1, projectsWithHits: 1 }, + results: [count({ source: "builtin", enabledInConfig: true })], + }), + ).map(stripAnsi); + expect(coveredOnly[1]).toBe(" 1 pattern is already covered by your policies."); }); it("uses singular nouns for counts of one", () => { @@ -123,6 +147,14 @@ describe("buildSummary", () => { }), ).map(stripAnsi); expect(lines[0]).toContain("1 tool call across 1 session"); - expect(lines.join("\n")).toContain("1 pattern slipping through"); + expect(lines.join("\n")).toContain("1 pattern is slipping through"); + }); + + it("paints only the glyph, and leaves the counts in the terminal's own ink", () => { + const painted = buildSummary( + result({ eventsScanned: 3, transcripts: { scanned: 1, skipped: 0, errors: 0, durationMs: 0 } }), + { color: true }, + ); + expect(painted[0]).toMatch(/^\x1B\[[0-9;]+m✓\x1B\[0m Scanned 3 tool calls across 1 session\.$/); }); }); diff --git a/__tests__/audit/audit-progress.test.ts b/__tests__/audit/audit-progress.test.ts new file mode 100644 index 000000000..fb3ad116a --- /dev/null +++ b/__tests__/audit/audit-progress.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node +/** + * `runAudit`'s progress callback, on real transcripts from two agents. + * + * The callback feeds `failproofai audit`'s per-agent bars (decision D16), so + * what is pinned here is what those bars are drawn from: one `discovered` with + * every agent's total before any transcript reports, then exactly one report per + * transcript, by agent, whose hits add up to the result. And the two promises + * that let it be optional: the audit is the same with or without a listener, + * and a listener that throws changes nothing. + * + * Every path the scan reads is a fixture under a temp dir — Claude through + * CLAUDE_PROJECTS_PATH, Factory through FACTORY_HOME, the cache through + * FAILPROOFAI_HOME — and the audit is restricted to those agents, so nothing + * here reads the real home. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runAudit } from "../../src/audit"; +import { resetReplay } from "../../src/audit/replay"; +import { BUILTIN_POLICIES } from "../../src/hooks/builtin-policies"; +import type { AuditProgress, AuditResult } from "../../src/audit/types"; +import type { IntegrationType } from "../../src/hooks/types"; + +const CLIS: IntegrationType[] = ["claude", "factory"]; + +function claudeTranscript(cwd: string, sessionId: string, commands: Array<[string, Record]>): string { + return commands + .map(([name, input], i) => + JSON.stringify({ + type: "assistant", + uuid: `${sessionId}-${i}`, + parentUuid: i === 0 ? null : `${sessionId}-${i - 1}`, + sessionId, + cwd, + timestamp: new Date(Date.UTC(2026, 9, 1, 10, i)).toISOString(), + message: { role: "assistant", content: [{ type: "tool_use", id: `tu-${sessionId}-${i}`, name, input }] }, + }), + ) + .join("\n"); +} + +function factoryTranscript(cwd: string, sessionId: string, command: string): string { + return [ + { type: "session_start", id: sessionId, cwd }, + { + type: "message", + timestamp: "2026-10-01T10:00:00.000Z", + message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Execute", input: { command } }] }, + }, + ] + .map((l) => JSON.stringify(l)) + .join("\n"); +} + +let root: string; +const saved: Record = {}; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "fpai-audit-progress-")); + for (const key of ["CLAUDE_PROJECTS_PATH", "FACTORY_HOME", "FAILPROOFAI_HOME"]) saved[key] = process.env[key]; + process.env.CLAUDE_PROJECTS_PATH = join(root, "claude-projects"); + process.env.FACTORY_HOME = join(root, "factory"); + process.env.FAILPROOFAI_HOME = join(root, "fp"); + + const claudeProject = join(root, "claude-projects", "-tmp-alpha"); + mkdirSync(claudeProject, { recursive: true }); + writeFileSync( + join(claudeProject, "11111111-2222-4333-8444-555555555551.jsonl"), + claudeTranscript("/tmp/alpha", "11111111-2222-4333-8444-555555555551", [ + ["Bash", { command: "sudo ls -la /root" }], + ["Bash", { command: "env" }], + ]), + ); + writeFileSync( + join(claudeProject, "11111111-2222-4333-8444-555555555552.jsonl"), + claudeTranscript("/tmp/alpha", "11111111-2222-4333-8444-555555555552", [ + ["Bash", { command: "cd /tmp/alpha && pnpm test" }], + ["Edit", { file_path: "/tmp/alpha/foo.ts", old_string: "a", new_string: "b" }], + ["Read", { file_path: "/tmp/alpha/foo.ts" }], + ]), + ); + + const factoryProject = join(root, "factory", "sessions", "-tmp-beta"); + mkdirSync(factoryProject, { recursive: true }); + writeFileSync( + join(factoryProject, "22222222-3333-4444-8555-666666666661.jsonl"), + factoryTranscript("/tmp/beta", "22222222-3333-4444-8555-666666666661", "sudo whoami"), + ); +}); + +afterAll(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +beforeEach(() => { + resetReplay(); +}); + +const transcriptsOf = (events: AuditProgress[]) => + events.filter((e): e is Extract => e.kind === "transcript"); + +/** Hits summed across every transcript report, by name. */ +function summed(events: AuditProgress[]): Record { + const out: Record = {}; + for (const e of transcriptsOf(events)) { + for (const [name, n] of Object.entries(e.hitsByName)) out[name] = (out[name] ?? 0) + n; + } + return out; +} + +const hitsOf = (result: AuditResult) => Object.fromEntries(result.results.map((r) => [r.name, r.hits])); + +/** The parts of a result that do not depend on the clock. */ +const comparable = (result: AuditResult) => ({ + ...result, + scannedAt: "", + transcripts: { ...result.transcripts, durationMs: 0 }, +}); + +describe("runAudit progress", () => { + it("reports every agent's total once, before any transcript", async () => { + const events: AuditProgress[] = []; + await runAudit({ clis: CLIS, noCache: true, onProgress: (p) => events.push(p) }); + + expect(events[0]?.kind).toBe("discovered"); + expect(events.filter((e) => e.kind === "discovered")).toHaveLength(1); + const discovered = events[0] as Extract; + // In scan order, as the bars draw them. + expect(discovered.agents).toEqual([ + { cli: "claude", transcripts: 2 }, + { cli: "factory", transcripts: 1 }, + ]); + }); + + it("counts the policies a replay can actually fire, not every builtin", async () => { + const events: AuditProgress[] = []; + await runAudit({ clis: CLIS, noCache: true, onProgress: (p) => events.push(p) }); + const discovered = events[0] as Extract; + + // Every builtin the replay registers (all but warn-repeated-tool-calls), + // minus the Stop-only require-*-before-stop gates a replay never asks. + const expected = BUILTIN_POLICIES.filter( + (p) => + p.name !== "warn-repeated-tool-calls" && + (!p.match.events?.length || p.match.events.some((e) => e === "PreToolUse" || e === "PostToolUse")), + ).length; + expect(discovered.policies).toBe(expected); + expect(discovered.policies).toBeLessThan(BUILTIN_POLICIES.length - 1); + }); + + it("reports each transcript once, by agent, and the hits add up to the result", async () => { + const events: AuditProgress[] = []; + const result = await runAudit({ clis: CLIS, noCache: true, onProgress: (p) => events.push(p) }); + + const reports = transcriptsOf(events); + expect(reports).toHaveLength(3); + expect(reports.filter((r) => r.cli === "claude")).toHaveLength(2); + expect(reports.filter((r) => r.cli === "factory")).toHaveLength(1); + expect(summed(events)).toEqual(hitsOf(result)); + + // Both agents found something, so both halves of the plumbing are live. + // Builtins arrive under their qualified names, detectors under their own. + expect(reports.find((r) => r.cli === "factory")?.hitsByName).toEqual({ "failproofai/block-sudo": 1 }); + expect(summed(events)).toHaveProperty("redundant-cd-cwd"); + }); + + it("is the same audit with or without a listener", async () => { + const without = await runAudit({ clis: CLIS, noCache: true }); + resetReplay(); + const withListener = await runAudit({ clis: CLIS, noCache: true, onProgress: () => {} }); + expect(comparable(withListener)).toEqual(comparable(without)); + }); + + it("ignores a listener that throws, rather than counting it as a scan error", async () => { + const baseline = await runAudit({ clis: CLIS, noCache: true }); + resetReplay(); + const result = await runAudit({ + clis: CLIS, + noCache: true, + onProgress: () => { + throw new Error("a broken progress display"); + }, + }); + expect(result.transcripts.errors).toBe(0); + expect(result.transcripts.skipped).toBe(0); + expect(comparable(result)).toEqual(comparable(baseline)); + }); + + it("still reports a transcript the cache answers", async () => { + // The first run fills the per-transcript cache under FAILPROOFAI_HOME; the + // second is answered from it without a scan, and must still move the bars. + await runAudit({ clis: CLIS }); + resetReplay(); + const events: AuditProgress[] = []; + const result = await runAudit({ clis: CLIS, onProgress: (p) => events.push(p) }); + + expect(transcriptsOf(events)).toHaveLength(3); + expect(summed(events)).toEqual(hitsOf(result)); + }); + + it("lists an agent with nothing to scan as zero", async () => { + const empty = mkdtempSync(join(tmpdir(), "fpai-audit-progress-goose-")); + const prev = process.env.GOOSE_HOME; + process.env.GOOSE_HOME = empty; + try { + const events: AuditProgress[] = []; + await runAudit({ clis: [...CLIS, "goose"], noCache: true, onProgress: (p) => events.push(p) }); + const discovered = events[0] as Extract; + expect(discovered.agents).toContainEqual({ cli: "goose", transcripts: 0 }); + expect(transcriptsOf(events).filter((r) => r.cli === "goose")).toHaveLength(0); + } finally { + if (prev === undefined) delete process.env.GOOSE_HOME; + else process.env.GOOSE_HOME = prev; + rmSync(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/audit/audit-screen.test.ts b/__tests__/audit/audit-screen.test.ts new file mode 100644 index 000000000..d1ca4a8ca --- /dev/null +++ b/__tests__/audit/audit-screen.test.ts @@ -0,0 +1,567 @@ +// @vitest-environment node +/** + * `failproofai audit`'s screen: the design's progress screen with a bar per + * agent (decision D16), finished in place, then the hand-off to the dashboard. + * + * Two halves. The builder is pure, so its lines are pinned exactly at fixed + * widths, plain and coloured, mid-scan and finished, with the version injected + * so the release bot's bump cannot turn this red. Then the wiring through + * `runAuditCli`, with `runAudit` stood in for: piped and NO_COLOR output is the + * finished screen printed once with no escapes, and a terminal gets live frames + * that are each ONE write — the old spinner wrote its cursor-up and its lines + * as separate chunks. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import type { AuditCount, AuditProgress, AuditResult, RunAuditOptions } from "../../src/audit/types"; + +const h = vi.hoisted(() => ({ + trackHookEvent: vi.fn(async () => {}), + runAudit: vi.fn(), + writeDashboardCache: vi.fn(() => true), + openWhenReady: vi.fn(), + launch: vi.fn(), +})); + +vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: h.trackHookEvent })); +vi.mock("../../src/audit/index", () => ({ runAudit: h.runAudit })); +vi.mock("../../src/audit/dashboard-cache", () => ({ writeDashboardCache: h.writeDashboardCache })); +vi.mock("../../src/audit/open-browser", () => ({ openWhenReady: h.openWhenReady })); +vi.mock("../../scripts/launch", () => ({ launch: h.launch })); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: () => "test-instance" })); + +import { buildAuditScreen, runAuditCli, runPostSetupAudit, type AuditScreenState } from "../../src/audit/cli"; + +const strip = (s: string): string => s.replace(/\x1B\[[0-9;?]*[A-Za-z]/g, ""); +const PINK_24 = "38;2;228;88;125"; +const MINT_24 = "38;2;102;209;181"; +const INK3_24 = "38;2;118;127;139"; +const TRACK_24 = "38;2;62;67;76"; + +function setEnv(env: Record): () => void { + const saved: Record = {}; + for (const key of Object.keys(env)) { + saved[key] = process.env[key]; + if (env[key] === undefined) delete process.env[key]; + else process.env[key] = env[key]; + } + return () => { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }; +} + +function withEnv(env: Record, fn: () => T): T { + const restore = setEnv(env); + try { + return fn(); + } finally { + restore(); + } +} + +/** The same, held until an async run settles — the CLI reads the environment after its first await too. */ +async function withEnvAsync(env: Record, fn: () => Promise): Promise { + const restore = setEnv(env); + try { + return await fn(); + } finally { + restore(); + } +} +const TRUECOLOR = { COLORTERM: "truecolor", TERM: "xterm-256color", NO_COLOR: undefined }; + +function count(over: Partial): AuditCount { + return { + name: "x", + source: "builtin", + category: "Risky", + severity: "deny", + hits: 1, + projects: 1, + examples: [], + displayTitle: "x", + impact: "", + enabledInConfig: false, + installHint: "", + ...over, + }; +} + +/** The design's sample, mid-scan. */ +const MID: AuditScreenState = { + agents: [ + { cli: "claude", total: 1284, done: 1284 }, + { cli: "codex", total: 412, done: 301 }, + { cli: "opencode", total: 96, done: 12 }, + ], + policies: 34, + hits: { + "failproofai/block-env-files": 4, + "failproofai/block-sudo": 2, + "failproofai/sanitize-api-keys": 1, + "failproofai/warn-all-files-staged": 18, + "failproofai/warn-git-amend": 5, + "failproofai/protect-env-vars": 40, + "reread-after-edit": 9, + "redundant-cd-cwd": 3, + }, +}; + +const RESULT: AuditResult = { + version: 2, + scannedAt: "2026-10-11T00:00:00.000Z", + scope: { cli: ["claude", "codex", "opencode"], projects: "all", since: null }, + transcripts: { scanned: 1792, skipped: 0, errors: 0, durationMs: 0 }, + results: Object.entries(MID.hits).map(([name, hits]) => + count({ + name, + hits, + source: name.includes("/") ? "builtin" : "audit-detector", + enabledInConfig: name === "failproofai/block-sudo" || name === "failproofai/block-env-files", + }), + ), + totals: { hits: 82, projectsWithHits: 3 }, + projectsScanned: Array.from({ length: 41 }, (_, i) => `/p${i}`), + eventsScanned: 18240, + enabledBuiltinNames: [], +}; + +const BAR30 = "━".repeat(30); + +describe("the audit screen, built", () => { + it("names the phase it has no bars for while transcripts are listed", () => { + expect(buildAuditScreen({ agents: null, policies: 0, hits: {} }, { version: "1.0.11", cols: 80, fit: true })).toEqual([ + "failproof ai v1.0.11 · Audit", + "", + // The dashboard's first stage, in its words — `AUDIT_STAGES[0]`. + "SCANNING HISTORY discovering transcripts", + "", + "ctrl+c stop", + ]); + }); + + it("draws the design's progress screen mid-scan, at 104 columns", () => { + expect(buildAuditScreen(MID, { version: "1.0.11", cols: 104, fit: true })).toEqual([ + "failproof ai v1.0.11 · Audit", + "", + "SCANNING HISTORY 3 agents, 34 policies", + ` claude ${BAR30} ✓ 1,284 sessions`, + // Colour off, the empty part of a bar is blank: one unbroken run of ━ + // would show no progress at all. + ` codex ${"━".repeat(22)}${" ".repeat(8)} 301 of 412 sessions`, + ` opencode ${"━".repeat(4)}${" ".repeat(26)} 12 of 96 sessions`, + "", + "FOUND SO FAR", + " would block 46: protect-env-vars 40, block-env-files 4, block-sudo 2", + " would warn 24: warn-all-files-staged 18, warn-git-amend 5, sanitize-api-keys 1", + " audit only 12: reread-after-edit 9, redundant-cd-cwd 3", + "", + "ctrl+c stop", + ]); + }); + + it("fits the found rows to 80 columns by dropping whole names, never cutting one", () => { + const lines = buildAuditScreen(MID, { version: "1.0.11", cols: 60, fit: true }); + const block = lines.find((l) => l.startsWith(" would block"))!; + expect(block).toBe(" would block 46: protect-env-vars 40, block-env-files 4, …"); + for (const line of lines) expect(line.length).toBeLessThanOrEqual(60); + // The bar gave up width before the counts did. + expect(lines.find((l) => l.startsWith(" codex"))).toMatch(/301 of 412 sessions$/); + }); + + it("always shows the first name, however narrow", () => { + const lines = buildAuditScreen(MID, { version: "1.0.11", cols: 20, fit: true }); + expect(lines.find((l) => l.startsWith(" would block"))).toBe(" would block 46: protect-env-vars 40, …"); + }); + + it("cuts nothing when fitting is off — piped output is never cut", () => { + const lines = buildAuditScreen(MID, { version: "1.0.11", cols: 60 }); + expect(lines.find((l) => l.startsWith(" would block"))).toBe( + " would block 46: protect-env-vars 40, block-env-files 4, block-sudo 2", + ); + }); + + it("files each finding by what the policy would do, not by the score's heuristic", () => { + const lines = buildAuditScreen( + { + agents: [{ cli: "claude", total: 1, done: 1 }], + policies: 34, + hits: { + // protect-* and prefer-* deny, though the score files them as warn. + "failproofai/protect-env-vars": 1, + "failproofai/prefer-package-manager": 1, + // warn-* instructs; sanitize-* acts after the call ran, so it cannot block. + "failproofai/warn-git-amend": 1, + "failproofai/sanitize-jwt": 1, + // An audit-only detector has no runtime policy at all. + "sleep-polling-loop": 1, + }, + }, + { version: "1.0.11", cols: 104 }, + ); + expect(lines).toContain(" would block 2: prefer-package-manager 1, protect-env-vars 1"); + expect(lines).toContain(" would warn 2: sanitize-jwt 1, warn-git-amend 1"); + expect(lines).toContain(" audit only 1: sleep-polling-loop 1"); + }); + + it("shows zero rows rather than hiding them, so the block does not jump", () => { + const lines = buildAuditScreen( + { agents: [{ cli: "claude", total: 3, done: 0 }], policies: 34, hits: {} }, + { version: "1.0.11", cols: 80, fit: true }, + ); + expect(lines).toContain(" would block 0"); + expect(lines).toContain(" would warn 0"); + expect(lines).toContain(" audit only 0"); + expect(lines).toContain(` claude ${" ".repeat(30)} 0 of 3 sessions`); + }); + + it("finishes in place: every bar done, the headings past tense, the summary where the keys were", () => { + expect(buildAuditScreen(MID, { version: "1.0.11", cols: 104, fit: true, result: RESULT })).toEqual([ + "failproof ai v1.0.11 · Audit", + "", + "SCANNED HISTORY 3 agents, 34 policies", + ` claude ${BAR30} ✓ 1,284 sessions`, + ` codex ${BAR30} ✓ 412 sessions`, + ` opencode ${BAR30} ✓ 96 sessions`, + "", + "FOUND", + " would block 46: protect-env-vars 40, block-env-files 4, block-sudo 2", + " would warn 24: warn-all-files-staged 18, warn-git-amend 5, sanitize-api-keys 1", + " audit only 12: reread-after-edit 9, redundant-cd-cwd 3", + "", + "✓ Scanned 18,240 tool calls across 1,792 sessions in 41 projects.", + " 6 patterns are slipping through, and 2 are already covered by your policies.", + ]); + }); + + it("takes the finished counts from the result, which is what the dashboard shows", () => { + const lines = buildAuditScreen( + { agents: [{ cli: "claude", total: 1, done: 1 }], policies: 34, hits: { "failproofai/block-sudo": 99 } }, + { version: "1.0.11", cols: 80, result: { ...RESULT, results: [count({ name: "failproofai/block-sudo", hits: 2 })] } }, + ); + expect(lines).toContain(" would block 2: block-sudo 2"); + }); + + it("finishes a scan with no sessions on the summary alone, with no empty blocks", () => { + const empty: AuditResult = { + ...RESULT, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 }, + projectsScanned: [], + eventsScanned: 0, + }; + const expected = ["failproof ai v1.0.11 · Audit", "", "✓ Scanned 0 tool calls across 0 sessions."]; + // Every agent listed, none with anything to scan… + expect(buildAuditScreen({ agents: [], policies: 34, hits: {} }, { version: "1.0.11", cols: 80, result: empty })).toEqual( + expected, + ); + // …or a scan that finished without ever saying what it found. + expect(buildAuditScreen({ agents: null, policies: 0, hits: {} }, { version: "1.0.11", cols: 80, result: empty })).toEqual( + expected, + ); + }); + + it("drops the key hint when the scan stopped, because nothing is running", () => { + const lines = buildAuditScreen(MID, { version: "1.0.11", cols: 80, stopped: true }); + expect(lines).not.toContain("ctrl+c stop"); + expect(lines).toContain("SCANNING HISTORY 3 agents, 34 policies"); + }); + + it("paints by role in colour, and says the same thing", () => { + const plain = buildAuditScreen(MID, { version: "1.0.11", cols: 104, fit: true }); + const painted = withEnv(TRUECOLOR, () => buildAuditScreen(MID, { version: "1.0.11", cols: 104, fit: true, color: true })); + // In colour the empty part of a bar is the track, drawn rather than blank. + expect(painted.map(strip)).toEqual(plain.map((l) => l.replace(/━ +(?= {2}\d)/, (run) => "━".repeat(run.length)))); + const codex = painted.find((l) => strip(l).startsWith(" codex"))!; + expect(codex).toContain(`\x1B[${PINK_24}m${"━".repeat(22)}\x1B[0m`); + expect(codex).toContain(`\x1B[${TRACK_24}m${"━".repeat(8)}\x1B[0m`); + expect(painted.find((l) => strip(l).startsWith(" claude"))).toContain(`\x1B[${MINT_24}m✓\x1B[0m`); + expect(painted).toContain(`\x1B[${INK3_24}mctrl+c stop\x1B[0m`); + expect(painted).toContain(`\x1B[1mSCANNING HISTORY\x1B[0m \x1B[${INK3_24}m3 agents, 34 policies\x1B[0m`); + }); + + it("emits no escape at all with colour off", () => { + const all = [ + ...buildAuditScreen(MID, { version: "1.0.11", cols: 80, fit: true }), + ...buildAuditScreen(MID, { version: "1.0.11", cols: 80, result: RESULT }), + ].join("\n"); + expect(all).not.toContain("\x1B"); + }); + + it("uses only the design's glyphs", () => { + const all = buildAuditScreen(MID, { version: "1.0.11", cols: 104, result: RESULT }).join(""); + expect(all).not.toMatch(/[✦🛡!│◆◇└⠋⠙⠹]/u); + }); +}); + +// ── through runAuditCli ────────────────────────────────────────────────────── + +const DISCOVERED: AuditProgress = { + kind: "discovered", + agents: [ + { cli: "claude", transcripts: 2 }, + { cli: "codex", transcripts: 1 }, + { cli: "goose", transcripts: 0 }, + ], + policies: 34, +}; +const REPORTS: AuditProgress[] = [ + { kind: "transcript", cli: "claude", hitsByName: { "failproofai/block-sudo": 2 } }, + { kind: "transcript", cli: "claude", hitsByName: { "failproofai/warn-git-amend": 1, "reread-after-edit": 3 } }, + { kind: "transcript", cli: "codex", hitsByName: { "failproofai/protect-env-vars": 5, "failproofai/sanitize-api-keys": 1 } }, +]; +const SCANNED: AuditResult = { + ...RESULT, + scope: { cli: ["claude", "codex", "goose"], projects: "all", since: null }, + transcripts: { scanned: 3, skipped: 0, errors: 0, durationMs: 0 }, + results: [ + count({ name: "failproofai/protect-env-vars", hits: 5 }), + count({ name: "reread-after-edit", hits: 3, source: "audit-detector" }), + count({ name: "failproofai/block-sudo", hits: 2, enabledInConfig: true }), + count({ name: "failproofai/warn-git-amend", hits: 1 }), + count({ name: "failproofai/sanitize-api-keys", hits: 1 }), + ], + totals: { hits: 12, projectsWithHits: 2 }, + projectsScanned: ["/a", "/b"], + eventsScanned: 40, +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** A stand-in scan that reports like the real one, pausing between reports so a terminal gets frames mid-scan. */ +function scanReporting(pause = 0) { + return async (opts: RunAuditOptions): Promise => { + if (pause) await sleep(5); + opts.onProgress?.(DISCOVERED); + for (const report of REPORTS) { + if (pause) await sleep(pause); + opts.onProgress?.(report); + } + if (pause) await sleep(pause); + return SCANNED; + }; +} + +let written: string[]; +let home: string; +let prevHome: string | undefined; +let prevTTY: boolean | undefined; +let prevColumns: number | undefined; +const stdout = process.stdout as unknown as { isTTY?: boolean; columns?: number }; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-audit-screen-")); + process.env.FAILPROOFAI_HOME = home; + prevTTY = stdout.isTTY; + prevColumns = stdout.columns; + vi.clearAllMocks(); + h.writeDashboardCache.mockReturnValue(true); + written = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (prevTTY === undefined) delete stdout.isTTY; + else stdout.isTTY = prevTTY; + if (prevColumns === undefined) delete stdout.columns; + else stdout.columns = prevColumns; + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +const output = () => written.join(""); +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +describe("failproofai audit, piped", () => { + it("prints the finished screen once, with no escapes and no progress frames", async () => { + delete stdout.isTTY; + h.runAudit.mockImplementation(scanReporting()); + await withEnvAsync({ ...TRUECOLOR, FORCE_COLOR: undefined }, () => runAuditCli([])); + + const text = output(); + expect(text).not.toContain("\x1B"); + expect(occurrences(text, "SCANNED HISTORY")).toBe(1); + expect(text).not.toContain("SCANNING HISTORY"); + expect(text).not.toContain("ctrl+c stop\n"); + // An agent with nothing to scan has no bar. + expect(text).not.toContain("goose"); + expect(text).toContain(` claude ${BAR30} ✓ 2 sessions`); + expect(text).toContain(` codex ${BAR30} ✓ 1 session`); + expect(text).toContain(" would block 7: protect-env-vars 5, block-sudo 2"); + expect(text).toContain(" would warn 2: sanitize-api-keys 1, warn-git-amend 1"); + expect(text).toContain(" audit only 3: reread-after-edit 3"); + expect(text).toContain("✓ Scanned 40 tool calls across 3 sessions in 2 projects."); + expect(text).toContain(" 4 patterns are slipping through, and 1 is already covered by your policies."); + }); + + it("hands off to the dashboard with one result line and the key hint, and no logomark", async () => { + delete stdout.isTTY; + h.runAudit.mockImplementation(scanReporting()); + await runAuditCli([]); + + const text = output(); + expect(text).toContain("✓ The audit is ready: http://localhost:8020/audit\n\nctrl+c stop the dashboard\n"); + expect(text).not.toMatch(/[▀▄█]/u); + expect(text).not.toContain("starting the dashboard"); + expect(h.openWhenReady).toHaveBeenCalledWith(8020, "/audit"); + expect(h.launch).toHaveBeenCalledWith("start", { screen: false }); + }); + + it("does not colour a pipe for FORCE_COLOR, the same as every other screen", async () => { + delete stdout.isTTY; + h.runAudit.mockImplementation(scanReporting()); + await withEnvAsync({ ...TRUECOLOR, FORCE_COLOR: "1" }, () => runAuditCli([])); + expect(output()).not.toContain("\x1B"); + }); + + it("says what changes an empty history instead of opening the dashboard", async () => { + delete stdout.isTTY; + h.runAudit.mockResolvedValue({ + ...SCANNED, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 }, + projectsScanned: [], + eventsScanned: 0, + }); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`__EXIT_${code}__`); + }) as never); + + await expect(runAuditCli([])).rejects.toThrow("__EXIT_0__"); + expect(output()).toContain("✓ Scanned 0 tool calls across 0 sessions.\n\nRun it again after using your agent: failproofai audit\n"); + expect(output()).not.toContain("discovering transcripts"); + // The audit reads each agent's own history, hooks or not — so the old + // pointer at `policies --install` sent people to the wrong fix. + expect(output()).not.toContain("policies --install"); + expect(h.launch).not.toHaveBeenCalled(); + }); + + it("names a cache it could not save, above the hand-off", async () => { + delete stdout.isTTY; + h.runAudit.mockImplementation(scanReporting()); + h.writeDashboardCache.mockReturnValue(false); + await runAuditCli([]); + expect(output()).toContain( + "▲ Could not save the audit, so the dashboard may show nothing.\n✓ The audit is ready: http://localhost:8020/audit", + ); + }); +}); + +describe("failproofai audit, on a terminal", () => { + it("under NO_COLOR prints the finished screen once, with no escapes and no frames", async () => { + stdout.isTTY = true; + stdout.columns = 100; + h.runAudit.mockImplementation(scanReporting(20)); + await withEnvAsync({ ...TRUECOLOR, NO_COLOR: "1" }, () => runAuditCli([])); + + const text = output(); + expect(text).not.toContain("\x1B"); + expect(occurrences(text, "SCANNED HISTORY")).toBe(1); + expect(text).not.toContain("SCANNING HISTORY"); + }); + + it("draws live frames, each ONE atomic write, and gives the cursor back", async () => { + stdout.isTTY = true; + stdout.columns = 100; + // Pauses longer than the 100 ms tempo, so frames land mid-scan. + h.runAudit.mockImplementation(scanReporting(130)); + await withEnvAsync(TRUECOLOR, () => runAuditCli([])); + + const frames = written.filter((w) => w.includes("\x1B[?2026h")); + expect(frames.length).toBeGreaterThanOrEqual(3); + for (const frame of frames) { + // Opened and closed in the same write: the terminal holds the frame. + expect(frame.startsWith("\x1B[?2026h")).toBe(true); + expect(frame.endsWith("\x1B[?2026l")).toBe(true); + } + // A cursor-up-and-clear never arrives on its own — that lone write is the + // blank flash the atomic repaint exists to prevent. + expect(written.filter((w) => /\x1B\[\d+A\x1B\[J/.test(w) && !w.includes("Audit"))).toEqual([]); + + // Hidden with the first frame, shown again with the last. + expect(frames[0]).toContain("\x1B[?25l"); + expect(strip(frames[0])).toContain("SCANNING HISTORY discovering transcripts"); + const last = frames[frames.length - 1]; + expect(last).toContain("\x1B[?25h"); + expect(strip(last)).toContain("SCANNED HISTORY 2 agents, 34 policies"); + expect(strip(last)).toContain("✓ Scanned 40 tool calls across 3 sessions in 2 projects."); + // It replaced the live frame rather than printing under it. + expect(last).toMatch(/\x1B\[\d+A\x1B\[J/); + + // Somewhere in between, a real mid-scan frame with a bar part-way. + const mid = frames.slice(1, -1).map(strip); + expect(mid.some((f) => f.includes("1 of 2 sessions") || f.includes("0 of 2 sessions"))).toBe(true); + expect(mid.some((f) => f.includes("FOUND SO FAR"))).toBe(true); + + // And the hand-off follows the finished frame, outside it. + expect(strip(output())).toContain("✓ The audit is ready: http://localhost:8020/audit"); + }); + + it("holds frames to the 100 ms tempo when reports arrive faster", async () => { + stdout.isTTY = true; + stdout.columns = 100; + // Every report arrives at once: one frame before the scan, and the finished + // frame after it, with nothing drawn for the reports in between. + h.runAudit.mockImplementation(scanReporting(0)); + await withEnvAsync(TRUECOLOR, () => runAuditCli([])); + expect(written.filter((w) => w.includes("\x1B[?2026h"))).toHaveLength(2); + }); + + it("leaves the screen as far as it got, without the key hint, when the scan fails", async () => { + stdout.isTTY = true; + stdout.columns = 100; + h.runAudit.mockImplementation(async (opts: RunAuditOptions) => { + opts.onProgress?.(DISCOVERED); + throw new TypeError("disk exploded"); + }); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`__EXIT_${code}__`); + }) as never); + + await expect(withEnvAsync(TRUECOLOR, () => runAuditCli([]))).rejects.toThrow("__EXIT_1__"); + const frames = written.filter((w) => w.includes("\x1B[?2026h")); + const last = frames[frames.length - 1]; + expect(last).toContain("\x1B[?25h"); + expect(strip(last)).toContain("SCANNING HISTORY 2 agents, 34 policies"); + expect(strip(last)).not.toContain("ctrl+c stop"); + }); +}); + +describe("the onboarding audit", () => { + afterEach(() => { + delete process.env.FAILPROOFAI_NO_AUTO_AUDIT; + }); + + it("draws the same screen, then says nothing is enforced on a machine with no packs", async () => { + delete stdout.isTTY; + h.runAudit.mockImplementation(scanReporting()); + await runPostSetupAudit(); + + const text = output(); + expect(text).not.toContain("\x1B"); + expect(text).not.toContain("now running"); + expect(occurrences(text, "SCANNED HISTORY")).toBe(1); + expect(text).toContain("▲ None of this is enforced yet. · failproofai policies add FailproofAI/policies"); + }); + + it("names the fix when its scan fails, and does not throw", async () => { + delete stdout.isTTY; + h.runAudit.mockRejectedValue(new TypeError("disk exploded")); + await expect(runPostSetupAudit()).resolves.toBeUndefined(); + expect(output()).toContain("✕ The audit could not finish. · failproofai audit"); + }); +}); diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts index 066ae5904..c03f7da02 100644 --- a/__tests__/audit/cli-login.test.ts +++ b/__tests__/audit/cli-login.test.ts @@ -214,6 +214,81 @@ describe("a preset address", () => { }); }); +describe("the sign-in screen", () => { + /** + * The 2026-10 screen language: the `Audit` header, an UPPERCASE heading, a + * field per question and one `✓`/`✕` line per outcome. It used to open with + * the full logomark inside `config`'s `│ ◆ ◇ └` spine; the redesign keeps the + * mark to bare `failproofai` and `config` (LAUNCH-10). + */ + let written: string[]; + beforeEach(() => { + written = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + written.push(String(chunk)); + return true; + }); + }); + const screen = () => written.join(""); + + it("opens with the Audit header and a SIGN IN heading, and no logomark or spine", async () => { + promptTextMock.mockResolvedValueOnce("you@example.com").mockResolvedValueOnce("123456"); + await runLogin(); + + const lines = screen().split("\n"); + expect(lines[0]).toBe(""); + expect(lines[1]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Audit$/); + expect(lines[3]).toBe("SIGN IN"); + expect(screen()).not.toMatch(/[│◆◇└▀▄█]/u); + }); + + it("asks through the new field, and states each outcome as a sentence", async () => { + promptTextMock.mockResolvedValueOnce("you@example.com").mockResolvedValueOnce("123456"); + await runLogin(); + + expect(promptTextMock.mock.calls.map(([opts]) => [opts.message, opts.field])).toEqual([ + ["email", true], + ["code", true], + ]); + expect(screen()).toContain("✓ Sent a code to you@example.com, valid for 10 minutes.\n"); + expect(screen()).toContain("✓ Signed in as you@example.com.\n"); + }); + + it("says a wrong code with a ✕ and asks again with the attempt on the hint", async () => { + promptTextMock + .mockResolvedValueOnce("you@example.com") + .mockResolvedValueOnce("000000") + .mockResolvedValueOnce("123456"); + verifyMock + .mockRejectedValueOnce(new AuthApiError(401, "invalid_code", "that code is wrong")) + .mockResolvedValueOnce(TOKENS); + await runLogin(); + + expect(screen()).toContain("✕ That code was wrong or has expired.\n"); + expect(promptTextMock.mock.calls[2]![0].hint).toBe("attempt 2 of 3"); + }); + + it("shows a preset address as the field it would have been typed into", async () => { + promptTextMock.mockResolvedValueOnce("123456"); + await runLogin("Preset@Example.com"); + expect(screen()).toContain("\n email › preset@example.com\n✓ Sent a code to preset@example.com"); + }); + + it("says nothing was changed when the sign-in is cancelled", async () => { + promptTextMock.mockResolvedValueOnce(null); + await expect(runLogin()).rejects.toThrow("Cancelled."); + expect(screen()).toContain("Cancelled. Nothing was changed.\n"); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("says a code that could not be sent with a ✕, and leaves the reason to the error", async () => { + promptTextMock.mockResolvedValueOnce("you@example.com"); + requestMock.mockRejectedValueOnce(new AuthApiError(429, "rate_limited", "slow down", 30)); + await expect(runLogin()).rejects.toThrow(/too many attempts/i); + expect(screen()).toContain("✕ Could not send a login code.\n"); + }); +}); + describe("an existing session that has already expired", () => { // `--schedule` printed `reports to ` and exited 0 for ANY session file // on disk, expiry unread — so somebody whose refresh token lapsed or was diff --git a/__tests__/audit/schedule-cli.test.ts b/__tests__/audit/schedule-cli.test.ts index e572ff1fe..7423fb581 100644 --- a/__tests__/audit/schedule-cli.test.ts +++ b/__tests__/audit/schedule-cli.test.ts @@ -138,8 +138,10 @@ describe("audit --no-schedule", () => { runScheduleOff(); expect(readConfig().audit.auto).toBe(false); - // Signing out is a separate decision, and the command says so — the session - // file is untouched, so re-enabling later costs no second round of OTP. + // Signing out is a separate decision — the session file is untouched, so + // re-enabling later costs no second round of OTP. (The command no longer + // says so in a line of its own: the redesign removes explanatory lines, + // decision D6.) expect(readAuth()?.user.email).toBe("you@example.com"); expect(stdout()).toContain("off"); }); @@ -162,9 +164,34 @@ describe("audit --no-schedule", () => { describe("audit --status", () => { it("reports off, with no email, on a fresh machine", () => { runScheduleStatus(); - expect(stdout()).toContain("scheduled audit"); - expect(stdout()).toContain("off"); - expect(stdout()).toContain("signed out"); + // Was a `scheduled audit off` row before the 2026-10 screen language: the + // heading now says what the block is about, and states read as words. + expect(stdout()).toMatch(/^ {2}scans +○ Off$/m); + expect(stdout()).toMatch(/^ {2}reports to +Signed out$/m); + }); + + it("is a screen in the new language: the Audit header, one heading, aligned rows", () => { + runScheduleStatus(); + const lines = stdout().split("\n"); + expect(lines[1]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Audit$/); + expect(lines).toContain("SCHEDULE"); + // Every value starts on one column, whatever its label. + const rows = lines.filter((l) => /^ {2}(scans|reports to|daemon|last result) /.test(l)); + expect(rows.length).toBe(4); + const column = (l: string) => l.search(/(?<=\S {2,})\S/); + expect(new Set(rows.map(column)).size).toBe(1); + // No spine, no logomark, no hand-padded title. + expect(stdout()).not.toMatch(/[│◆◇└▀▄█]/u); + expect(stdout()).not.toContain("failproofai audit "); + }); + + it("states a schedule that is on with the on glyph and its interval", async () => { + writeAuth(SESSION); + await runScheduleOn("1"); + out = []; + runScheduleStatus(); + expect(stdout()).toMatch(/^ {2}scans +● Every day$/m); + expect(stdout()).toMatch(/^ {2}reports to +you@example\.com$/m); }); it("reports on, the interval, and where reports go", async () => { @@ -190,6 +217,19 @@ describe("audit --status", () => { // The exact state `report-harm.ts` reports as "signed-out". Silence about // it would look like the feature failing. expect(stdout()).toMatch(/scans continue/i); + // A ▲ with the command that fixes it, rather than a pink row. + expect(stdout()).toContain( + "▲ Scans continue, but digests are paused until you sign in. · failproofai audit --schedule", + ); + }); + + it("names a schedule that predates the consent stamp, with the fix", () => { + // `audit.auto` set by a release where it only meant "scan locally": signed + // in, scheduled, and still sending nothing until somebody opts in. + writeAuth(SESSION); + updateConfig({ audit: { auto: true } }); + runScheduleStatus(); + expect(stdout()).toContain("▲ Scans continue, but digests need a fresh opt-in. · failproofai audit --schedule"); }); it("never throws on a home with no schedule, cache or machine file", () => { @@ -237,8 +277,10 @@ describe("daemon reporting", () => { }); it("--status names the repair for every state the daemon can be in", () => { + // `running` is matched without case since the redesign, which capitalises + // a state: `● Running`. for (const [status, expected] of [ - ["running", /running/], + ["running", /running/i], ["stopped", /failproofai config/], ["not-installed", /not installed/], ["condition-failed", /binary is missing/], @@ -249,6 +291,74 @@ describe("daemon reporting", () => { expect(stdout(), status).toMatch(expected); } }); + + it("--status gives the daemon a row and, when it needs fixing, one ▲ with the fix", () => { + daemonStatus.value = "stopped"; + runScheduleStatus(); + expect(stdout()).toMatch(/^ {2}daemon +○ Stopped$/m); + expect(stdout()).toContain("▲ The daemon is stopped. · failproofai config"); + + out = []; + daemonStatus.value = "running"; + runScheduleStatus(); + expect(stdout()).toMatch(/^ {2}daemon +● Running$/m); + expect(stdout()).not.toContain("▲"); + }); + + it("warns on stderr in the new language when nothing will run the schedule", async () => { + daemonStatus.value = "not-installed"; + writeAuth(SESSION); + await runScheduleOn("7"); + expect(stderr()).toContain( + "▲ The daemon is not installed, so nothing will run on the timer yet. · failproofai config", + ); + }); +}); + +describe("audit --schedule, the screen", () => { + it("states the result, then what each report sends, then where to look next", async () => { + writeAuth(SESSION); + await runScheduleOn("7"); + const lines = stdout().split("\n"); + expect(lines[1]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Audit$/); + expect(stdout()).toContain("✓ Scheduled audits to report to you@example.com every 7 days.\n"); + expect(stdout()).toContain("See when the next scan runs: failproofai audit --status"); + expect(stdout()).not.toMatch(/[│◆◇└▀▄█]/u); + }); + + it("keeps the disclosure of what leaves the machine — the consent stamp certifies it was read", async () => { + // `reportsConsentedAt` records that a person READ this line before anything + // was sent, so it is part of the consent, not fine print, and the one + // explanatory line the redesign keeps (decision D6). Removing it changes + // what every stamp already written means. + writeAuth(SESSION); + await runScheduleOn("7"); + expect(stdout()).toContain( + "\n Each report sends finding counts, redacted example commands and this machine's name.\n", + ); + expect(readConfig().audit.reportsConsentedAt).toEqual(expect.any(Number)); + }); + + it("says every day, not every 1 days", async () => { + writeAuth(SESSION); + await runScheduleOn("1"); + expect(stdout()).toContain("every day."); + }); +}); + +describe("audit --no-schedule, the screen", () => { + it("opens with the Audit header and states the result as a sentence", async () => { + writeAuth(SESSION); + await runScheduleOn("7"); + out = []; + runScheduleOff(); + expect(stdout()).toMatch(/^\nfailproof ai {2}v\S+ {2}· {2}Audit\n\n✓ Turned off scheduled audits\.\n\n$/); + }); + + it("says plainly when there was nothing to turn off", () => { + runScheduleOff(); + expect(stdout()).toContain("\nScheduled audits were already off.\n"); + }); }); describe("--email", () => { diff --git a/__tests__/audit/scheduled-audit.test.ts b/__tests__/audit/scheduled-audit.test.ts index fcdb60e63..39e67a8d0 100644 --- a/__tests__/audit/scheduled-audit.test.ts +++ b/__tests__/audit/scheduled-audit.test.ts @@ -249,7 +249,7 @@ describe("the interactive `failproofai audit` shares the lock", () => { await runAuditCli([]); - expect(h.launch).toHaveBeenCalledWith("start"); + expect(h.launch).toHaveBeenCalledWith("start", { screen: false }); expect(existsSync(auditLockFile())).toBe(false); }); diff --git a/__tests__/dashboard/install-hooks-web-agent-selection.test.ts b/__tests__/dashboard/install-hooks-web-agent-selection.test.ts new file mode 100644 index 000000000..ea6b2438c --- /dev/null +++ b/__tests__/dashboard/install-hooks-web-agent-selection.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +/** + * The dashboard's per-agent hook panel keeps `agents.selected` true the same + * way the CLI does (decision D2): ticking an agent and applying traces it, + * unticking it untraces it. Server side only — the panel shows errors and no + * success message, so its UI is unchanged. + * + * The real server actions and the real manager, against a throwaway HOME, + * FAILPROOFAI_HOME and pack directory. User scope only, which is the only scope + * the panel uses: the actions pass no cwd, so a project-scope call here would + * reach this repository's own dogfood configs. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("@/src/hooks/integrations", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, detectInstalledClis: vi.fn(() => []) }; +}); +vi.mock("@/src/hooks/pack-store", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, addPack: vi.fn(async () => undefined) }; +}); +vi.mock("@/src/hooks/hook-telemetry", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, trackHookEvent: vi.fn(async () => undefined) }; +}); + +import { installHooksWebAction, removeHooksWebAction } from "@/app/actions/install-hooks-web"; +import { detectInstalledClis } from "@/src/hooks/integrations"; +import { readConfig } from "@/src/hooks/fp-config"; + +let root: string; +let home: string; +let fpHome: string; +let saved: Record; + +const configPath = () => join(fpHome, "config.json"); +const goosePlugin = () => join(home, ".agents", "plugins", "failproofai", "hooks", "hooks.json"); +function writeSelection(agents: { selected: string[]; seen: string[] }): void { + writeFileSync(configPath(), `${JSON.stringify({ agents }, null, 2)}\n`); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "fpai-web-agent-selection-")); + home = join(root, "home"); + fpHome = join(root, "fp-home"); + const packs = join(root, "packs"); + for (const dir of [home, fpHome, packs]) mkdirSync(dir, { recursive: true }); + saved = { + HOME: process.env.HOME, + FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, + FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR, + FAILPROOFAI_BINARY_OVERRIDE: process.env.FAILPROOFAI_BINARY_OVERRIDE, + }; + process.env.HOME = home; + process.env.FAILPROOFAI_HOME = fpHome; + process.env.FAILPROOFAI_PACK_DIR = packs; + process.env.FAILPROOFAI_BINARY_OVERRIDE = join(root, "failproofai"); + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +describe("the dashboard's per-agent hook panel", () => { + it("Apply with an agent ticked traces it", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + await installHooksWebAction("user", ["goose"]); + expect(existsSync(goosePlugin())).toBe(true); + expect(readConfig().agents).toEqual({ selected: ["claude", "goose"], seen: ["claude", "goose"] }); + }); + + it("Apply with an agent unticked stops tracing it", async () => { + writeSelection({ selected: ["claude", "goose"], seen: ["claude", "goose"] }); + await installHooksWebAction("user", ["goose"]); + await removeHooksWebAction("user", ["goose"]); + expect(JSON.parse(readFileSync(goosePlugin(), "utf8")).hooks).toBeUndefined(); + expect(readConfig().agents).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); + + it("unticking on a machine with no selection writes one with this machine's agents but that one", async () => { + await installHooksWebAction("user", ["goose"]); + expect(existsSync(configPath())).toBe(false); + await removeHooksWebAction("user", ["goose"]); + // Not every agent but Goose: `failproofai config` restores a saved list as + // it stands, and would hook all of them, installed or not. + expect(readConfig().agents).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); +}); diff --git a/__tests__/e2e/cli/agent-selection.e2e.test.ts b/__tests__/e2e/cli/agent-selection.e2e.test.ts new file mode 100644 index 000000000..808c1c410 --- /dev/null +++ b/__tests__/e2e/cli/agent-selection.e2e.test.ts @@ -0,0 +1,133 @@ +/** + * E2E: the commands that install or remove hooks keep `agents.selected` true + * (decision D2), through the real binary. + * + * Unit tests drive installHooks/removeHooks directly; this is what proves + * bin/failproofai.mjs asks them to. Each test gets its own HOME, + * FAILPROOFAI_HOME, pack directory and working directory, so nothing here can + * reach the developer's own config. No command below needs the network: no + * policy name is fetched, and `policies add` resolves against a pack written to + * disk first. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); +const TRACED = "✓ Goose is traced again, so failproofai config keeps its hooks."; +const UNTRACED = "✓ Goose is no longer traced, so failproofai config won't add its hooks back."; + +let root: string; +let home: string; +let fpHome: string; +let packs: string; +let project: string; + +function run(...args: string[]): { exitCode: number; stdout: string; stderr: string; lastLine: string } { + const result = spawnSync("bun", [BINARY_PATH, ...args], { + cwd: project, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + FAILPROOFAI_HOME: fpHome, + FAILPROOFAI_PACK_DIR: packs, + FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH, + FAILPROOFAI_TELEMETRY_DISABLED: "1", + NO_COLOR: "1", + }, + encoding: "utf8", + timeout: 20_000, + }); + const stdout = (result.stdout ?? "").trim(); + return { + exitCode: result.status ?? 1, + stdout, + stderr: (result.stderr ?? "").trim(), + lastLine: stdout.split("\n").at(-1) ?? "", + }; +} + +function writeSelection(selected: string[], seen: string[]): void { + writeFileSync(join(fpHome, "config.json"), `${JSON.stringify({ agents: { selected, seen } }, null, 2)}\n`); +} +const selected = (): string[] => + (JSON.parse(readFileSync(join(fpHome, "config.json"), "utf8")) as { agents: { selected: string[] } }).agents.selected; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "fp-e2e-agent-selection-")); + home = join(root, "home"); + fpHome = join(root, "fp-home"); + packs = join(root, "packs"); + project = join(root, "project"); + for (const dir of [home, fpHome, packs, project]) mkdirSync(dir, { recursive: true }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("E2E: hook commands keep the agent selection true", () => { + it("policies --install --cli goose traces Goose, and --uninstall stops tracing it", () => { + writeSelection(["claude"], ["claude"]); + + const installed = run("policies", "--install", "--cli", "goose"); + expect(installed.exitCode, installed.stderr).toBe(0); + expect(installed.lastLine).toBe(TRACED); + expect(selected()).toEqual(["claude", "goose"]); + + const removed = run("policies", "--uninstall", "--cli", "goose"); + expect(removed.exitCode, removed.stderr).toBe(0); + expect(removed.lastLine).toBe(UNTRACED); + expect(selected()).toEqual(["claude"]); + }); + + it("policies --uninstall --scope project leaves the selection alone", () => { + writeSelection(["claude", "goose"], ["goose"]); + expect(run("policies", "--install", "--cli", "goose", "--scope", "project").exitCode).toBe(0); + const before = readFileSync(join(fpHome, "config.json"), "utf8"); + + const removed = run("policies", "--uninstall", "--cli", "goose", "--scope", "project"); + expect(removed.exitCode, removed.stderr).toBe(0); + expect(removed.stdout).not.toContain("traced"); + expect(readFileSync(join(fpHome, "config.json"), "utf8")).toBe(before); + }); + + it("policies add --cli goose traces Goose, and policies remove all stops tracing it", () => { + // The bundled pack on disk, so the name resolves without a fetch. + const artifact = "export const hooks = [];\n"; + const digest = createHash("sha256").update(artifact).digest("hex"); + mkdirSync(join(packs, "artifacts"), { recursive: true }); + writeFileSync(join(packs, "artifacts", `${digest}.mjs`), artifact); + writeFileSync( + join(packs, "installed.json"), + JSON.stringify({ + schemaVersion: 1, + packs: [{ + id: "FailproofAI/policies", + version: "1.2.0", + source: "github:FailproofAI/policies@v1.2.0", + entry: `artifacts/${digest}.mjs`, + sha256: digest, + policies: [{ name: "block-sudo", description: "does block-sudo", category: "Security", defaultEnabled: true, match: {} }], + }], + }), + ); + writeSelection(["claude"], ["claude"]); + + const added = run("policies", "add", "block-sudo", "--cli", "goose"); + expect(added.exitCode, added.stderr).toBe(0); + expect(added.lastLine).toBe(TRACED); + expect(selected()).toEqual(["claude", "goose"]); + + const removed = run("policies", "remove", "all", "--cli", "goose"); + expect(removed.exitCode, removed.stderr).toBe(0); + expect(removed.lastLine).toBe(UNTRACED); + expect(selected()).toEqual(["claude"]); + }); +}); diff --git a/__tests__/e2e/cli/cli-args.e2e.test.ts b/__tests__/e2e/cli/cli-args.e2e.test.ts index 61a1421ac..bfc76f631 100644 --- a/__tests__/e2e/cli/cli-args.e2e.test.ts +++ b/__tests__/e2e/cli/cli-args.e2e.test.ts @@ -8,6 +8,10 @@ * before running these tests. */ import { describe, it, expect } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runCli, assertCleanError, assertSuccess } from "../helpers/cli-runner"; // ── Top-level flags ─────────────────────────────────────────────────────────── @@ -47,9 +51,10 @@ describe("top-level: --help", () => { it("prints help and exits 0", () => { const result = runCli("--help"); assertSuccess(result); - // The index no longer shouts a USAGE heading — it spends its lines on - // commands and names the shape once, in the footer under them. - expect(result.stdout).toContain("failproofai [options]"); + // The 2026-10 index: what to do first, then every command by what it is + // for, and a closing line naming per-command help and the version flag. + expect(result.stdout).toContain("GET STARTED"); + expect(result.stdout).toContain("failproofai -v"); expect(result.stdout).toContain("policies"); // The half that replaced every inlined flag. expect(result.stdout).toContain("failproofai help "); @@ -58,7 +63,7 @@ describe("top-level: --help", () => { it("-h shorthand prints help and exits 0", () => { const result = runCli("-h"); assertSuccess(result); - expect(result.stdout).toContain("failproofai [options]"); + expect(result.stdout).toContain("GET STARTED"); }); it("rejects extra argument after --help", () => { @@ -75,29 +80,34 @@ describe("top-level: --help", () => { describe("top-level: unknown command", () => { it("rejects unknown subcommand with clean error", () => { const result = runCli("unknowncommand"); - assertCleanError(result, "Unknown command: unknowncommand"); - }); - - it("always offers a runnable suggestion for an unknown subcommand", () => { - // Deliberately NOT pinned to a specific word. "unknowncommand" is a typo of - // nothing, so which subcommand comes out nearest is an artefact of the - // command LIST, not a contract: it read "policies" only because three names - // tied at distance 12 and `SUBCOMMANDS[0]` broke the tie. Adding - // `uninstall` (distance 10) changed the winner without changing any - // behaviour anyone relies on. The contract is that a suggestion is offered - // and names a real subcommand; the nearest-match behaviour itself is - // covered by the tests below, which use inputs that ARE typos of something. + // The design's words (Q4), in the notice shape: `✕`, then the fix line. + assertCleanError(result, "✕ There is no command called 'unknowncommand'."); + }); + + it("suggests nothing for a word that is close to no command, and points at help", () => { + // This used to ALWAYS name a subcommand, so a typo of nothing got the + // winner of a 12-way tie on distance — an artefact of the command list. + // A far-off word now gets no guess (Q6): the help index is the honest answer. const result = runCli("unknowncommand"); - const match = /Did you mean: failproofai (\S+)\?/.exec(result.stderr); - expect(match).not.toBeNull(); - expect(["policies", "policy", "audit", "config", "uninstall"]).toContain(match![1]); + expect(result.stderr).not.toContain("Did you mean"); + expect(result.stderr).toContain(" Run failproofai help to see every command."); }); it("suggests the NEAREST subcommand, not a hardcoded one", () => { // The suggestion was the literal string "policies" for every input, which // was right only when the typo happened to be a typo of that word. - expect(runCli("confg").stderr).toContain("failproofai config"); - expect(runCli("audits").stderr).toContain("failproofai audit"); + expect(runCli("confg").stderr).toContain(" Did you mean failproofai config?"); + expect(runCli("audits").stderr).toContain(" Did you mean failproofai audit?"); + }); + + it("answers the words people type with the command that does it", () => { + // `status` got `flush` and `install` got `uninstall` — the opposite of the + // request — from the nearest-spelling guess alone. + expect(runCli("status").stderr).toContain(" Did you mean failproofai config --status?"); + const install = runCli("install").stderr; + expect(install).toContain(" Did you mean failproofai policies --install?"); + expect(install).not.toContain("uninstall"); + expect(runCli("login").stderr).toContain(" Did you mean failproofai config?"); }); it("points a stale `auth` at audit rather than somewhere unrelated", () => { @@ -105,7 +115,7 @@ describe("top-level: unknown command", () => { // plain muscle memory lands here. It must not be answered with the one // command that has nothing to do with what was typed. const result = runCli("auth", "login"); - assertCleanError(result, "Unknown command: auth"); + assertCleanError(result, "✕ There is no command called 'auth'."); expect(result.stderr).toContain("failproofai audit"); }); }); @@ -113,12 +123,35 @@ describe("top-level: unknown command", () => { describe("top-level: unknown flag", () => { it("rejects unknown flag with clean error", () => { const result = runCli("--unknownflag"); - assertCleanError(result, "Unknown flag: --unknownflag"); + assertCleanError(result, "✕ Unknown flag: --unknownflag."); }); - it("suggests closest known flag", () => { - const result = runCli("--unknownflag"); - expect(result.stderr).toContain("Did you mean"); + it("suggests the closest known flag as a whole command", () => { + expect(runCli("--versoin").stderr).toContain(" Did you mean failproofai --version?"); + expect(runCli("--status").stderr).toContain(" Did you mean failproofai config --status?"); + }); + + it("suggests nothing for a flag that is close to none, and never `audit` for -i", () => { + const far = runCli("--unknownflag"); + expect(far.stderr).not.toContain("Did you mean"); + expect(far.stderr).toContain(" Run failproofai help for usage."); + expect(runCli("-i").stderr).not.toContain("audit"); + }); +}); + +describe("every error prints in the notice shape", () => { + it("opens on `✕`, never on `Error:`, and keeps the exit code", () => { + for (const result of [runCli("status"), runCli("policies", "--typo"), runCli("help", "nonsense")]) { + expect(result.exitCode).toBe(1); + expect(result.stderr.startsWith("✕ ")).toBe(true); + expect(result.stderr).not.toContain("Error:"); + } + }); + + it("prints no escape codes and no backticks into a pipe", () => { + const { stderr } = runCli("policies", "--typo"); + expect(stderr).not.toMatch(/\x1B\[|`/); + expect(stderr).toBe("✕ Unknown flag: --typo.\n Run failproofai policies --help for usage."); }); }); @@ -127,29 +160,107 @@ describe("top-level: --hook", () => { const result = runCli("--hook"); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Missing event type after --hook"); + // Pointed at the page that lists the events and agent ids, instead of a + // 131-column usage line. + expect(result.stderr).toContain(" Run failproofai help hook for the events and agent ids."); + }); +}); + +describe("policies: --cli names every agent", () => { + const EVERY_AGENT = [ + " Use any of: claude, codex, copilot, cursor, opencode, pi,", + " hermes, openclaw, factory, devin, antigravity, goose", + ].join("\n"); + + it("says `Not an agent` for a typo, with the near one, and lists all twelve", () => { + // It said "Missing value(s)" even for a typo, and named eight of twelve. + const result = runCli("policies", "--install", "--cli", "claud"); + assertCleanError(result, "✕ Not an agent: claud (did you mean claude?)"); + expect(result.stderr).toContain(EVERY_AGENT); + }); + + it("says the agent is missing when nothing follows --cli", () => { + const result = runCli("policies", "--uninstall", "--cli"); + assertCleanError(result, "✕ Missing agent name after --cli."); + expect(result.stderr).toContain(EVERY_AGENT); }); }); // ── policies (list / default) ───────────────────────────────────────────────── describe("policies: list (default)", () => { + // The listing opens with the redesign's wordmark header (D14), not with the + // command's name, so that header is what proves the listing rendered. + const HEADER = /failproof ai\s+v\S+\s+·\s+Policies/; + it("lists policies and exits 0 with no args", () => { const result = runCli("policies"); assertSuccess(result); - expect(result.stdout).toContain("failproofai policies"); + expect(result.stdout).toMatch(HEADER); expect(result.stdout).not.toContain("block-sudo"); }); it("lists policies when --list alias is used", () => { const result = runCli("policies", "--list"); assertSuccess(result); - expect(result.stdout).toContain("failproofai policies"); + expect(result.stdout).toMatch(HEADER); + }); + + it("lists every policy, folding none, with --all", () => { + // Only a pack section folds, and this file's HOME has no pack, so on its own + // `not.toMatch(/more off/)` could never fail. Seed a pack with one policy + // off, prove the bare listing folds it, then that --all does not. The pack + // directory is set for these two runs only: other tests here assert an + // empty listing. + const packs = mkdtempSync(join(tmpdir(), "failproofai-cli-e2e-packs-")); + const artifact = "export const hooks = [];\n"; + const digest = createHash("sha256").update(artifact).digest("hex"); + mkdirSync(join(packs, "artifacts"), { recursive: true }); + writeFileSync(join(packs, "artifacts", `${digest}.mjs`), artifact); + writeFileSync( + join(packs, "installed.json"), + JSON.stringify({ + schemaVersion: 1, + packs: [{ + id: "FailproofAI/policies", + version: "1.2.0", + source: "github:FailproofAI/policies@v1.2.0", + entry: `artifacts/${digest}.mjs`, + sha256: digest, + enabled: ["e2e-shown-when-on"], + policies: [ + { name: "e2e-shown-when-on", description: "on", category: "Security", defaultEnabled: true, match: {} }, + { name: "e2e-folded-when-off", description: "off", category: "Security", defaultEnabled: false, match: {} }, + ], + }], + }), + ); + const prior = process.env.FAILPROOFAI_PACK_DIR; + process.env.FAILPROOFAI_PACK_DIR = packs; + try { + const folded = runCli("policies"); + assertSuccess(folded); + expect(folded.stdout).toMatch(/● e2e-shown-when-on/); + expect(folded.stdout).toMatch(/○ 1 more off/); + expect(folded.stdout).not.toContain("e2e-folded-when-off"); + + const result = runCli("policies", "--all"); + assertSuccess(result); + expect(result.stdout).toMatch(HEADER); + expect(result.stdout).toMatch(/● e2e-shown-when-on/); + expect(result.stdout).toMatch(/○ e2e-folded-when-off/); + expect(result.stdout).not.toMatch(/more off/); + } finally { + if (prior === undefined) delete process.env.FAILPROOFAI_PACK_DIR; + else process.env.FAILPROOFAI_PACK_DIR = prior; + rmSync(packs, { recursive: true, force: true }); + } }); it("p shorthand lists policies", () => { const result = runCli("p"); assertSuccess(result); - expect(result.stdout).toContain("failproofai policies"); + expect(result.stdout).toMatch(HEADER); }); it("rejects unexpected positional argument", () => { @@ -185,14 +296,12 @@ describe("policies: --help", () => { describe("pack: --help", () => { it("prints help when the flag follows a nested subcommand", () => { - // `pack` is a spelling of `policies` now, so this reaches the unified - // add/remove/show help rather than a pack-only one. + // `pack` is a spelling of `policies`, and `add|remove|show --help` prints + // the one policies page, which carries every flag of every policies lane. const result = runCli("pack", "add", "--help"); assertSuccess(result); - expect(result.stdout).toContain("failproofai policies add|remove|show"); - // Section headings are lowercase now — the brand's display type is, and - // twelve screens sharing one renderer means they share its case too. - expect(result.stdout).toContain("a name or a source"); + expect(result.stdout).toContain("failproofai policies add [name | owner/repo]"); + expect(result.stdout).toBe(runCli("policies", "--help").stdout); }); }); @@ -414,3 +523,109 @@ describe("policies --uninstall: all valid names", () => { assertSuccess(result); }); }); + +describe("config's own options (setup v2)", () => { + it("refuses --oss with --token: one uses open source, the other connects", () => { + const result = runCli("config", "--oss", "--token", "k".repeat(20)); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--oss and --token cannot be combined"); + }); + + it("refuses a name that is not an agent, naming the real ones", () => { + const result = runCli("config", "--agents", "claud,codex"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not an agent: claud"); + expect(result.stderr).toContain("claude, codex, copilot"); + expect(result.stderr).toContain("or all"); + }); + + it("refuses an option it does not take, instead of running setup without it", () => { + const result = runCli("config", "--agent", "claude"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unexpected argument: --agent"); + }); + + it("suggests the flag a bare word meant", () => { + const result = runCli("config", "status"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("failproofai config --status"); + }); + + it("documents --oss and --agents", () => { + const result = runCli("config", "--help"); + assertSuccess(result); + expect(result.stdout).toMatch(/--oss +Use open source; leaves any connection as it is/); + expect(result.stdout).toMatch(/--agents +Trace these agents, or all, without asking/); + }); +}); + +describe("backfill's arguments", () => { + it("refuses a bare window instead of silently running the default", () => { + const result = runCli("backfill", "6m"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unexpected argument: 6m"); + expect(result.stderr).toContain("failproofai backfill --since 6m"); + }); + + it("documents --agents", () => { + const result = runCli("backfill", "--help"); + assertSuccess(result); + expect(result.stdout).toMatch(/--agents +Only these traced agents/); + }); +}); + +describe("action results in the new look", () => { + it("draws a failed flush as ✕ with its fix indented under it, under the header", () => { + const result = runCli("flush"); + expect(result.exitCode).toBe(1); + const lines = result.stderr.split("\n"); + expect(lines[0]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Flush$/); + expect(result.stderr).toMatch(/^✕ This machine is not connected, so there is nowhere to flush to\.$/m); + expect(result.stderr).toMatch(/^ {2}Connect it with failproofai config\.$/m); + // Backticks become the command alone; never printed literally. + expect(result.stderr).not.toContain("`"); + }); +}); + +describe("review fixes for #881", () => { + it("answers --uninstall and unpause with what they asked for, never the opposite", () => { + const uninstall = runCli("--uninstall").stderr; + expect(uninstall).toContain(" Did you mean failproofai policies --uninstall?"); + expect(uninstall).not.toMatch(/policies --install/); + const unpause = runCli("unpause").stderr; + expect(unpause).toContain(" Did you mean failproofai config --resume?"); + expect(unpause).not.toContain("--pause"); + }); + + it("accepts the retired --send-transcripts as a no-op, rather than as an unknown option", () => { + // Missing --connect's url is the first thing it trips over, so the run + // proves the flag got past the option check without reaching a server. + const result = runCli("config", "--send-transcripts", "--connect"); + expect(result.stderr).not.toContain("Unexpected argument"); + assertCleanError(result, "Missing value after --connect."); + }); + + it("refuses --send-transcripts with --no-transcripts", () => { + assertCleanError(runCli("config", "--send-transcripts", "--no-transcripts"), "cannot be combined"); + }); + + it("refuses --machine-label with --oss on a machine that is not connected, instead of dropping it", () => { + const result = runCli("config", "--oss", "--machine-label", "prod-01"); + assertCleanError(result, "--oss leaves this one unconnected"); + }); + + it("points `policies add -c` at the lane that takes it, and the shared help says which lane that is", () => { + const add = runCli("policies", "add", "-c", "./guards.mjs"); + assertCleanError(add, "Unknown flag: -c."); + expect(add.stderr).toContain("failproofai policies --install -c "); + expect(add.stderr).not.toContain("policies add --help"); + const help = runCli("policies", "--help"); + expect(help.stdout).toMatch(/-c, --custom +With --install, add a custom policy file \(repeatable\)/); + expect(help.stdout).toContain("--uninstall -c removes every custom policy file"); + }); + + it("refuses --machine-label with --agents and no key when nobody is at a prompt", () => { + const result = runCli("config", "--agents", "claude", "--machine-label", "prod-01"); + assertCleanError(result, "this one is not connected"); + }); +}); diff --git a/__tests__/e2e/layout/cloud-collector.sh b/__tests__/e2e/layout/cloud-collector.sh index 01cfc1a4e..c38c4d031 100755 --- a/__tests__/e2e/layout/cloud-collector.sh +++ b/__tests__/e2e/layout/cloud-collector.sh @@ -24,7 +24,9 @@ export FAILPROOFAI_HOME="$H/home" export FAILPROOFAI_NO_FIRST_RUN=1 printf '\n=== 1. CONNECT: both capabilities verified against the real server ===\n' -OUT=$($CLI config --connect "$API" --token "$KEY" --send-transcripts 2>&1); RC=$? +# No --send-transcripts: transcripts are the default since #653 (the flag is +# still accepted as a no-op, for scripts written before then). +OUT=$($CLI config --connect "$API" --token "$KEY" 2>&1); RC=$? has "reports a full connection" "$OUT" "Connected to" check "exit 0 when policy enrolment succeeds" "$RC" "0" check "credentials.json written" "$([ -f "$H/home/credentials.json" ] && echo y || echo n)" "y" @@ -33,7 +35,7 @@ has "mode flipped to cloud" "$(cat "$H/home/config.json")" '"kind": "cloud"' hasnt "token never lands in config.json" "$(cat "$H/home/config.json")" "$KEY" has "both tables present" "$(cat "$H/home/credentials.json")" "\"cloud\":" has "ingest table present" "$(cat "$H/home/credentials.json")" "\"ingest\":" -has "transcripts opted in" "$(cat "$H/home/config.json")" '"sessions": true' +has "transcripts on by default" "$(cat "$H/home/config.json")" '"sessions": true' printf '\n=== 2. DAEMON: starts, reads layout-2 config, enables the collector ===\n' mkdir -p "$H/home/policies" diff --git a/__tests__/hooks/agent-selection-retrace.test.ts b/__tests__/hooks/agent-selection-retrace.test.ts new file mode 100644 index 000000000..8e673458a --- /dev/null +++ b/__tests__/hooks/agent-selection-retrace.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment node +/** + * Decision D2, the parts that reach the daemon: + * + * - An agent traced again through `policies --install`, `policies add` or the + * dashboard's agent panel starts fresh, exactly as one re-ticked in + * `failproofai config` does: an `added` backfill request lands BEFORE the + * selection that traces it, or the daemon resumes the cursors it held when + * the agent was untraced and ships what was written in between. + * - A removal on a machine with no saved list records the agents on this + * machine minus the removed ones — not every agent this build knows, which + * `failproofai config` would then restore and hook, installed or not. + * + * Real files throughout, under a throwaway HOME, FAILPROOFAI_HOME, HERMES_HOME + * and pack directory. Only PATH detection, the pack fetch and telemetry are + * stubbed. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/integrations", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, detectInstalledClis: vi.fn(() => []) }; +}); +vi.mock("../../src/hooks/pack-store", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, addPack: vi.fn(async () => undefined) }; +}); +vi.mock("../../src/hooks/hook-telemetry", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, trackHookEvent: vi.fn(async () => undefined) }; +}); +// Passed through, so the real file is written; wrapped, so a test can look at +// the disk at the moment the selection is written. +vi.mock("../../src/hooks/fp-config", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, updateConfig: vi.fn(actual.updateConfig) }; +}); + +import { keepAgentSelectionTrue } from "../../src/hooks/agent-selection"; +import { backfillRequestPath, machineCollectsSessions, requestFreshStart } from "../../src/hooks/backfill-request"; +import { writeCollectorSettings, writeIngestCredential } from "../../src/hooks/collector-config"; +import { readConfig, updateConfig } from "../../src/hooks/fp-config"; +import { detectInstalledClis, getIntegration } from "../../src/hooks/integrations"; +import { installHooks } from "../../src/hooks/manager"; + +let root: string; +let home: string; +let fpHome: string; +let project: string; +let saved: Record; + +const pendingRequest = (): Record | null => + existsSync(backfillRequestPath()) ? (JSON.parse(readFileSync(backfillRequestPath(), "utf8")) as Record) : null; + +function connect(sessions = true): void { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "s".repeat(20) }); + writeCollectorSettings({ sessions, hooks: true }); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "fpai-retrace-")); + home = join(root, "home"); + fpHome = join(root, "fp-home"); + project = join(root, "project"); + for (const dir of [home, fpHome, project, join(home, ".hermes"), join(root, "packs")]) mkdirSync(dir, { recursive: true }); + saved = { + HOME: process.env.HOME, + FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, + FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR, + FAILPROOFAI_BINARY_OVERRIDE: process.env.FAILPROOFAI_BINARY_OVERRIDE, + HERMES_HOME: process.env.HERMES_HOME, + NO_COLOR: process.env.NO_COLOR, + }; + process.env.HOME = home; + process.env.FAILPROOFAI_HOME = fpHome; + process.env.FAILPROOFAI_PACK_DIR = join(root, "packs"); + process.env.FAILPROOFAI_BINARY_OVERRIDE = join(root, "failproofai"); + process.env.HERMES_HOME = join(home, ".hermes"); + process.env.NO_COLOR = "1"; + vi.mocked(detectInstalledClis).mockReturnValue([]); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +describe("an agent traced again starts fresh", () => { + it("writes the added request before the selection that traces it", () => { + connect(); + updateConfig({ agents: { selected: ["claude"], seen: ["claude", "codex"] } }); + let requestAtSelection: Record | null = null; + const real = vi.mocked(updateConfig).getMockImplementation()!; + vi.mocked(updateConfig).mockImplementationOnce((patch) => { + requestAtSelection = pendingRequest(); + return real(patch); + }); + + expect(keepAgentSelectionTrue("installed", ["codex"])).toContain("traced again"); + + expect(requestAtSelection).toMatchObject({ kind: "added", agents: ["codex"] }); + expect(requestAtSelection).not.toHaveProperty("sinceMs"); + expect(readConfig().agents?.selected).toEqual(["claude", "codex"]); + }); + + it("asks for it through `policies --install` (installHooks with syncAgentSelection)", async () => { + connect(); + updateConfig({ agents: { selected: ["claude"], seen: ["claude"] } }); + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose"], { syncAgentSelection: true }); + expect(pendingRequest()).toMatchObject({ kind: "added", agents: ["goose"] }); + expect(readConfig().agents?.selected).toEqual(["claude", "goose"]); + }); + + it("names only the agents actually added back", () => { + connect(); + updateConfig({ agents: { selected: ["claude"], seen: ["claude"] } }); + keepAgentSelectionTrue("installed", ["claude", "goose"]); + expect(pendingRequest()).toMatchObject({ kind: "added", agents: ["goose"] }); + }); + + it("writes none when nothing collects: open source, or sessions off", () => { + updateConfig({ agents: { selected: ["claude"], seen: ["claude"] } }); + keepAgentSelectionTrue("installed", ["goose"]); + expect(pendingRequest()).toBeNull(); + + connect(/* sessions */ false); + keepAgentSelectionTrue("installed", ["codex"]); + expect(pendingRequest()).toBeNull(); + expect(readConfig().agents?.selected).toEqual(["claude", "codex", "goose"]); + }); + + it("writes none without a saved list, or for an agent already traced: nothing was untraced", () => { + connect(); + keepAgentSelectionTrue("installed", ["goose"]); + expect(pendingRequest()).toBeNull(); + updateConfig({ agents: { selected: ["goose"], seen: ["goose"] } }); + keepAgentSelectionTrue("installed", ["goose"]); + expect(pendingRequest()).toBeNull(); + }); + + it("writes none for a removal", () => { + connect(); + updateConfig({ agents: { selected: ["claude", "goose"], seen: ["claude", "goose"] } }); + keepAgentSelectionTrue("removed", ["goose"]); + expect(pendingRequest()).toBeNull(); + }); +}); + +describe("requestFreshStart and machineCollectsSessions", () => { + it("collects only with an ingest key and sessions on", () => { + expect(machineCollectsSessions()).toBe(false); + connect(false); + expect(machineCollectsSessions()).toBe(false); + connect(true); + expect(machineCollectsSessions()).toBe(true); + }); + + it("writes nothing for no agents or when nothing collects, and never throws", () => { + expect(requestFreshStart([], true)).toBe(false); + expect(requestFreshStart(["goose"], false)).toBe(false); + expect(pendingRequest()).toBeNull(); + expect(requestFreshStart(["goose"], true, 1_000)).toBe(true); + expect(pendingRequest()).toEqual({ kind: "added", agents: ["goose"], requestedAtMs: 1_000 }); + // A home that cannot hold the file: logged, not thrown. + rmSync(fpHome, { recursive: true, force: true }); + mkdirSync(join(root, "blocked")); + process.env.FAILPROOFAI_HOME = join(root, "blocked", "file-not-dir"); + writeFileSync(join(root, "blocked", "file-not-dir"), "x"); + expect(requestFreshStart(["goose"], true)).toBe(false); + }); +}); + +describe("a removal with no saved list", () => { + it("records the agents on this machine but the removed one, not every agent", () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + // Codex is not on PATH but is still hooked at user scope: still traced. + vi.spyOn(getIntegration("codex"), "hooksInstalledInSettings").mockImplementation((scope) => scope === "user"); + + expect(keepAgentSelectionTrue("removed", ["goose"])).toBe( + "✓ Goose is no longer traced, so failproofai config won't add its hooks back.", + ); + expect(readConfig().agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "goose"] }); + }); + + it("records an empty list when the removed agent was the only one", () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude"]); + keepAgentSelectionTrue("removed", ["claude"]); + expect(readConfig().agents).toEqual({ selected: [], seen: ["claude"] }); + }); +}); diff --git a/__tests__/hooks/agent-selection.test.ts b/__tests__/hooks/agent-selection.test.ts new file mode 100644 index 000000000..8f7e95ba0 --- /dev/null +++ b/__tests__/hooks/agent-selection.test.ts @@ -0,0 +1,393 @@ +// @vitest-environment node +/** + * Decision D2: commands that install or remove hooks keep `agents.selected` + * true — so `failproofai config` neither undoes an install nor resurrects a + * removal, and the daemon stops collecting an agent somebody unhooked. + * + * Real files throughout: a throwaway HOME (user-scope agent settings resolve + * through homedir()), FAILPROOFAI_HOME (config.json) and pack directory. Only + * what would reach outside the machine is stubbed: PATH detection, the pack + * fetch and telemetry. + */ +import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/integrations", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, detectInstalledClis: vi.fn(() => []) }; +}); +vi.mock("../../src/hooks/pack-store", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, addPack: vi.fn(async () => undefined) }; +}); +vi.mock("../../src/hooks/hook-telemetry", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, trackHookEvent: vi.fn(async () => undefined) }; +}); + +import { keepAgentSelectionTrue } from "../../src/hooks/agent-selection"; +import { detectInstalledClis } from "../../src/hooks/integrations"; +import { installHooks, removeHooks } from "../../src/hooks/manager"; +import { isAgentTraced, readConfig } from "../../src/hooks/fp-config"; +import { INTEGRATION_TYPES } from "../../src/hooks/types"; + +const ARTIFACT = "export const hooks = [];\n"; +const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex"); + +let root: string; +let home: string; +let fpHome: string; +let project: string; +let packRoot: string; +let saved: Record; +let logs: MockInstance; + +const configPath = () => join(fpHome, "config.json"); +const goosePlugin = (base: string) => join(base, ".agents", "plugins", "failproofai", "hooks", "hooks.json"); + +function writeSelection(agents?: { selected: string[]; seen: string[] }, extra: Record = {}): void { + writeFileSync(configPath(), `${JSON.stringify({ ...extra, ...(agents ? { agents } : {}) }, null, 2)}\n`); +} +const selectionOnDisk = () => readConfig().agents; +const printed = (): string[] => logs.mock.calls.map((call) => String(call[0])); +const lastPrinted = (): string | undefined => printed().at(-1); + +/** A pack on disk declaring `names`, so a name can resolve to a pack policy. */ +function installPack(id: string, names: string[]): void { + mkdirSync(join(packRoot, "artifacts"), { recursive: true }); + writeFileSync(join(packRoot, "artifacts", `${DIGEST}.mjs`), ARTIFACT); + const pack = { + id, + version: "1.2.0", + source: `github:${id}@v1.2.0`, + entry: `artifacts/${DIGEST}.mjs`, + sha256: DIGEST, + policies: names.map((name) => ({ name, description: `does ${name}`, category: "Finance", defaultEnabled: true, match: {} })), + }; + writeFileSync(join(packRoot, "installed.json"), JSON.stringify({ schemaVersion: 1, packs: [pack] })); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "fpai-agent-selection-")); + home = join(root, "home"); + fpHome = join(root, "fp-home"); + project = join(root, "project"); + packRoot = join(root, "packs"); + for (const dir of [home, fpHome, project, packRoot]) mkdirSync(dir, { recursive: true }); + saved = { + HOME: process.env.HOME, + FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, + FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR, + FAILPROOFAI_BINARY_OVERRIDE: process.env.FAILPROOFAI_BINARY_OVERRIDE, + NO_COLOR: process.env.NO_COLOR, + }; + process.env.HOME = home; + process.env.FAILPROOFAI_HOME = fpHome; + process.env.FAILPROOFAI_PACK_DIR = packRoot; + process.env.FAILPROOFAI_BINARY_OVERRIDE = join(root, "failproofai"); + // Plain text whatever the runner's stdout is, so a note compares as words. + process.env.NO_COLOR = "1"; + vi.mocked(detectInstalledClis).mockReturnValue([]); + logs = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +describe("keepAgentSelectionTrue — an install", () => { + it("changes nothing without a selection: every agent is traced already", () => { + expect(keepAgentSelectionTrue("installed", ["goose"])).toBeNull(); + expect(existsSync(configPath())).toBe(false); + }); + + it("traces the agent in INTEGRATION_TYPES order, and records it as seen when it is detected", () => { + writeSelection({ selected: ["goose", "claude"], seen: ["claude"] }); + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "codex"]); + expect(keepAgentSelectionTrue("installed", ["codex"])).toBe( + "✓ OpenAI Codex is traced again, so failproofai config keeps its hooks.", + ); + expect(selectionOnDisk()).toEqual({ selected: ["claude", "codex", "goose"], seen: ["claude", "codex"] }); + }); + + it("leaves seen alone for an agent that is not detected", () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + keepAgentSelectionTrue("installed", ["goose"]); + expect(selectionOnDisk()).toEqual({ selected: ["claude", "goose"], seen: ["claude"] }); + }); + + it("names several agents as one sentence, with plural verbs", () => { + writeSelection({ selected: ["claude"], seen: [] }); + expect(keepAgentSelectionTrue("installed", ["goose", "codex"])).toBe( + "✓ OpenAI Codex and Goose are traced again, so failproofai config keeps their hooks.", + ); + writeSelection({ selected: ["claude"], seen: [] }); + expect(keepAgentSelectionTrue("installed", ["goose", "cursor", "codex"])).toBe( + "✓ OpenAI Codex, Cursor Agent and Goose are traced again, so failproofai config keeps their hooks.", + ); + }); + + it("names only the agents it actually added", () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + expect(keepAgentSelectionTrue("installed", ["claude", "goose"])).toBe( + "✓ Goose is traced again, so failproofai config keeps its hooks.", + ); + }); + + it("changes nothing, and says nothing, for an agent already traced", () => { + writeSelection({ selected: ["claude", "goose"], seen: ["claude"] }); + const before = readFileSync(configPath(), "utf8"); + expect(keepAgentSelectionTrue("installed", ["goose"])).toBeNull(); + expect(readFileSync(configPath(), "utf8")).toBe(before); + }); + + it("keeps an agent id this build does not know, and every other key in the file", () => { + writeSelection({ selected: ["future-agent", "claude"], seen: ["future-agent"] }, { + collector: { sessions: true }, + fromANewerBuild: { keep: "me" }, + }); + keepAgentSelectionTrue("installed", ["goose"]); + const raw = JSON.parse(readFileSync(configPath(), "utf8")) as Record; + expect(raw.agents).toEqual({ selected: ["claude", "goose", "future-agent"], seen: ["future-agent"] }); + expect(raw.fromANewerBuild).toEqual({ keep: "me" }); + expect(readConfig().collector.sessions).toBe(true); + }); +}); + +describe("keepAgentSelectionTrue — a removal", () => { + it("stops tracing the agent and keeps seen as it was", () => { + writeSelection({ selected: ["claude", "goose"], seen: ["claude", "goose"] }); + expect(keepAgentSelectionTrue("removed", ["goose"])).toBe( + "✓ Goose is no longer traced, so failproofai config won't add its hooks back.", + ); + expect(selectionOnDisk()).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); + + it("without a selection, writes the agents on this machine but the removed ones, and seen as detected now", () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + expect(keepAgentSelectionTrue("removed", ["goose"])).toBe( + "✓ Goose is no longer traced, so failproofai config won't add its hooks back.", + ); + // Not every agent but Goose: `failproofai config` restores a saved list as + // it stands, and would hook all of them, installed or not. + expect(selectionOnDisk()).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + expect(isAgentTraced("goose")).toBe(false); + expect(isAgentTraced("claude")).toBe(true); + }); + + it("allows an empty selection once every agent's hooks are out", () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + keepAgentSelectionTrue("removed", ["claude"]); + expect(selectionOnDisk()).toEqual({ selected: [], seen: ["claude"] }); + expect(isAgentTraced("claude")).toBe(false); + + // And from no selection at all, removing every agent. + rmSync(configPath()); + expect(keepAgentSelectionTrue("removed", [...INTEGRATION_TYPES])).toMatch(/^✓ .* are no longer traced/); + expect(selectionOnDisk()?.selected).toEqual([]); + }); + + it("names several agents as one sentence, with plural verbs", () => { + writeSelection({ selected: ["claude", "codex", "goose"], seen: [] }); + expect(keepAgentSelectionTrue("removed", ["goose", "claude"])).toBe( + "✓ Claude Code and Goose are no longer traced, so failproofai config won't add their hooks back.", + ); + expect(selectionOnDisk()?.selected).toEqual(["codex"]); + }); + + it("changes nothing, and says nothing, for an agent not traced", () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + const before = readFileSync(configPath(), "utf8"); + expect(keepAgentSelectionTrue("removed", ["goose"])).toBeNull(); + expect(readFileSync(configPath(), "utf8")).toBe(before); + }); + + it("says when the selection could not be written, instead of throwing", () => { + // A home under a regular file: nothing can be created inside it. + writeFileSync(join(root, "a-file"), ""); + process.env.FAILPROOFAI_HOME = join(root, "a-file", "fp-home"); + const line = keepAgentSelectionTrue("removed", ["goose"]); + expect(line).toMatch(/^▲ Couldn't record Goose as no longer traced \(.+\), so failproofai config may add its hooks back\.$/); + }); +}); + +describe("policies --install / policies add / the agent picker", () => { + // bin/failproofai.mjs resolves the agents (from --cli or the picker in + // install-prompt.ts) and hands them to installHooks with syncAgentSelection; + // these are those calls. + it("`policies --install --cli goose` traces Goose and says so after everything else", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose"], { syncAgentSelection: true }); + expect(existsSync(goosePlugin(home))).toBe(true); + expect(selectionOnDisk()?.selected).toEqual(["claude", "goose"]); + expect(lastPrinted()).toBe("✓ Goose is traced again, so failproofai config keeps its hooks."); + expect(printed().filter((line) => line.includes("traced"))).toHaveLength(1); + }); + + it("counts a project-scope install too", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + await installHooks(undefined, "project", project, false, undefined, undefined, false, ["goose"], { syncAgentSelection: true }); + expect(existsSync(goosePlugin(project))).toBe(true); + expect(selectionOnDisk()?.selected).toEqual(["claude", "goose"]); + }); + + it("several agents from the picker get one line", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose", "codex"], { syncAgentSelection: true }); + expect(selectionOnDisk()?.selected).toEqual(["claude", "codex", "goose"]); + expect(lastPrinted()).toBe("✓ OpenAI Codex and Goose are traced again, so failproofai config keeps their hooks."); + }); + + it("`policies add --cli goose` traces Goose", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + await installHooks(["block-sudo"], "user", project, false, undefined, undefined, false, ["goose"], { syncAgentSelection: true }); + expect(selectionOnDisk()?.selected).toEqual(["claude", "goose"]); + expect(lastPrinted()).toBe("✓ Goose is traced again, so failproofai config keeps its hooks."); + }); + + it("a third-party pack policy wires no hooks, so it traces nothing", async () => { + installPack("acme/finance", ["block-big-refund"]); + writeSelection({ selected: ["claude"], seen: ["claude"] }); + const before = readFileSync(configPath(), "utf8"); + await installHooks(["block-big-refund"], "user", project, false, undefined, undefined, false, ["goose"], { syncAgentSelection: true }); + expect(existsSync(goosePlugin(home))).toBe(false); + expect(readFileSync(configPath(), "utf8")).toBe(before); + expect(printed().some((line) => line.includes("traced"))).toBe(false); + }); + + it("without a selection there is nothing to add, and nothing is printed about it", async () => { + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose"], { syncAgentSelection: true }); + expect(existsSync(configPath())).toBe(false); + expect(printed().some((line) => line.includes("traced"))).toBe(false); + }); + + it("a quiet install still keeps the selection true, and prints nothing", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose"], { + quiet: true, + syncAgentSelection: true, + }); + expect(selectionOnDisk()?.selected).toEqual(["claude", "goose"]); + expect(logs).not.toHaveBeenCalled(); + }); + + it("a caller that does not ask leaves the selection alone", async () => { + writeSelection({ selected: ["claude"], seen: ["claude"] }); + const before = readFileSync(configPath(), "utf8"); + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose"]); + expect(existsSync(goosePlugin(home))).toBe(true); + expect(readFileSync(configPath(), "utf8")).toBe(before); + }); +}); + +describe("policies --uninstall / policies remove all", () => { + async function hookGoose(scope: "user" | "project"): Promise { + await installHooks(undefined, scope, project, false, undefined, undefined, false, ["goose"]); + logs.mockClear(); + } + + it("`policies --uninstall --cli goose` stops tracing Goose and says so after everything else", async () => { + writeSelection({ selected: ["claude", "goose"], seen: ["claude", "goose"] }); + await hookGoose("user"); + await removeHooks(undefined, "user", project, { cli: ["goose"], syncAgentSelection: true }); + expect(selectionOnDisk()).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + expect(printed()[0]).toContain("Removed"); + expect(lastPrinted()).toBe("✓ Goose is no longer traced, so failproofai config won't add its hooks back."); + }); + + it("`--scope all` counts", async () => { + writeSelection({ selected: ["claude", "goose"], seen: [] }); + await hookGoose("user"); + await removeHooks(undefined, "all", project, { cli: ["goose"], syncAgentSelection: true }); + expect(selectionOnDisk()?.selected).toEqual(["claude"]); + expect(lastPrinted()).toBe("✓ Goose is no longer traced, so failproofai config won't add its hooks back."); + }); + + it("without a selection, writes one that traces the agents on this machine but the one removed", async () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + await hookGoose("user"); + await removeHooks(undefined, "user", project, { cli: ["goose"], syncAgentSelection: true }); + expect(selectionOnDisk()).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); + + it("`policies remove all --cli goose` takes the hooks out, so it stops tracing too", async () => { + writeSelection({ selected: ["claude", "goose"], seen: [] }); + await hookGoose("user"); + await removeHooks(["all"], "user", project, { cli: ["goose"], syncAgentSelection: true }); + expect(selectionOnDisk()?.selected).toEqual(["claude"]); + }); + + it("an agent with nothing to remove still stops being traced: the command said so", async () => { + writeSelection({ selected: ["claude", "goose"], seen: [] }); + await removeHooks(undefined, "user", project, { cli: ["goose"], syncAgentSelection: true }); + expect(printed()).toEqual([ + "✓ No settings file found, so there is nothing to remove.", + "✓ Goose is no longer traced, so failproofai config won't add its hooks back.", + ]); + expect(selectionOnDisk()?.selected).toEqual(["claude"]); + }); + + it("a project-scope-only removal leaves the selection alone", async () => { + writeSelection({ selected: ["claude", "goose"], seen: ["goose"] }); + await hookGoose("project"); + const before = readFileSync(configPath(), "utf8"); + await removeHooks(undefined, "project", project, { cli: ["goose"], syncAgentSelection: true }); + expect(existsSync(goosePlugin(project)) && JSON.parse(readFileSync(goosePlugin(project), "utf8")).hooks).toBeFalsy(); + expect(readFileSync(configPath(), "utf8")).toBe(before); + expect(printed().some((line) => line.includes("traced"))).toBe(false); + }); + + it("a project-scope-only removal writes no selection where there was none", async () => { + await hookGoose("project"); + await removeHooks(undefined, "project", project, { cli: ["goose"], syncAgentSelection: true }); + expect(existsSync(configPath())).toBe(false); + }); + + it("a local-scope removal leaves the selection alone", async () => { + writeSelection({ selected: ["claude"], seen: [] }); + const before = readFileSync(configPath(), "utf8"); + await removeHooks(undefined, "local", project, { cli: ["claude"], syncAgentSelection: true }); + expect(readFileSync(configPath(), "utf8")).toBe(before); + }); + + it("turning a policy off removes no hooks, so it leaves the selection alone", async () => { + writeSelection({ selected: ["claude", "goose"], seen: [] }); + const before = readFileSync(configPath(), "utf8"); + await removeHooks(["block-sudo"], "user", project, { cli: ["goose"], syncAgentSelection: true }); + expect(readFileSync(configPath(), "utf8")).toBe(before); + }); +}); + +describe("the commands that own or ignore the selection", () => { + it("`failproofai config`'s own installHooks call writes no selection and prints no note", async () => { + // Exactly the wizard's call (configure-wizard.ts): it writes the selection + // itself, after this, from the answer it just collected. + writeSelection({ selected: ["claude"], seen: ["claude"] }); + const before = readFileSync(configPath(), "utf8"); + await installHooks([], "user", project, false, "configure-wizard", undefined, false, ["goose"], { replace: true, quiet: true }); + expect(existsSync(goosePlugin(home))).toBe(true); + expect(readFileSync(configPath(), "utf8")).toBe(before); + expect(logs).not.toHaveBeenCalled(); + }); + + it("`failproofai uninstall` leaves the selection alone", async () => { + writeSelection({ selected: ["claude", "goose"], seen: ["goose"] }); + await installHooks(undefined, "user", project, false, undefined, undefined, false, ["goose"]); + const before = readFileSync(configPath(), "utf8"); + // The shape of uninstall-cli.ts's call, minus its cosmetic `silent`. This + // proves removeHooks keeps the selection for those options; that the real + // command still passes them is pinned in uninstall-cli.test.ts. + await removeHooks(undefined, "all", project, { cli: ["goose"], removeCustomHooks: true, source: "uninstall_command" }); + expect(JSON.parse(readFileSync(goosePlugin(home), "utf8")).hooks).toBeUndefined(); + expect(readFileSync(configPath(), "utf8")).toBe(before); + }); +}); diff --git a/__tests__/hooks/backfill-cli.test.ts b/__tests__/hooks/backfill-cli.test.ts new file mode 100644 index 000000000..b09fb8578 --- /dev/null +++ b/__tests__/hooks/backfill-cli.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +vi.mock("../../src/hooks/daemon-service", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isDaemonSupportedPlatform: vi.fn(() => true), + daemonServiceStatus: vi.fn(() => "running"), + daemonVersionSkew: vi.fn(() => null), + }; +}); + +import { runBackfillCommand } from "../../src/hooks/backfill-cli"; +import { backfillRequestPath } from "../../src/hooks/backfill-request"; +import { writeCollectorSettings, writeIngestCredential } from "../../src/hooks/collector-config"; +import { updateConfig } from "../../src/hooks/fp-config"; +import { daemonServiceStatus, daemonVersionSkew } from "../../src/hooks/daemon-service"; +import type { IntegrationType } from "../../src/hooks/types"; + +const NOW = Date.parse("2026-10-10T12:00:00Z"); +const DAY = 24 * 60 * 60 * 1000; +const SINCE = NOW - 30 * DAY; + +let home: string; +let prev: string | undefined; +beforeEach(() => { + prev = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-backfill-cli-")); + process.env.FAILPROOFAI_HOME = home; + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "k".repeat(20) }); + writeCollectorSettings({ sessions: true, hooks: true }); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + vi.mocked(daemonVersionSkew).mockReturnValue(null); +}); +afterEach(() => { + if (prev === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prev; + rmSync(home, { recursive: true, force: true }); +}); + +const sessions: Partial> = { claude: 412, codex: 96, opencode: 31, goose: 7, hermes: 3 }; +const countSessions = vi.fn(async (cli: IntegrationType) => sessions[cli] ?? 0); +const run = (opts: Parameters[0] = {}) => + runBackfillCommand({ now: NOW, sinceMs: SINCE, countSessions, ...opts }); +const pending = () => JSON.parse(readFileSync(backfillRequestPath(), "utf8")); + +describe("failproofai backfill — traced agents only", () => { + it("dry run: counts the traced agents and names the ones skipped", async () => { + updateConfig({ agents: { selected: ["claude", "codex", "opencode"], seen: [] } }); + const { exitCode, lines } = await run({ dryRun: true }); + const text = lines.join("\n"); + expect(exitCode).toBe(0); + expect(lines[0]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Backfill$/); + expect(text).toContain("WOULD RE-SEND since 2026-09-10, traced agents only"); + expect(text).toMatch(/Claude Code +412 sessions/); + expect(text).toMatch(/OpenAI Codex +96 sessions/); + // A database-backed agent has no window: it is all of it, and says so. + expect(text).toMatch(/OpenCode +all 31 sessions, whatever the window/); + expect(text).toContain("Not traced, skipped: GitHub Copilot, Cursor Agent, Pi, Hermes, OpenClaw, Factory Droid, Devin CLI, Antigravity CLI, Goose"); + expect(text).toContain("Run it for real with failproofai backfill"); + expect(existsSync(backfillRequestPath())).toBe(false); + }); + + it("says Hermes is not re-sent rather than counting what will not move", async () => { + updateConfig({ agents: { selected: ["hermes"], seen: [] } }); + const text = (await run({ dryRun: true })).lines.join("\n"); + expect(text).toMatch(/Hermes +not re-sent: its sessions have no time window yet/); + }); + + it("asks the daemon for every traced agent without naming them", async () => { + updateConfig({ agents: { selected: ["claude", "codex"], seen: [] } }); + const { exitCode, lines } = await run(); + expect(exitCode).toBe(0); + expect(lines.join("\n")).toContain("✓ Asked the daemon to re-send 508 sessions from 2 traced agents since 2026-09-10."); + expect(lines.join("\n")).toContain("They reach the dashboard over the next few minutes."); + expect(pending()).toEqual({ kind: "user", sinceMs: SINCE, requestedAtMs: NOW }); + }); + + it("names the agents when --agents narrows it", async () => { + updateConfig({ agents: { selected: ["claude", "codex"], seen: [] } }); + await run({ agents: ["codex"] }); + expect(pending()).toEqual({ kind: "user", sinceMs: SINCE, requestedAtMs: NOW, agents: ["codex"] }); + }); + + it("refuses an untraced agent and writes nothing", async () => { + updateConfig({ agents: { selected: ["claude", "codex"], seen: [] } }); + const { exitCode, lines } = await run({ agents: ["goose"] }); + expect(exitCode).toBe(1); + expect(lines[0]).toBe("✕ Goose isn't traced, so it can't be backfilled."); + expect(lines[1]).toBe(" Trace it first with failproofai config or failproofai config --agents claude,codex,goose"); + expect(existsSync(backfillRequestPath())).toBe(false); + }); + + it("refuses a name that is not an agent", async () => { + const { exitCode, lines } = await run({ agents: ["claud"] }); + expect(exitCode).toBe(1); + expect(lines[0]).toBe("✕ Not an agent: claud."); + expect(existsSync(backfillRequestPath())).toBe(false); + }); + + it("refuses to name agents to a daemon of another version, which would re-send them all", async () => { + vi.mocked(daemonVersionSkew).mockReturnValue({ installed: "1.0.9", expected: "1.0.11" }); + const { exitCode, lines } = await run({ agents: ["claude"] }); + expect(exitCode).toBe(1); + expect(lines.join("\n")).toContain("failproofai update"); + expect(existsSync(backfillRequestPath())).toBe(false); + // A plain backfill proceeds: it means "everything" on both versions. + expect((await run()).exitCode).toBe(0); + }); + + it("traces every agent when there is no saved selection", async () => { + const text = (await run({ dryRun: true })).lines.join("\n"); + expect(text).not.toContain("Not traced, skipped"); + expect(text).toMatch(/Goose +all 7 sessions/); + }); + + it("says a stopped daemon holds the request, instead of when it lands", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("stopped"); + const text = (await run()).lines.join("\n"); + expect(text).toContain("▲ failproofaid is stopped, so nothing moves until it starts. The request waits for it."); + expect(text).not.toContain("over the next few minutes"); + }); + + it("re-sends only hook decisions when session collection is off, and says so", async () => { + writeCollectorSettings({ sessions: false, hooks: true }); + const text = (await run()).lines.join("\n"); + expect(text).toContain("✓ Asked the daemon to re-send hook decisions since 2026-09-10."); + expect(text).toContain("Session transcripts are not sent: session collection is off."); + }); + + it("points an unconnected machine at config, never at a key on the command line", async () => { + rmSync(home, { recursive: true, force: true }); + home = mkdtempSync(resolve(tmpdir(), "fpai-backfill-cli-")); + process.env.FAILPROOFAI_HOME = home; + const { exitCode, lines } = await run(); + expect(exitCode).toBe(1); + expect(lines).toEqual([ + "✕ This machine isn't connected, so there is nowhere to send history.", + " Connect it: failproofai config", + ]); + }); +}); + +describe("failproofai backfill --dry-run — the command it hands over", () => { + // The preview showed `since 2026-04-13` and then suggested a bare + // `failproofai backfill`, which re-sends the default 30 days instead. + const line = (lines: string[]) => lines.find((l) => l.includes("Run it for real")) ?? ""; + + it("keeps --since as it was typed", async () => { + const r = await run({ dryRun: true, sinceMs: NOW - 180 * DAY, since: "6m" }); + expect(line(r.lines)).toBe("Run it for real with failproofai backfill --since 6m"); + }); + + it("keeps --since and --agents together", async () => { + const r = await run({ dryRun: true, sinceMs: NOW - 365 * DAY, since: "1y", agents: ["codex", "claude"] }); + expect(line(r.lines)).toBe("Run it for real with failproofai backfill --since 1y --agents claude,codex"); + }); + + it("gives the window as its start date when the typed form is not known", async () => { + const r = await run({ dryRun: true, sinceMs: Date.parse("2026-04-13T00:00:00Z") }); + expect(line(r.lines)).toBe("Run it for real with failproofai backfill --since 2026-04-13"); + }); + + it("quotes a typed date that holds spaces, so the line runs as printed", async () => { + const r = await run({ dryRun: true, sinceMs: Date.parse("April 13 2026"), since: "April 13 2026" }); + expect(line(r.lines)).toBe('Run it for real with failproofai backfill --since "April 13 2026"'); + }); + + it("adds no --since when none was given", async () => { + const r = await runBackfillCommand({ now: NOW, countSessions, dryRun: true }); + expect(line(r.lines)).toBe("Run it for real with failproofai backfill"); + }); +}); diff --git a/__tests__/hooks/backfill-request.test.ts b/__tests__/hooks/backfill-request.test.ts new file mode 100644 index 000000000..ceee2fb4c --- /dev/null +++ b/__tests__/hooks/backfill-request.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, statSync, mkdirSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; + +vi.mock("../../src/hooks/daemon-service", async (importOriginal) => { + const actual = await importOriginal(); + // The result screen asks systemd whether the daemon runs; never reach it. + return { ...actual, isDaemonSupportedPlatform: vi.fn(() => false), daemonServiceStatus: vi.fn(() => "running") }; +}); + +import { + ADDED_REQUEST_MAX_AGE_MS, + backfillRequestPath, + mergeBackfillRequests, + readPendingRequest, + writeBackfillRequest, + type BackfillRequest, +} from "../../src/hooks/backfill-request"; +import { runBackfillCommand } from "../../src/hooks/backfill-cli"; +import { writeCollectorSettings, writeIngestCredential } from "../../src/hooks/collector-config"; + +const NOW = 1_800_000_000_000; +const DAY = 24 * 60 * 60 * 1000; + +let home: string; +let prev: string | undefined; +beforeEach(() => { + prev = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-backfill-")); + process.env.FAILPROOFAI_HOME = home; +}); +afterEach(() => { + if (prev === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prev; + rmSync(home, { recursive: true, force: true }); +}); + +describe("readPendingRequest — read the way the daemon reads it", () => { + it("reads a request with no kind as a user request, as older CLIs wrote it", () => { + expect(readPendingRequest({ sinceMs: 5, requestedAtMs: 6 }, NOW)).toEqual({ kind: "user", sinceMs: 5, requestedAtMs: 6 }); + }); + + it("drops what the daemon would drop", () => { + expect(readPendingRequest({ kind: "user" }, NOW)).toBeNull(); // no window + expect(readPendingRequest({ kind: "added", requestedAtMs: NOW }, NOW)).toBeNull(); // no agents + expect(readPendingRequest({ kind: "added", agents: ["goose"] }, NOW)).toBeNull(); // no age + expect(readPendingRequest({ kind: "later", sinceMs: 1 }, NOW)).toBeNull(); // unknown kind + expect(readPendingRequest({ sinceMs: 1, agents: "claude" }, NOW)).toBeNull(); // not a list + expect(readPendingRequest([], NOW)).toBeNull(); + }); + + it("drops an added request older than the daemon would act on", () => { + const old = { kind: "added", agents: ["goose"], requestedAtMs: NOW - ADDED_REQUEST_MAX_AGE_MS - 1 }; + expect(readPendingRequest(old, NOW)).toBeNull(); + expect(readPendingRequest({ ...old, requestedAtMs: NOW - 1000 }, NOW)).toMatchObject({ kind: "added" }); + }); +}); + +describe("mergeBackfillRequests — the stronger request wins", () => { + const added = (agents: string[]): BackfillRequest => ({ kind: "added", requestedAtMs: NOW, agents }); + const user = (sinceMs: number, agents?: string[]): BackfillRequest => ({ + kind: "user", + sinceMs, + requestedAtMs: NOW, + ...(agents ? { agents } : {}), + }); + + it("is the new request when nothing is pending", () => { + expect(mergeBackfillRequests(null, added(["goose"]))).toEqual(added(["goose"])); + }); + + it("unions two added requests", () => { + expect(mergeBackfillRequests(added(["goose"]), added(["hermes", "goose"]))).toEqual(added(["goose", "hermes"])); + }); + + it("keeps a pending user request a user request, with its window", () => { + expect(mergeBackfillRequests(user(NOW - 30 * DAY, ["claude"]), added(["goose"]))).toEqual( + user(NOW - 30 * DAY, ["claude", "goose"]), + ); + }); + + it("turns a pending added request into the user request that follows it", () => { + expect(mergeBackfillRequests(added(["goose"]), user(NOW - DAY, ["claude"]))).toEqual(user(NOW - DAY, ["goose", "claude"])); + }); + + it("keeps the widest window, and no list when either side reaches every agent", () => { + expect(mergeBackfillRequests(user(NOW - 30 * DAY), user(NOW - DAY, ["claude"]))).toEqual(user(NOW - 30 * DAY)); + expect(mergeBackfillRequests(user(NOW - DAY), added(["goose"]))).toEqual(user(NOW - DAY)); + }); +}); + +describe("writeBackfillRequest", () => { + it("writes the request owner-only, where the daemon looks", () => { + writeBackfillRequest({ kind: "added", requestedAtMs: NOW, agents: ["goose"] }); + const path = backfillRequestPath(); + expect(path).toBe(resolve(home, "state", "backfill-request.json")); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ kind: "added", requestedAtMs: NOW, agents: ["goose"] }); + expect(statSync(path).mode & 0o077).toBe(0); + }); + + it("merges with a request still pending instead of replacing it", () => { + const path = backfillRequestPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ sinceMs: NOW - 30 * DAY, requestedAtMs: NOW - DAY })); + const written = writeBackfillRequest({ kind: "added", requestedAtMs: NOW, agents: ["goose"] }); + expect(written).toEqual({ kind: "user", sinceMs: NOW - 30 * DAY, requestedAtMs: NOW }); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(written); + }); + + it("replaces a pending file the daemon could not read anyway", () => { + const path = backfillRequestPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{not json"); + expect(writeBackfillRequest({ kind: "added", requestedAtMs: NOW, agents: ["goose"] })).toEqual({ + kind: "added", + requestedAtMs: NOW, + agents: ["goose"], + }); + }); + + it("never puts a window on an added request, which an older daemon would replay for every agent", () => { + writeBackfillRequest({ kind: "added", requestedAtMs: NOW, agents: ["goose"] }); + expect(JSON.parse(readFileSync(backfillRequestPath(), "utf8"))).not.toHaveProperty("sinceMs"); + }); +}); + +describe("failproofai backfill writes through the shared writer", () => { + it("writes a user request with its window", async () => { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "k".repeat(20) }); + writeCollectorSettings({ sessions: true, hooks: true }); + const result = await runBackfillCommand({ now: NOW, sinceMs: NOW - 7 * DAY, countSessions: async () => 0 }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(readFileSync(backfillRequestPath(), "utf8"))).toEqual({ + kind: "user", + sinceMs: NOW - 7 * DAY, + requestedAtMs: NOW, + }); + }); + + it("writes nothing on a dry run, or on a machine that is not connected", async () => { + expect((await runBackfillCommand({ now: NOW })).exitCode).toBe(1); + expect(existsSync(backfillRequestPath())).toBe(false); + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "k".repeat(20) }); + writeCollectorSettings({ sessions: true, hooks: true }); + expect((await runBackfillCommand({ now: NOW, dryRun: true, countSessions: async () => 0 })).exitCode).toBe(0); + expect(existsSync(backfillRequestPath())).toBe(false); + }); +}); diff --git a/__tests__/hooks/cloud-enrollment-cli.test.ts b/__tests__/hooks/cloud-enrollment-cli.test.ts index 00c11c94f..bfe7f3e65 100644 --- a/__tests__/hooks/cloud-enrollment-cli.test.ts +++ b/__tests__/hooks/cloud-enrollment-cli.test.ts @@ -428,16 +428,16 @@ describe("--disconnect means disconnect", () => { expect(r.lines.join("\n")).toMatch(/No new hook activity or transcripts will be queued/); }); - it("names the restart rather than claiming a running daemon already stopped", async () => { - // It used to print "Hook activity and transcripts stop being sent", which - // was not true yet: the collector manager starts once for the daemon's - // lifetime (`main.rs`) and the uploader caches its bearer key at - // construction, so a running failproofaid never notices the credential - // file disappear. The claim only became true at the next daemon start. + it("says a running daemon stops on its own, without sending anyone to restart it", async () => { + // It once said "restart it to stop the current process sending", true when + // the collector manager started once per daemon lifetime and cached the + // key. The manager now polls the collector config, which includes the + // ingest credential, and stops the collector within one interval once it + // is gone, so the restart advice sent people to do work that was not needed. await runConnectCommand({ ...base, machineId: "m-1" }); const text = runDisconnectCommand().lines.join("\n"); - expect(text).toMatch(/restart it to stop the current process/); - expect(text).not.toMatch(/transcripts stop being sent/); + expect(text).toMatch(/stops sending within a few seconds/); + expect(text).not.toMatch(/restart it/); }); it("stops ENFORCING cloud-managed policies, not just refreshing them", async () => { diff --git a/__tests__/hooks/cloud-key-prompt.test.ts b/__tests__/hooks/cloud-key-prompt.test.ts new file mode 100644 index 000000000..3d47cb0a3 --- /dev/null +++ b/__tests__/hooks/cloud-key-prompt.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { PassThrough } from "node:stream"; +import { collapseAfter, promptCloudKey, OPEN_SOURCE, type KeyVerdict, type TTYIn, type TTYOut } from "../../src/hooks/tui"; + +// Driven through a real PassThrough so Node's own keypress parser runs: the +// paste guard depends on the `paste-start` / `paste-end` keys it emits. +function terminal(columns = 80) { + const written: string[] = []; + const stdout = { + isTTY: true, + columns, + write: (chunk: string) => { + written.push(String(chunk)); + return true; + }, + } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (on: boolean) => void }).setRawMode = () => {}; + const raw = () => written.join(""); + const plain = () => raw().replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); + return { stdin, stdout, written, raw, plain, type: (s: string) => stdin.write(s) }; +} + +const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms)); +const KEY = "fpk_live_0123456789abcdef"; + +function ask(t: ReturnType, extra: Partial[0]> = {}) { + const check = vi.fn(async (): Promise => ({ ok: true })); + const pending = promptCloudKey({ + message: "Connect to cloud", + meta: "app.befailproof.ai", + tabHint: "use open source instead", + emptyError: "Paste a key, or press tab to use open source.", + check, + collapsed: (answer) => ["CONNECT TO CLOUD", answer === OPEN_SOURCE ? "open source" : `answered ${answer.kind}`], + stdin: t.stdin, + stdout: t.stdout, + ...extra, + }); + return { pending, check: (extra.check as typeof check | undefined) ?? check }; +} + +afterEach(() => { + delete process.env.NO_COLOR; +}); + +describe("promptCloudKey", () => { + it("draws the heading with the host, the field and the hints, as designed", async () => { + const t = terminal(); + const { pending } = ask(t); + await tick(); + expect(t.plain()).toContain("CONNECT TO CLOUD app.befailproof.ai"); + expect(t.plain()).toContain(" API key › "); + expect(t.plain()).toContain("enter connect · tab use open source instead · esc cancel"); + t.type("\u001b"); + expect(await pending).toBeNull(); + }); + + it("checks a typed key and resolves it, never printing the key itself", async () => { + const t = terminal(); + const { pending, check } = ask(t); + t.type(KEY); + t.type("\r"); + const answer = await pending; + expect(check).toHaveBeenCalledWith(KEY); + expect(answer).toEqual({ kind: "typed", key: KEY, note: undefined }); + expect(t.raw()).not.toContain(KEY); + expect(t.plain()).toContain("•".repeat(KEY.length)); + }); + + it("carries a partial key's note through to the answer", async () => { + const t = terminal(); + const check = vi.fn(async (): Promise => ({ ok: true, line: "This key can send events but not pull policies." })); + const { pending } = ask(t, { check }); + t.type(KEY + "\r"); + expect(await pending).toEqual({ kind: "typed", key: KEY, note: "This key can send events but not pull policies." }); + }); + + it("resolves open source on tab", async () => { + const t = terminal(); + const { pending, check } = ask(t); + t.type("\t"); + expect(await pending).toBe(OPEN_SOURCE); + expect(check).not.toHaveBeenCalled(); + }); + + it("treats a tab or newline inside a paste as part of the paste", async () => { + const t = terminal(); + const { pending, check } = ask(t); + // A key copied with a stray tab and a trailing newline: neither may switch + // to open source or submit halfway through the paste. + t.type(`\u001b[200~fpk_live_\t0123456789abcdef\r\u001b[201~`); + await tick(20); + expect(check).not.toHaveBeenCalled(); + t.type("\r"); + expect(await pending).toEqual({ kind: "typed", key: KEY, note: undefined }); + }); + + it("refuses a short key inline without asking the server", async () => { + const t = terminal(); + const { pending, check } = ask(t); + t.type("abc\r"); + await tick(20); + expect(check).not.toHaveBeenCalled(); + expect(t.plain()).toContain("✕ That looks too short to be a key."); + t.type("\u001b"); + await pending; + }); + + it("asks for a key or tab on an empty enter with no saved key", async () => { + const t = terminal(); + const { pending, check } = ask(t); + t.type("\r"); + await tick(20); + expect(check).not.toHaveBeenCalled(); + expect(t.plain()).toContain("✕ Paste a key, or press tab to use open source."); + t.type("\u001b"); + await pending; + }); + + it("keeps a refused key in the field, says why, and retries on enter", async () => { + const t = terminal(); + const check = vi + .fn<(key: string) => Promise>() + .mockResolvedValueOnce({ ok: false, line: "That key was refused: the server rejected that key (401)." }) + .mockResolvedValueOnce({ ok: true }); + const { pending } = ask(t, { check }); + t.type(KEY + "\r"); + await tick(20); + const screen = t.plain(); + expect(screen).toContain("✕ That key was refused: the server rejected that key (401)."); + expect(screen).toContain("enter try again · tab use open source instead · esc cancel"); + t.type("\r"); + expect(await pending).toEqual({ kind: "typed", key: KEY, note: undefined }); + expect(check).toHaveBeenCalledTimes(2); + }); + + it("ignores keys while a typed key is being checked, but esc still leaves", async () => { + const t = terminal(); + let release: (v: KeyVerdict) => void = () => {}; + const check = vi.fn(() => new Promise((r) => (release = r))); + const { pending } = ask(t, { check }); + t.type(KEY + "\r"); + await tick(20); + t.type("\t"); // would be open source if it were not ignored + await tick(20); + t.type("\u001b"); + expect(await pending).toBeNull(); + release({ ok: true }); + }); + + it("spins only once a check has taken longer than 150 ms", async () => { + const t = terminal(); + let release: (v: KeyVerdict) => void = () => {}; + const check = vi.fn(() => new Promise((r) => (release = r))); + const { pending } = ask(t, { check }); + t.type(KEY + "\r"); + await tick(60); + expect(t.plain()).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Checking the key…/); + await tick(300); + expect(t.plain()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Checking the key…/); + release({ ok: true }); + await pending; + }); + + describe("with a saved key", () => { + it("shows a working saved key and keeps it on an empty enter", async () => { + const t = terminal(); + const saved = { masked: "****3f2a", check: vi.fn(async (): Promise => ({ ok: true })) }; + const { pending, check } = ask(t, { saved }); + await tick(20); + expect(t.plain()).toContain("✓ Your saved key works: ****3f2a"); + expect(t.plain()).toContain("enter use saved key · type to replace it · tab use open source instead"); + t.type("\r"); + expect(await pending).toEqual({ kind: "saved", verified: true }); + expect(check).not.toHaveBeenCalled(); + }); + + it("waits for a check still in flight before keeping the saved key", async () => { + const t = terminal(); + let release: (v: KeyVerdict) => void = () => {}; + const saved = { masked: "****3f2a", check: () => new Promise((r) => (release = r)) }; + const { pending } = ask(t, { saved }); + await tick(10); + expect(t.plain()).toContain("Checking your saved key ****3f2a…"); + t.type("\r"); + await tick(20); + let settled = false; + void pending.then(() => (settled = true)); + await tick(20); + expect(settled).toBe(false); + release({ ok: true }); + expect(await pending).toEqual({ kind: "saved", verified: true }); + }); + + it("treats a refused saved key as no saved key at all", async () => { + const t = terminal(); + const saved = { + masked: "****3f2a", + check: async (): Promise => ({ ok: false, line: "Your saved key no longer works: the server rejected that key (401)" }), + }; + const { pending } = ask(t, { saved }); + await tick(20); + expect(t.plain()).toContain("▲ Your saved key no longer works: the server rejected that key (401)"); + t.type("\r"); + await tick(20); + expect(t.plain()).toContain("✕ Paste a key, or press tab to use open source."); + t.type("\u001b"); + expect(await pending).toBeNull(); + }); + + it("still offers a saved key that could not be checked, unverified", async () => { + const t = terminal(); + const saved = { + masked: "****3f2a", + check: async (): Promise => ({ ok: false, usable: true, line: "Couldn't check your saved key: could not reach the server" }), + }; + const { pending } = ask(t, { saved }); + await tick(20); + expect(t.plain()).toContain("▲ Couldn't check your saved key: could not reach the server"); + t.type("\r"); + expect(await pending).toEqual({ kind: "saved", verified: false }); + }); + + it("replaces the saved key when a new one is typed", async () => { + const t = terminal(); + const saved = { masked: "****3f2a", check: async (): Promise => ({ ok: true }) }; + const { pending, check } = ask(t, { saved }); + await tick(20); + t.type(KEY + "\r"); + expect(await pending).toEqual({ kind: "typed", key: KEY, note: undefined }); + expect(check).toHaveBeenCalledWith(KEY); + }); + }); + + it("puts the terminal back: paste mode off and the cursor shown", async () => { + const t = terminal(); + const { pending } = ask(t); + await tick(); + expect(t.raw()).toContain("\u001b[?2004h"); + t.type("\u001b"); + await pending; + const tail = t.written.slice(-3).join(""); + expect(tail).toContain("\u001b[?2004l"); + expect(tail).toContain("\u001b[?25h"); + }); + + it("collapses to the caller's lines", async () => { + const t = terminal(); + const { pending } = ask(t); + t.type("\t"); + await pending; + const lastFrame = t.written.filter((w) => w.includes("\u001b[?2026h")).pop() ?? ""; + expect(lastFrame).toContain("CONNECT TO CLOUD"); + expect(lastFrame).toContain("open source"); + expect(lastFrame).not.toContain("API key"); + }); + + it("answers null without a terminal instead of waiting on a pipe", async () => { + const t = terminal(); + (t.stdin as unknown as { isTTY: boolean }).isTTY = false; + expect(await promptCloudKey({ message: "x", tabHint: "skip", emptyError: "e", check: async () => ({ ok: true }), stdin: t.stdin, stdout: t.stdout })).toBeNull(); + expect(t.written).toEqual([]); + }); + + it.each([true, false])("prints colour only when colour is on (color=%s)", async (color) => { + if (!color) process.env.NO_COLOR = "1"; + else process.env.FORCE_COLOR = "3"; + try { + const t = terminal(); + const { pending } = ask(t); + t.type("ab\r"); + await tick(20); + t.type("\u001b"); + await pending; + const sgr = /\x1b\[[0-9;]*m/.test(t.raw()); + expect(sgr).toBe(color); + } finally { + delete process.env.FORCE_COLOR; + } + }); +}); + +describe("collapseAfter", () => { + it("reserves rows before the child writes, then erases back to where it started", () => { + const t = terminal(); + const result = collapseAfter(t.stdout, 10, () => { + t.stdout.write("[sudo] password for someone: \n"); + return 42; + }); + expect(result).toBe(42); + const all = t.raw(); + // Reserved and saved before the child's output... + expect(all.indexOf("\n".repeat(10) + "\u001b[10A\u001b7")).toBe(0); + // ...restored and cleared after it, even when the child throws. + expect(all.endsWith("\u001b8\u001b[J")).toBe(true); + expect(() => collapseAfter(t.stdout, 10, () => { throw new Error("boom"); })).toThrow("boom"); + expect(t.raw().endsWith("\u001b8\u001b[J")).toBe(true); + }); + + it("just runs without a terminal", () => { + const t = terminal(); + (t.stdout as unknown as { isTTY: boolean }).isTTY = false; + expect(collapseAfter(t.stdout, 10, () => "ran")).toBe("ran"); + expect(t.written).toEqual([]); + }); +}); diff --git a/__tests__/hooks/config-round-trip.test.ts b/__tests__/hooks/config-round-trip.test.ts index d76648f9a..5880dc904 100644 --- a/__tests__/hooks/config-round-trip.test.ts +++ b/__tests__/hooks/config-round-trip.test.ts @@ -28,6 +28,7 @@ import { readCredentialsRaw, writeCredentials, DEFAULT_CONFIG, + isAgentTraced, } from "../../src/hooks/fp-config"; let home: string; @@ -288,3 +289,62 @@ describe("credentials.json preserves keys this build does not own", () => { expect(credentials.cloud?.token).toBe("t"); }); }); + + +describe("config.json agents: the remembered answer to \"which agents should failproofai trace?\"", () => { + it("is absent on a machine that never chose, and writes no key — absent means every agent", () => { + writeConfig(structuredClone(DEFAULT_CONFIG), {}); + expect(readFile(configFile()).agents).toBeUndefined(); + expect(readConfig().agents).toBeUndefined(); + expect(isAgentTraced("goose")).toBe(true); + }); + + it("round-trips selected and seen", () => { + writeConfig({ ...structuredClone(DEFAULT_CONFIG), agents: { selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] } }, {}); + expect(readFile(configFile()).agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] }); + expect(readConfig().agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] }); + }); + + it("survives every unrelated update — the wizard's own connect step writes twice after it", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + updateConfig({ mode: "cloud" }); + updateConfig({ collector: { sessions: true } }); + expect(readFile(configFile()).agents).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); + + it("is replaced by a patch that names it and removed by null, pruning the table", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude"] } }); + updateConfig({ agents: { selected: ["codex"], seen: ["claude", "codex"] } }); + expect(readFile(configFile()).agents).toEqual({ selected: ["codex"], seen: ["claude", "codex"] }); + updateConfig({ agents: null }); + expect(readFile(configFile()).agents).toBeUndefined(); + expect(readConfig().agents).toBeUndefined(); + }); + + it("keeps an unknown sibling a newer build wrote inside the agents table", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: [], future_knob: 1 } }); + updateConfig({ agents: { selected: ["claude", "codex"], seen: ["claude", "codex"] } }); + expect(readFile(configFile()).agents.future_knob).toBe(1); + }); + + it("reads a garbage value as no selection, so a broken file never switches tracing off", () => { + for (const agents of ["all", ["claude"], { selected: "claude" }, { seen: ["claude"] }, null]) { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents }); + expect(readConfig().agents).toBeUndefined(); + expect(isAgentTraced("claude")).toBe(true); + } + }); + + it("keeps the names as written — the daemon is the one that warns about an unknown id — minus non-strings and repeats", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude", 3, "claude", "", "future-agent"], seen: "x" } }); + expect(readConfig().agents).toEqual({ selected: ["claude", "future-agent"], seen: [] }); + }); + + it("answers isAgentTraced from the selection once there is one", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + expect(isAgentTraced("claude")).toBe(true); + expect(isAgentTraced("goose")).toBe(false); + // An empty selection is a real choice, not "no selection". + expect(isAgentTraced("claude", { ...readConfig(), agents: { selected: [], seen: [] } })).toBe(false); + }); +}); diff --git a/__tests__/hooks/config-status-policies.test.ts b/__tests__/hooks/config-status-policies.test.ts new file mode 100644 index 000000000..4b86a3e4d --- /dev/null +++ b/__tests__/hooks/config-status-policies.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Installed packs are digest-verified on read, so a fixture would have to be a +// real signed artifact; the manifest reader is stubbed instead and everything +// else — config merge, convention files — is read from a throwaway home. +vi.mock("../../src/hooks/pack-manifest", () => ({ + readInstalledPacks: vi.fn(() => ({ packs: [], errors: [] })), + hasInstalledRegexPacks: vi.fn(() => false), +})); +vi.mock("../../src/hooks/cloud-managed-policies", () => ({ + readActiveCloudManagedPolicies: vi.fn(() => []), +})); + +import { summarizePolicySources } from "../../src/hooks/config-status"; +import { hasInstalledRegexPacks, readInstalledPacks } from "../../src/hooks/pack-manifest"; +import { readActiveCloudManagedPolicies } from "../../src/hooks/cloud-managed-policies"; + +let dir: string; +let project: string; +const saved: Record = {}; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpai-status-policies-")); + for (const key of ["HOME", "FAILPROOFAI_HOME"]) saved[key] = process.env[key]; + process.env.HOME = join(dir, "home"); + process.env.FAILPROOFAI_HOME = join(dir, "home", ".failproofai"); + mkdirSync(process.env.FAILPROOFAI_HOME, { recursive: true }); + project = join(dir, "project"); + mkdirSync(project, { recursive: true }); + vi.mocked(readInstalledPacks).mockReturnValue({ packs: [], errors: [] } as never); + vi.mocked(hasInstalledRegexPacks).mockReturnValue(false); + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([]); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(dir, { recursive: true, force: true }); +}); + +const pack = (id: string, version: string, names: string[], over: Record = {}) => ({ + id, + version, + source: id, + path: "/x", + sha256: "0", + effect: "enforce", + policies: names.map((name) => ({ name, description: name })), + enabled: null, + clis: null, + ...over, +}); + +const writeGlobalConfig = (body: unknown) => + writeFileSync(join(process.env.FAILPROOFAI_HOME!, "policies-config.json"), JSON.stringify(body)); + +describe("summarizePolicySources", () => { + it("counts what is on in each enforcing pack, minus what was switched off after install", () => { + vi.mocked(readInstalledPacks).mockReturnValue({ + packs: [ + pack("FailproofAI/policies", "06b802b1c2d3", ["a", "b", "c", "d"], { enabled: ["a", "b", "c"] }), + pack("acme/guards", "a1b2c3d4e5f6", ["x", "y"]), + pack("acme/trial", "f00", ["t1", "t2"], { effect: "observe" }), + pack("acme/empty", "e00", ["z"], { enabled: [] }), + ], + errors: [], + } as never); + writeGlobalConfig({ disabledCustomPolicies: ["pack:FailproofAI/policies@06b802b1c2d3:b"] }); + expect(summarizePolicySources(project)).toEqual({ + packOn: 4, // a, c from ours; x, y from acme + firstPack: "FailproofAI/policies@06b802b1c2d3", + morePacks: 1, + cloudOn: 0, + observing: 2, + customFiles: 0, + legacy: 0, + refusedPacks: 0, + packsInstalled: 4, + anyPack: "FailproofAI/policies@06b802b1c2d3", + }); + }); + + it("names the first pack that has something on, not merely the first installed", () => { + vi.mocked(readInstalledPacks).mockReturnValue({ + packs: [pack("acme/off", "1", ["p"], { enabled: [] }), pack("acme/on", "2", ["q"])], + errors: [], + } as never); + const summary = summarizePolicySources(project); + expect(summary.firstPack).toBe("acme/on@2"); + expect(summary.anyPack).toBe("acme/off@1"); + expect(summary.morePacks).toBe(0); + }); + + it("counts the refusals that deny, as `failproofai policies` does", () => { + vi.mocked(readInstalledPacks).mockReturnValue({ + packs: [], + errors: [ + { id: "acme/broken", reason: "digest mismatch", effect: "enforce" }, + { id: "acme/unknown-effect", reason: "unreadable" }, + // Neither of these has anything to fail closed on. + { id: "acme/trial", reason: "digest mismatch", effect: "observe" }, + { id: "acme/jev-only", reason: "digest mismatch", effect: "enforce", semanticOnly: true }, + ], + } as never); + expect(summarizePolicySources(project).refusedPacks).toBe(2); + }); + + it("counts cloud-managed policies by effect", () => { + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([ + { id: "c1", effect: "enforce" }, + { id: "c2", effect: "enforce" }, + { id: "c3", effect: "observe" }, + ] as never); + expect(summarizePolicySources(project)).toMatchObject({ cloudOn: 2, observing: 1 }); + }); + + it("counts custom policy files and convention files, once each", () => { + writeGlobalConfig({ customPoliciesPaths: ["/somewhere/mine.mjs"] }); + mkdirSync(join(project, ".failproofai", "policies"), { recursive: true }); + writeFileSync(join(project, ".failproofai", "policies", "team-policies.mjs"), ""); + writeFileSync(join(project, ".failproofai", "policies", "not-a-convention.mjs"), ""); + mkdirSync(join(process.env.FAILPROOFAI_HOME!, "policies"), { recursive: true }); + writeFileSync(join(process.env.FAILPROOFAI_HOME!, "policies", "my-policies.ts"), ""); + expect(summarizePolicySources(project).customFiles).toBe(3); + }); + + it("does not count the global convention folder twice when run from home", () => { + mkdirSync(join(process.env.FAILPROOFAI_HOME!, "policies"), { recursive: true }); + writeFileSync(join(process.env.FAILPROOFAI_HOME!, "policies", "my-policies.ts"), ""); + expect(summarizePolicySources(process.env.HOME).customFiles).toBe(1); + }); + + it("counts builtins the pre-pack migration shim still runs, only while no regex pack exists", () => { + writeGlobalConfig({ enabledPolicies: ["block-sudo", "block-env-files"] }); + expect(summarizePolicySources(project).legacy).toBe(2); + vi.mocked(hasInstalledRegexPacks).mockReturnValue(true); + expect(summarizePolicySources(project).legacy).toBe(0); + }); + + it("survives an unreadable pack manifest or deployment", () => { + vi.mocked(readInstalledPacks).mockImplementation(() => { + throw new Error("boom"); + }); + vi.mocked(readActiveCloudManagedPolicies).mockImplementation(() => { + throw new Error("bad manifest"); + }); + expect(summarizePolicySources(project)).toMatchObject({ packOn: 0, cloudOn: 0, packsInstalled: 0 }); + }); +}); diff --git a/__tests__/hooks/config-status.test.ts b/__tests__/hooks/config-status.test.ts new file mode 100644 index 000000000..978cc278a --- /dev/null +++ b/__tests__/hooks/config-status.test.ts @@ -0,0 +1,705 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + gatherStatusFacts, + pickStatusAttention, + readCollectorHealth, + renderStatusScreen, + type StatusDeps, + type StatusFacts, +} from "../../src/hooks/config-status"; +import { _resetForTest } from "../../src/hooks/hook-activity-store"; +import { writePause } from "../../src/hooks/session-pause"; + +const NOW = Date.UTC(2026, 9, 11, 14, 2, 0); +const CLOCK = { locale: "en-GB", timeZone: "UTC" }; +const PLAIN = { version: "1.0.11", cols: 104, color: false, clock: CLOCK }; +const strip = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + +const MINT = "\x1B[38;2;102;209;181m"; +const GREY = "\x1B[38;2;118;127;139m"; +const PINK = "\x1B[38;2;228;88;125m"; +const AMBER = "\x1B[38;2;227;179;65m"; +const RED = "\x1B[38;2;240;113;120m"; + +function withTrueColor(fn: () => T): T { + const keys = ["COLORTERM", "TERM", "NO_COLOR"] as const; + const saved = keys.map((k) => [k, process.env[k]] as const); + process.env.COLORTERM = "truecolor"; + process.env.TERM = "xterm-256color"; + delete process.env.NO_COLOR; + try { + return fn(); + } finally { + for (const [k, v] of saved) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +const NO_REFUSALS = { total: 0, rejected: 0, credentialRejected: 0, byStatus: {} }; + +/** A healthy connected machine — the reference screen's sample, as facts. */ +function facts(over: Partial = {}): StatusFacts { + return { + now: NOW, + daemon: { + supported: true, + platform: "linux", + service: "running", + answering: true, + configured: true, + version: "1.0.11", + skew: null, + checkCommand: "systemctl status failproofaid@chetan", + }, + pauses: [], + cloud: { + kind: "file", + host: "be.failproof.ai", + org: "chetanraghuvanshi85", + machine: "chetan-pc (dde01f39)", + pullsPolicies: true, + sendsEvents: true, + }, + delivery: { + health: { writtenAt: NOW - 5_000, lastEventAt: NOW - 12_000, lastOkAt: NOW - 20_000, delivering: true }, + refused: NO_REFUSALS, + queued: 0, + }, + agents: { traced: 9, notInstalled: 3 }, + policies: { + packOn: 10, + firstPack: "FailproofAI/policies@06b802b1c2d3", + morePacks: 0, + cloudOn: 0, + observing: 0, + customFiles: 0, + legacy: 0, + refusedPacks: 0, + packsInstalled: 1, + anyPack: "FailproofAI/policies@06b802b1c2d3", + }, + notEnforcing: null, + ...over, + }; +} + +const UNCONNECTED: Partial = { + cloud: { kind: "none" }, + delivery: undefined, +}; + +/** The value of the first row with this label, plain. */ +function rowValue(lines: string[], label: string): string | undefined { + const line = lines.find((l) => new RegExp(`^ ${label}\\s{2,}`).test(strip(l))); + return line === undefined ? undefined : strip(line).replace(new RegExp(`^ ${label}\\s+`), ""); +} + +describe("config --status — the screen", () => { + it("draws a connected, healthy machine in the reference's three sections", () => { + expect(renderStatusScreen(facts(), PLAIN)).toEqual([ + "failproof ai v1.0.11 · Status", + "", + "THIS MACHINE", + " daemon ● Running failproofaid 1.0.11", + " paused No", + "", + "CLOUD", + " account chetanraghuvanshi85 on be.failproof.ai", + " machine chetan-pc (dde01f39)", + " delivery ● Healthy, last event 12s ago, nothing queued", + "", + "ENFORCEMENT", + " agents 9 traced, 3 not installed", + " policies 10 on from FailproofAI/policies@06b802b1c2d3", + ]); + }); + + it("gives an unconnected machine one cloud row that says how to connect", () => { + const lines = renderStatusScreen(facts(UNCONNECTED), PLAIN); + const cloud = lines.slice(lines.indexOf("CLOUD"), lines.indexOf("ENFORCEMENT")); + expect(cloud).toEqual(["CLOUD", " account Not connected · failproofai config", ""]); + expect(lines.join("\n")).not.toMatch(/delivery|machine/); + }); + + it("says where an environment-configured cloud comes from, and still reports delivery", () => { + const lines = renderStatusScreen(facts({ cloud: { kind: "env", url: "https://staging.example" } }), PLAIN); + expect(rowValue(lines, "account")).toBe("Set by FAILPROOFAI_CLOUD_URL to https://staging.example"); + // The file's machine may describe a connection that is not the one in effect. + expect(rowValue(lines, "machine")).toBeUndefined(); + expect(rowValue(lines, "delivery")).toMatch(/^● Healthy/); + }); + + it("names the host when the org was never recorded, and a half connection as such", () => { + const reporting = renderStatusScreen( + facts({ cloud: { kind: "file", host: "be.failproof.ai", pullsPolicies: false, sendsEvents: true } }), + PLAIN, + ); + expect(rowValue(reporting, "account")).toBe("Connected to be.failproof.ai, for reporting only"); + const policyOnly = renderStatusScreen( + facts({ + cloud: { kind: "file", host: "be.failproof.ai", org: "acme", machine: "m-1", pullsPolicies: true, sendsEvents: false }, + }), + PLAIN, + ); + expect(rowValue(policyOnly, "delivery")).toBe("○ Not sending, connected for policy only"); + }); + + it("puts every section's values on ONE column, derived from the labels", () => { + const lines = renderStatusScreen(facts(), PLAIN); + const starts = lines + .filter((l) => l.startsWith(" ") && !l.startsWith(" ")) + .map((l) => l.length - l.replace(/^ {2}\S+\s+/, "").length); + expect(new Set(starts)).toEqual(new Set([13])); + }); + + it("never cuts a value, however narrow the terminal", () => { + const long = "a-machine-label-long-enough-to-wrap-twice-at-forty (0b1c2d3e)"; + const lines = renderStatusScreen( + facts({ cloud: { kind: "file", host: "policies.internal.example.com", org: "acme", machine: long, pullsPolicies: true, sendsEvents: true } }), + { ...PLAIN, cols: 40 }, + ); + expect(rowValue(lines, "machine")).toBe(long); + }); + + it("opens with the header and the version it is given", () => { + expect(renderStatusScreen(facts(), { ...PLAIN, version: "9.9.9" })[0]).toBe("failproof ai v9.9.9 · Status"); + }); + + it("paints by role in a terminal, and is the same text as the plain render", () => { + const f = facts({ + daemon: { ...facts().daemon, service: "stopped", answering: false, configured: false }, + notEnforcing: "no-hooks", + }); + const painted = withTrueColor(() => renderStatusScreen(f, { ...PLAIN, color: true })); + const text = painted.join("\n"); + expect(text).toContain(`${GREY}○\x1B[0m Installed, not running`); // off: grey + expect(text).toContain(`${MINT}●\x1B[0m Healthy`); // on: mint + expect(text).toContain(`${GREY}daemon\x1B[0m`); // labels grey + expect(text).toContain("\x1B[1mTHIS MACHINE\x1B[0m"); // headings bold + expect(text).toContain(`${AMBER}▲\x1B[0m Policies are not enforcing yet.`); + expect(text).toContain(`${PINK}failproofai config\x1B[0m`); + expect(painted.map(strip)).toEqual(renderStatusScreen(f, PLAIN)); + expect(renderStatusScreen(f, PLAIN).join("\n")).not.toContain("\x1B"); + }); + + it("paints a failure red", () => { + const painted = withTrueColor(() => + renderStatusScreen(facts({ daemon: { ...facts().daemon, answering: false, service: "stopped" } }), { + ...PLAIN, + color: true, + }), + ); + expect(painted.join("\n")).toContain(`${RED}✕\x1B[0m failproofaid is not answering, so every tool call is denied.`); + }); +}); + +describe("config --status — THIS MACHINE", () => { + const daemonRow = (over: Partial) => + rowValue(renderStatusScreen(facts({ daemon: { ...facts().daemon, configured: false, ...over } }), PLAIN), "daemon"); + + it("says which daemon runs, or the real state when none does", () => { + expect(daemonRow({})).toBe("● Running failproofaid 1.0.11"); + expect(daemonRow({ version: undefined })).toBe("● Running failproofaid"); + expect(daemonRow({ service: "stopped", answering: false })).toBe("○ Installed, not running"); + expect(daemonRow({ service: "condition-failed", answering: false })).toBe( + "○ Installed, will not start: its binary or worker is missing", + ); + expect(daemonRow({ service: "not-installed", answering: false })).toBe("○ Not installed"); + expect(daemonRow({ service: "not-installed", answering: true })).toBe("● Running outside the service manager"); + expect(daemonRow({ supported: false, platform: "win32", service: "unsupported-platform", answering: false })).toBe( + "○ Not supported on win32", + ); + }); + + it("keeps a version mismatch on the row, where a worse problem cannot take it away", () => { + expect(daemonRow({ version: "1.0.10", skew: { installed: "1.0.10", expected: "1.0.11" } })).toBe( + "● Running failproofaid 1.0.10, this CLI is 1.0.11", + ); + }); + + it("keeps macOS's unreadable service state as a fact, answering or not", () => { + expect(daemonRow({ service: "unknown", answering: true })).toBe("● Answering, its service state needs root to read"); + expect(daemonRow({ service: "unknown", answering: false })).toBe( + "○ Not answering, its service state needs root to read", + ); + }); + + it("has no dashboard row: the dashboard is a foreground command, not machine state", () => { + expect(renderStatusScreen(facts(), PLAIN).join("\n")).not.toMatch(/dashboard\s{2}|Not running/); + }); + + it("says so plainly when nothing is paused", () => { + expect(rowValue(renderStatusScreen(facts(), PLAIN), "paused")).toBe("No"); + expect(renderStatusScreen(facts(), PLAIN).join("\n")).not.toMatch(/Resume early|keep enforcing/); + }); + + it("lists each paused session with its id and time left, says cloud keeps enforcing, and how to resume it", () => { + const lines = renderStatusScreen( + facts({ pauses: [{ sessionId: "0b1c2d3e-0000-4000-8000-000000000001", expiresAt: NOW + 28 * 60_000, cwd: "/home/u/project" }] }), + PLAIN, + ); + const machine = lines.slice(lines.indexOf("THIS MACHINE"), lines.indexOf("CLOUD") - 1); + expect(machine).toEqual([ + "THIS MACHINE", + " daemon ● Running failproofaid 1.0.11", + " paused 1 session", + " 0b1c2d3e-0000-4000-8000-000000000001 28m left, until 14:30 · /home/u/project", + " Cloud-managed policies keep enforcing.", + ]); + expect(lines.slice(-2)).toEqual([ + "", + "Resume early: failproofai config --resume --session 0b1c2d3e-0000-4000-8000-000000000001", + ]); + }); + + it("keeps one row per session when several are paused, and names the id to pass", () => { + const lines = renderStatusScreen( + facts({ + pauses: [ + { sessionId: "s-new", expiresAt: NOW + 10 * 60_000 }, + { sessionId: "s-old", expiresAt: NOW + 90 * 60_000, cwd: "/w" }, + ], + }), + PLAIN, + ); + expect(rowValue(lines, "paused")).toBe("2 sessions"); + expect(lines).toContain(" s-new 10m left, until 14:12"); + expect(lines).toContain(" s-old 1h30m left, until 15:32 · /w"); + expect(lines[lines.length - 1]).toBe("Resume early: failproofai config --resume --session "); + }); +}); + +describe("config --status — delivery", () => { + const deliveryRow = (over: Partial>) => + rowValue(renderStatusScreen(facts({ delivery: { ...facts().delivery!, ...over } }), PLAIN), "delivery"); + const health = facts().delivery!.health!; + + it("reports a healthy pipe with its last event and backlog", () => { + expect(deliveryRow({})).toBe("● Healthy, last event 12s ago, nothing queued"); + expect(deliveryRow({ queued: 2, oldestQueuedMs: 60_000 })).toBe("● Healthy, last event 12s ago, 2 batches queued"); + expect(deliveryRow({ queued: 1, oldestQueuedMs: 60_000 })).toBe("● Healthy, last event 12s ago, 1 batch queued"); + expect(deliveryRow({ health: { ...health, lastEventAt: undefined } })).toBe( + "● Healthy, no events since failproofaid started, nothing queued", + ); + }); + + it("puts refused batches in the row, with their status and age", () => { + const refused = { total: 26, rejected: 26, credentialRejected: 26, byStatus: { 401: 26 }, oldestAgeMs: 134 * 60_000 }; + expect(deliveryRow({ refused })).toBe("▲ 26 batches refused (401), oldest 2h14m"); + }); + + it("says when the daemon has not reported, or stopped reporting", () => { + expect(deliveryRow({ health: null })).toBe("○ No report from failproofaid yet"); + expect(deliveryRow({ health: { ...health, writtenAt: NOW - 12 * 60_000 } })).toBe( + "▲ No report from failproofaid for 12m", + ); + expect(deliveryRow({ health: { ...health, delivering: false } })).toBe("○ Not delivering, failproofaid has no key"); + }); + + it("calls a backlog stuck only when it is old AND nothing has been delivered for as long", () => { + // Draining: old batches, but uploads are succeeding. + expect(deliveryRow({ queued: 120, oldestQueuedMs: 40 * 60_000 })).toBe( + "● Healthy, last event 12s ago, 120 batches queued", + ); + expect( + deliveryRow({ queued: 50, oldestQueuedMs: 3 * 3_600_000, health: { ...health, lastOkAt: NOW - 3 * 3_600_000 } }), + ).toBe("▲ 50 batches queued, oldest 3h, last delivered 3h ago"); + expect(deliveryRow({ queued: 5, oldestQueuedMs: 20 * 60_000, health: { ...health, lastOkAt: undefined } })).toBe( + "▲ 5 batches queued, oldest 20m, nothing delivered since failproofaid started", + ); + }); +}); + +describe("config --status — ENFORCEMENT", () => { + const policiesRow = (over: Partial) => + rowValue(renderStatusScreen(facts({ policies: { ...facts().policies, ...over } }), PLAIN), "policies"); + + it("counts traced agents and the ones not installed", () => { + expect(rowValue(renderStatusScreen(facts(), PLAIN), "agents")).toBe("9 traced, 3 not installed"); + expect(rowValue(renderStatusScreen(facts({ agents: { traced: 12, notInstalled: 0 } }), PLAIN), "agents")).toBe( + "12 traced", + ); + expect(rowValue(renderStatusScreen(facts({ agents: { traced: 8, notTraced: 1, notInstalled: 3 } }), PLAIN), "agents")).toBe( + "8 traced, 1 not traced, 3 not installed", + ); + }); + + it("says how many policies are on and where they come from", () => { + expect(policiesRow({})).toBe("10 on from FailproofAI/policies@06b802b1c2d3"); + expect(policiesRow({ morePacks: 1, packOn: 14 })).toBe("14 on from FailproofAI/policies@06b802b1c2d3 and 1 more pack"); + expect(policiesRow({ morePacks: 2 })).toBe("10 on from FailproofAI/policies@06b802b1c2d3 and 2 more packs"); + expect(policiesRow({ cloudOn: 3 })).toBe("10 on from FailproofAI/policies@06b802b1c2d3, 3 from cloud"); + expect(policiesRow({ packOn: 0, firstPack: undefined, cloudOn: 3 })).toBe("3 on from cloud"); + expect(policiesRow({ customFiles: 2 })).toBe("10 on from FailproofAI/policies@06b802b1c2d3, custom policies from 2 files"); + expect(policiesRow({ packOn: 0, firstPack: undefined, customFiles: 1 })).toBe("Custom policies from 1 file"); + expect(policiesRow({ packOn: 0, firstPack: undefined, observing: 4 })).toBe("4 observing"); + expect(policiesRow({ packOn: 0, firstPack: undefined, packsInstalled: 0, anyPack: undefined, legacy: 8 })).toBe( + "8 on from legacy builtins", + ); + }); + + it("counts a pack that will not load apart from what is on", () => { + expect(policiesRow({ refusedPacks: 1 })).toBe("10 on from FailproofAI/policies@06b802b1c2d3, 1 pack will not load"); + expect(policiesRow({ packOn: 0, firstPack: undefined, packsInstalled: 0, anyPack: undefined, refusedPacks: 2 })).toBe( + "2 packs will not load", + ); + }); + + it("names the pack that is installed with everything off, and an empty machine as empty", () => { + expect(policiesRow({ packOn: 0, firstPack: undefined })).toBe("0 on from FailproofAI/policies@06b802b1c2d3"); + expect(policiesRow({ packOn: 0, firstPack: undefined, packsInstalled: 0, anyPack: undefined })).toBe("None installed"); + }); + + it("has no per-day counts and no per-agent rows: one row per fact about the machine", () => { + const text = renderStatusScreen(facts(), PLAIN).join("\n"); + expect(text).not.toMatch(/^\s+today\s/m); + expect(text).not.toMatch(/^\s+hermes\s/m); + }); +}); + +describe("config --status — the attention line", () => { + /** Every problem at once; each test takes the worst away and expects the next. */ + const everything = (): StatusFacts => + facts({ + daemon: { + ...facts().daemon, + configured: true, + answering: false, + service: "stopped", + skew: { installed: "1.0.10", expected: "1.0.11" }, + }, + delivery: { + ...facts().delivery!, + refused: { total: 3, rejected: 3, credentialRejected: 2, byStatus: { 401: 2, 422: 1 } }, + }, + notEnforcing: "no-policies", + policies: { ...facts().policies, refusedPacks: 1 }, + }); + + const attentionLines = (f: StatusFacts) => + renderStatusScreen(f, PLAIN).filter((l) => l.startsWith("▲") || l.startsWith("✕")); + + it("shows at most one, the most severe", () => { + expect(attentionLines(everything())).toEqual([ + "✕ failproofaid is not answering, so every tool call is denied. · failproofai config", + ]); + expect(attentionLines(facts())).toEqual([]); + }); + + it("walks down the severity order as each problem is fixed", () => { + const f = everything(); + f.daemon = { ...f.daemon, configured: false }; + // Not configured: a down daemon no longer denies, but a refused pack still does. + expect(pickStatusAttention(f)).toEqual({ + severity: "fail", + text: "A pack will not load, so the tool calls it covers are denied.", + fix: "failproofai policies", + }); + + f.policies = { ...f.policies, refusedPacks: 0 }; + expect(pickStatusAttention(f)).toEqual({ + severity: "fail", + text: "The cloud is refusing this machine's key, so nothing reaches the dashboard.", + fix: "failproofai config", + }); + + f.delivery = { ...f.delivery!, refused: { total: 1, rejected: 1, credentialRejected: 0, byStatus: { 422: 1 } } }; + expect(pickStatusAttention(f)).toEqual({ + severity: "caution", + text: "Policies are not enforcing yet.", + fix: "failproofai policies add", + }); + + f.notEnforcing = null; + expect(pickStatusAttention(f)).toEqual({ + severity: "caution", + text: "failproofaid is not running, so nothing is pulled or delivered.", + fix: "failproofai config", + }); + + f.daemon = { ...f.daemon, answering: true, service: "running" }; + expect(pickStatusAttention(f)).toEqual({ + severity: "caution", + text: "The cloud refused 1 batch (422); they are not retried.", + }); + + f.delivery = { ...f.delivery!, refused: NO_REFUSALS }; + expect(pickStatusAttention(f)).toEqual({ + severity: "caution", + text: "failproofaid 1.0.10 does not match this CLI (1.0.11).", + fix: "failproofai update", + }); + + f.daemon = { ...f.daemon, skew: null }; + expect(pickStatusAttention(f)).toBeNull(); + }); + + it("fixes 'not enforcing' with the command for its cause", () => { + const fixFor = (reason: StatusFacts["notEnforcing"], packsInstalled = 1) => + pickStatusAttention(facts({ notEnforcing: reason, policies: { ...facts().policies, packsInstalled } }))?.fix; + expect(fixFor("no-hooks")).toBe("failproofai config"); + expect(fixFor("observe-only")).toBe("failproofai policies"); + expect(fixFor("no-policies", 1)).toBe("failproofai policies add"); + expect(fixFor("no-policies", 0)).toBe("failproofai policies add FailproofAI/policies"); + }); + + it("never calls a machine with a refused pack 'not enforcing': it is denying", () => { + const f = facts({ notEnforcing: "no-policies", policies: { ...facts().policies, packOn: 0, refusedPacks: 2 } }); + expect(pickStatusAttention(f)).toEqual({ + severity: "fail", + text: "2 packs will not load, so the tool calls they cover are denied.", + fix: "failproofai policies", + }); + }); + + it("names what stands idle when the daemon is down on a connected machine", () => { + const down = (cloud: StatusFacts["cloud"], over: Partial = {}) => + pickStatusAttention( + facts({ cloud, daemon: { ...facts().daemon, configured: false, answering: false, service: "stopped", ...over } }), + ); + const both = facts().cloud; + expect(down(both)?.text).toBe("failproofaid is not running, so nothing is pulled or delivered."); + expect(down({ kind: "file", host: "h", pullsPolicies: true, sendsEvents: false })?.text).toBe( + "failproofaid is not running, so no cloud policy is pulled.", + ); + expect(down({ kind: "file", host: "h", pullsPolicies: false, sendsEvents: true })?.text).toBe( + "failproofaid is not running, so nothing is delivered.", + ); + expect(down(both, { service: "not-installed" })?.text).toBe( + "failproofaid is not installed, so nothing is pulled or delivered.", + ); + // macOS: the state needs root to read, so the fix is the command that reads it. + expect(down(both, { service: "unknown", checkCommand: "sudo launchctl print system/ai.failproof.failproofaid" })).toEqual({ + severity: "caution", + text: "No daemon is answering, so nothing is pulled or delivered.", + fix: "sudo launchctl print system/ai.failproof.failproofaid", + }); + expect(down(both, { supported: false, service: "unsupported-platform" })).toEqual({ + severity: "caution", + text: "failproofaid does not run on this platform, so nothing is pulled or delivered.", + }); + // Nothing connected: a stopped daemon on an unconfigured machine costs nothing. + expect(down({ kind: "none" })).toBeNull(); + }); +}); + +// ── reading the machine ────────────────────────────────────────────────────── + +let dir: string; +const saved: Record = {}; +const ENV = [ + "HOME", + "FAILPROOFAI_HOME", + "FAILPROOFAI_CLOUD_URL", + "FAILPROOFAI_CLOUD_CREDENTIALS", + "FAILPROOFAI_DAEMON_SOCKET", + "FAILPROOFAI_DAEMON_BINARY", + "FAILPROOFAI_STATE_DIR", + "FAILPROOFAI_DASHBOARD_HOST", +]; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpai-status-")); + for (const key of ENV) saved[key] = process.env[key]; + for (const key of ENV) delete process.env[key]; + // A throwaway HOME as well: some readers resolve through homedir(). + process.env.HOME = join(dir, "home"); + process.env.FAILPROOFAI_HOME = join(dir, "home", ".failproofai"); + mkdirSync(process.env.FAILPROOFAI_HOME, { recursive: true }); + _resetForTest(); +}); + +afterEach(() => { + for (const key of ENV) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + _resetForTest(); + rmSync(dir, { recursive: true, force: true }); +}); + +const fp = (...parts: string[]) => join(process.env.FAILPROOFAI_HOME!, ...parts); +const put = (rel: string, body: unknown) => { + const file = fp(rel); + mkdirSync(join(file, ".."), { recursive: true }); + writeFileSync(file, typeof body === "string" ? body : JSON.stringify(body)); + return file; +}; + +/** Nothing here reaches systemd, PATH, a socket or the network. */ +const seams = (over: StatusDeps = {}): StatusDeps => ({ + now: NOW, + cwd: join(dir, "project"), + daemonStatus: () => "not-installed", + daemonAnswering: () => false, + detectInstalled: () => ["claude", "codex"], + notEnforcing: () => null, + ...over, +}); + +describe("gatherStatusFacts", () => { + it("reads a fresh, unconnected machine", async () => { + const f = await gatherStatusFacts(seams()); + expect(f.cloud).toEqual({ kind: "none" }); + expect(f.delivery).toBeUndefined(); + expect(f.pauses).toEqual([]); + // No selection saved means every agent is traced; two binaries were found. + expect(f.agents).toEqual({ traced: 12, notTraced: 0, notInstalled: 10 }); + expect(f.policies.packsInstalled).toBe(0); + expect(f.daemon).toMatchObject({ service: "not-installed", answering: false, configured: false }); + }); + + it("counts the saved agent selection, ignoring ids that are not agents", async () => { + put("config.json", { agents: { selected: ["claude", "codex", "goose", "not-an-agent"], seen: ["claude"] } }); + const f = await gatherStatusFacts(seams({ detectInstalled: () => ["claude", "codex", "goose"] })); + expect(f.agents).toEqual({ traced: 3, notTraced: 0, notInstalled: 9 }); + }); + + it("counts agents found here but left out of the selection", async () => { + put("config.json", { agents: { selected: ["claude"], seen: ["claude", "codex"] } }); + const f = await gatherStatusFacts(seams({ detectInstalled: () => ["claude", "codex"] })); + expect(f.agents).toEqual({ traced: 1, notTraced: 1, notInstalled: 10 }); + }); + + it("reads the connection, delivery health, refusals and backlog from disk — never the token", async () => { + put("credentials.json", { + cloud: { url: "https://be.failproof.ai", machine_id: "dde01f39-afba-40eb-bf1a-815d9f17ac2d", token: "abcdefghijkl", machine_label: "chetan-pc" }, + ingest: { url: "https://be.failproof.ai/v1/events", key: "abcdefghijkl" }, + org: { id: "o-1", slug: "failproofai", name: "FailproofAI" }, + }); + put("state/collector-health.json", { + ts: Math.floor((NOW - 10_000) / 1000), + sources: { claude: { root_present: true, last_event_ts: Math.floor((NOW - 30_000) / 1000), events: 4, cursor: 9, last_error: null, errors: 0 } }, + delivery: { accepted: 4, skipped: 0, batches_fully_skipped: 0, last_ok_ts: Math.floor((NOW - 25_000) / 1000) }, + }); + put("state/failed/hooks-a-1-0.a1.c401.jsonl", "{}\n"); + put("state/spool/claude-x-1-0.jsonl", "{}\n"); + + const f = await gatherStatusFacts(seams()); + expect(f.cloud).toEqual({ + kind: "file", + host: "be.failproof.ai", + org: "FailproofAI (failproofai)", + machine: "chetan-pc (dde01f39)", + pullsPolicies: true, + sendsEvents: true, + }); + expect(f.delivery?.health).toEqual({ + writtenAt: Math.floor((NOW - 10_000) / 1000) * 1000, + lastEventAt: Math.floor((NOW - 30_000) / 1000) * 1000, + lastOkAt: Math.floor((NOW - 25_000) / 1000) * 1000, + delivering: true, + }); + expect(f.delivery?.refused).toMatchObject({ rejected: 1, credentialRejected: 1, byStatus: { 401: 1 } }); + expect(f.delivery?.queued).toBe(1); + + const screen = renderStatusScreen(f, PLAIN).join("\n"); + expect(screen).not.toContain("abcdefghijkl"); + expect(screen).toContain("FailproofAI (failproofai) on be.failproof.ai"); + }); + + it("says the environment is in charge when FAILPROOFAI_CLOUD_URL is set", async () => { + put("credentials.json", { cloud: { url: "https://from-file", machine_id: "m", token: "t" } }); + process.env.FAILPROOFAI_CLOUD_URL = "https://from-env"; + const f = await gatherStatusFacts(seams()); + expect(f.cloud).toEqual({ kind: "env", url: "https://from-env" }); + expect(renderStatusScreen(f, PLAIN).join("\n")).not.toMatch(/from-file/); + }); + + it("lists active pauses with time left, and omits expired ones", async () => { + writePause({ sessionId: "live", durationMs: 600_000, now: NOW, cwd: "/tmp/p" }); + writePause({ sessionId: "dead", durationMs: 1_000, now: NOW - 60_000 }); + const f = await gatherStatusFacts(seams()); + expect(f.pauses).toEqual([{ sessionId: "live", expiresAt: NOW + 600_000, cwd: "/tmp/p" }]); + const out = renderStatusScreen(f, PLAIN).join("\n"); + expect(out).toMatch(/live {2}10m left/); + expect(out).not.toMatch(/dead/); + }); + + it("records the daemon's version and a version skew", async () => { + put("VERSION", { layout: 4, cli: "1.0.11", daemon: "1.0.0" }); + put("bin/failproofaid-1.0.0", "binary"); + const f = await gatherStatusFacts(seams({ daemonStatus: () => "running", daemonAnswering: () => true })); + expect(f.daemon.version).toBe("1.0.0"); + expect(f.daemon.skew?.installed).toBe("1.0.0"); + expect(pickStatusAttention(f)?.text).toMatch(/^failproofaid 1\.0\.0 does not match this CLI/); + }); + + it("asks the shared not-enforcing predicate, for this directory", async () => { + const asked: Array = []; + const f = await gatherStatusFacts( + seams({ + notEnforcing: (cwd) => { + asked.push(cwd); + return "no-hooks"; + }, + }), + ); + expect(f.notEnforcing).toBe("no-hooks"); + expect(asked).toEqual([join(dir, "project")]); + }); +}); + +describe("readCollectorHealth", () => { + it("converts the daemon's seconds and takes the newest source event", () => { + const file = put("state/collector-health.json", { + ts: 1_800_000_000, + sources: { + claude: { root_present: true, last_event_ts: 1_799_999_000, events: 3 }, + codex: { root_present: true, last_event_ts: 1_799_999_500, events: 1 }, + goose: { root_present: false, last_event_ts: 0, events: 0 }, + }, + delivery: { accepted: 4, skipped: 0, batches_fully_skipped: 0, last_ok_ts: 1_799_999_900 }, + }); + expect(readCollectorHealth(file)).toEqual({ + writtenAt: 1_800_000_000_000, + lastEventAt: 1_799_999_500_000, + lastOkAt: 1_799_999_900_000, + delivering: true, + }); + }); + + it("reads zero as none, and a missing delivery block as a daemon with no key", () => { + const file = put("state/collector-health.json", { + ts: 1_800_000_000, + sources: { claude: { root_present: true, last_event_ts: 0, events: 0 } }, + }); + expect(readCollectorHealth(file)).toEqual({ writtenAt: 1_800_000_000_000, delivering: false }); + const idle = put("state/collector-health.json", { ts: 1_800_000_000, sources: {}, delivery: { last_ok_ts: 0 } }); + expect(readCollectorHealth(idle)).toEqual({ writtenAt: 1_800_000_000_000, delivering: true }); + }); + + it("returns null rather than throwing on a missing, malformed or undated record", () => { + expect(readCollectorHealth(fp("state", "collector-health.json"))).toBeNull(); + expect(readCollectorHealth(put("state/collector-health.json", "{ half"))).toBeNull(); + expect(readCollectorHealth(put("state/collector-health.json", { sources: {} }))).toBeNull(); + expect(readCollectorHealth(put("state/collector-health.json", "[]"))).toBeNull(); + }); + + it("defaults to the file the daemon writes", () => { + put("state/collector-health.json", { ts: 1_800_000_000, sources: {} }); + expect(readCollectorHealth()?.writtenAt).toBe(1_800_000_000_000); + }); +}); + +describe("the backlog age the delivery row reads", () => { + it("comes from the oldest spooled batch", async () => { + put("credentials.json", { ingest: { url: "https://be.failproof.ai/v1/events", key: "k" } }); + // Both stamped relative to the injected clock, never the real one. + const stamp = (file: string, ageMs: number) => { + const when = new Date(NOW - ageMs); + utimesSync(file, when, when); + }; + stamp(put("state/spool/old-1-0.jsonl", "{}\n"), 30 * 60_000); + stamp(put("state/spool/new-1-0.jsonl", "{}\n"), 60_000); + const f = await gatherStatusFacts(seams()); + expect(f.delivery?.queued).toBe(2); + expect(f.delivery?.oldestQueuedMs).toBe(30 * 60_000); + }); +}); diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 885eeaadf..dc86e19cb 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -1,37 +1,32 @@ 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 { tmpdir } from "node:os"; import { resolve, dirname } from "node:path"; // The interactive prompts, the install manager, telemetry and CLI detection are -// mocked so we can drive the wizard head-lessly and assert the exact side effect -// (the installHooks call) without touching the filesystem or a real TTY. +// mocked so the wizard can be driven head-lessly and its exact side effects +// asserted without touching a real TTY, systemd, or the network. vi.mock("../../src/hooks/tui", async (importOriginal) => { const actual = await importOriginal(); - // Keep the pure helpers (summarize, ellipsize) real; stub only the interactive prompts. + // Keep the pure helpers (screenKit, paint, collapseAfter) real; stub only the + // interactive prompts. return { ...actual, selectOne: vi.fn(), multiSelect: vi.fn(), promptText: vi.fn(), - intro: vi.fn(), - outro: vi.fn(), + promptCloudKey: vi.fn(), }; }); -vi.mock("../../src/hooks/manager", () => ({ installHooks: vi.fn(async () => {}) })); -// The wizard's apply path writes `customPoliciesEnabled` to the config for the -// CHOSEN SCOPE, and project scope resolves from `process.cwd()` — which, in a -// test, is this repository. So every applied project-scope run wrote -// `"customPoliciesEnabled": false` into the committed dogfood config, and the -// next `git add -A` committed it: custom policies silently off for everyone who -// pulled. Isolating HOME (below) could never catch this, because project scope -// never consults HOME. -// -// Redirect the resolved path rather than stubbing the write, so the real -// setCustomPoliciesEnabled still runs and stays under test — just against a -// temp file. `WIZARD_TEST_CONFIG_DIR` is recomputed identically outside the -// factory so afterAll can clean it up. +vi.mock("../../src/hooks/manager", () => ({ + installHooks: vi.fn(async () => {}), + notEnforcingReason: vi.fn(() => "no-policies"), + enforcingPolicyCount: vi.fn(() => ({ count: 0, custom: false })), +})); +// The apply path resolves project-scoped config from `process.cwd()` — this +// repository, under test. A run that wrote there once committed custom policies +// switched off for everyone who pulled. Redirected rather than stubbed, so the +// real code still runs against a temp file. vi.mock("../../src/hooks/hooks-config", async (importOriginal) => { const actual = await importOriginal(); const { mkdirSync } = await import("node:fs"); @@ -41,60 +36,32 @@ vi.mock("../../src/hooks/hooks-config", async (importOriginal) => { mkdirSync(dir, { recursive: true }); return { ...actual, - // Only the cwd-derived scopes are redirected. User scope already resolves - // from HOME, which this file isolates, and the daemon tests depend on that - // real path — redirecting it too would move `daemonConfigured` out from - // under them. getConfigPathForScope: (scope: string, cwd?: string) => scope === "user" ? actual.getConfigPathForScope("user", cwd) : join(dir, scope === "local" ? "policies-config.local.json" : "policies-config.json"), }; }); -// installDaemonService shells out to real systemctl/launchctl — several tests -// below drive the wizard with scope "user", which is exactly the condition -// that triggers it. Mocked so an ordinary unit test run never touches this -// machine's real systemd/launchd state. -// Only the three that shell out are stubbed; setDaemonConfigured stays real -// so the `daemonConfigured` assertions below test the actual marker write -// (against this file's isolated HOME), not a mock of it. +// Only what shells out is stubbed; setDaemonConfigured stays real so the +// `daemonConfigured` assertions test the actual marker write. vi.mock("../../src/hooks/daemon-service", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - isDaemonSupportedPlatform: vi.fn(() => false), - installDaemonService: vi.fn(async () => ({ installed: false, reason: "mocked" })), + isDaemonSupportedPlatform: vi.fn(() => true), + installDaemonService: vi.fn(async () => ({ installed: true })), daemonServiceFilePath: vi.fn(() => null), - // Drives the already-installed / crash-looped branches without touching - // this machine's real service manager. - daemonServiceStatus: vi.fn(() => "not-installed" as const), - // Reads /etc/systemd/system on the real machine, so a developer box with a - // pre-FAILPROOFAI_CLI_CMD unit installed would otherwise send every - // already-running test down the refresh branch. + daemonServiceStatus: vi.fn(() => "running" as const), daemonServiceNeedsUpgrade: vi.fn(() => false), ensureDaemonServiceCurrent: vi.fn(async () => ({ outcome: "current" as const })), daemonStatusCommand: vi.fn(() => "systemctl status failproofaid@test"), - // Step 0 primes sudo before anything is drawn. Mocked true by default so - // no test can block on a real password prompt. primeElevation: vi.fn(() => true), canElevate: vi.fn(() => true), - // The end-to-end health probe opens the real daemon socket. Default true — - // "the service manager says running" and "it can actually answer" agree on - // a healthy machine, which is what every pre-existing test here means by - // running. The tests that drive the broken-worker branch override it. probeDaemonEndToEnd: vi.fn(async () => true), - // Richer form the wizard uses so it can name WHICH fault it hit — an - // unreachable socket and a worker that will not run need different words. probeDaemon: vi.fn(async () => ({ ok: true })), uninstallDaemonService: vi.fn(async () => {}), }; }); -// The wizard kicks off the audit pipeline after a completed apply; stub it so -// tests never scan real history. -// The connect step reaches the network twice — once to probe the key before -// the review screen, once via connectToCloud at apply. Both stubbed so no test -// talks to a server, and so the "revoked between probe and apply" case can be -// driven deterministically. vi.mock("../../src/hooks/cloud-connection", async (importOriginal) => { const actual = await importOriginal(); return { @@ -110,6 +77,12 @@ vi.mock("../../src/hooks/collector-config", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, validateIngestKey: vi.fn(async () => ({ ok: true })) }; }); +// Introspect is the first check a key gets. "unsupported" (an older server) +// hands the decision to the ingest probe above, which each test controls. +vi.mock("../../src/hooks/cloud-introspect", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, introspectKey: vi.fn(async () => ({ kind: "unsupported" })) }; +}); vi.mock("../../src/audit/cli", () => ({ runPostSetupAudit: vi.fn(async () => {}) })); vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: vi.fn(async () => {}) })); vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-id") })); @@ -118,16 +91,28 @@ vi.mock("../../src/hooks/integrations", async (importOriginal) => { return { ...actual, detectInstalledClis: vi.fn(() => ["claude"]) }; }); -import { selectOne, multiSelect, promptText, outro, type TTYIn, type TTYOut } from "../../src/hooks/tui"; +import { + multiSelect, + promptCloudKey, + OPEN_SOURCE, + paint, + screenKit, + type CloudKeyAnswer, + type CloudKeyPromptOptions, + type MultiSelectOptions, + type OpenSource, + type TTYIn, + type TTYOut, +} from "../../src/hooks/tui"; import { connectToCloud } from "../../src/hooks/cloud-connection"; -import { validateIngestKey } from "../../src/hooks/collector-config"; -import { installHooks } from "../../src/hooks/manager"; +import { validateIngestKey, writeIngestCredential, writeCollectorSettings } from "../../src/hooks/collector-config"; +import { introspectKey } from "../../src/hooks/cloud-introspect"; +import { installHooks, notEnforcingReason, enforcingPolicyCount } from "../../src/hooks/manager"; import { isDaemonSupportedPlatform, installDaemonService, daemonServiceStatus, daemonServiceNeedsUpgrade, - daemonServiceFilePath, ensureDaemonServiceCurrent, primeElevation, canElevate, @@ -136,1482 +121,1023 @@ import { uninstallDaemonService, } from "../../src/hooks/daemon-service"; import { - buildAgentChoices, - buildCompletionSummary, - clisSupportingScope, - reviewLines, - policyNamesLine, + agentChanges, + agentChoices, + agentTag, + agentsCollapsedLines, + checkCloudKey, + cloudCollapsedLines, + defaultSelection, + doneLines, + hookedAtUserScope, + planAgents, + removeUserScopeHooks, + resolveCloudUrl, runConfigureWizard, maybeFirstRunConfigure, hasSeenLauncher, markLauncherSeen, } from "../../src/hooks/configure-wizard"; import { INTEGRATION_TYPES, type IntegrationType } from "../../src/hooks/types"; -import { getIntegration } from "../../src/hooks/integrations"; +import { detectInstalledClis, getIntegration } from "../../src/hooks/integrations"; import { runPostSetupAudit } from "../../src/audit/cli"; import { trackHookEvent } from "../../src/hooks/hook-telemetry"; -import { configFile as fpConfigFile, launcherMarker } from "../../src/hooks/fp-home"; -import { readConfig as readFpConfig } from "../../src/hooks/fp-config"; +import { configFile as fpConfigFile, credentialsFile, launcherMarker } from "../../src/hooks/fp-home"; +import { readConfig as readFpConfig, updateConfig } from "../../src/hooks/fp-config"; +import { backfillRequestPath } from "../../src/hooks/backfill-request"; const mkTtyStdin = (): TTYIn => ({ isTTY: true }) as unknown as TTYIn; -const mkTtyStdout = (): TTYOut => - ({ isTTY: true, write: vi.fn(() => true), columns: 80 }) as unknown as TTYOut; +const mkTtyStdout = (): TTYOut => ({ isTTY: true, write: vi.fn(() => true), columns: 80 }) as unknown as TTYOut; const ttyIO = () => ({ stdin: mkTtyStdin(), stdout: mkTtyStdout() }); - /** A pipe, a CI job, or an agent driving the CLI — anything without a terminal. */ const headlessIO = () => ({ stdin: { isTTY: false } as unknown as TTYIn, stdout: { isTTY: false, write: vi.fn(() => true), columns: 80 } as unknown as TTYOut, }); +const printed = (stdout: TTYOut): string => + vi.mocked(stdout.write).mock.calls.map((c) => String(c[0])).join("").replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b[78]/g, ""); /** - * Queue answers for a wizard run BY NAME rather than by position. - * - * The wizard's step order is a product decision that has already changed twice - * (policies moved ahead of assistants and then left entirely; the - * Recommended/Customize fork and the scope question both went). Positional - * `mockResolvedValueOnce` chains meant every such change broke every test at - * once and each had to be re-counted by hand — which is exactly the kind of - * churn that tempts someone to "fix" a test by loosening it. Naming the steps - * keeps a reorder to a one-line change here. - * - * Current order — selectOne: connect, review. - * multiSelect: assistants. - * `undefined` means "this step is not reached in this test". - * - * There is no policy step and no scope step. Setup asks nothing about what to - * enforce, and scope is GLOBAL always — a project-scoped install guards the one - * directory it was run from and silently leaves every other repo unguarded. So - * `multiSelect` is asked exactly once, for the harnesses, and `selectOne` twice. + * Queue answers for a run BY NAME. Two prompts are left: the cloud-key field + * and the agents picker. Without an answer queued, the cloud step takes Tab + * (open source) and the picker takes Enter on whatever it opened with. */ -function drive(answers: { - connect?: "key" | "local" | null; - review?: "apply" | "cancel" | null; -}) { - const one = vi.mocked(selectOne); - if ("connect" in answers) one.mockResolvedValueOnce(answers.connect as never); - if ("review" in answers) one.mockResolvedValueOnce(answers.review as never); +function drive(answers: { cloud?: CloudKeyAnswer | OpenSource | null; agents?: IntegrationType[] | null }) { + if ("cloud" in answers) vi.mocked(promptCloudKey).mockResolvedValueOnce(answers.cloud as never); + if ("agents" in answers) vi.mocked(multiSelect).mockResolvedValueOnce(answers.agents as never); } - -/** The happy path: global scope, Claude, stay local, apply. */ -/** The happy path: stay local, apply. Setup asks nothing else. */ -const HAPPY = { - connect: "local" as const, - review: "apply" as const, -}; - -/** - * A realistic enabled set for the review-screen tests below. - * - * These were written against `RECOMMENDED_POLICIES`, which left with the preset - * module — the wizard has no policy list of its own any more. The names are kept - * verbatim rather than replaced with `policy-1 … policy-14` because what these - * tests measure is COLUMN WIDTH, and a slug of the wrong length measures the - * wrong thing. Membership is not the subject: `reviewLines`' truncation is. - */ -const FOURTEEN_ENABLED = [ - "sanitize-jwt", - "sanitize-api-keys", - "sanitize-connection-strings", - "sanitize-private-key-content", - "sanitize-bearer-tokens", - "protect-env-vars", - "block-env-files", - "block-secrets-write", - "block-failproofai-commands", - "block-sudo", - "block-curl-pipe-sh", - "block-rm-rf", - "block-push-master", - "block-force-push", -]; - -// The wizard's apply path calls markLauncherSeen(), which writes under -// homedir()/.failproofai — isolate HOME for the whole file so no test ever -// touches the developer's real config. +const cloudOpts = (): CloudKeyPromptOptions => vi.mocked(promptCloudKey).mock.calls[0]![0]; +const agentOpts = (): MultiSelectOptions => + vi.mocked(multiSelect).mock.calls[0]![0] as unknown as MultiSelectOptions; + +// The apply path writes under homedir()/.failproofai — HOME is isolated for the +// whole file so no test ever touches the developer's real config. So is +// FAILPROOFAI_HOME: every failproofai path resolves through it FIRST, and the +// beforeEach below deletes credentials.json and config.json. A developer who +// exports it (the documented override for containers and the SDK spool) would +// otherwise lose their real cloud key to the first test. let fileHome: string; let realHome: string | undefined; +let realFpHome: string | undefined; /** Must match the path built inside the hooks-config mock factory above. */ const WIZARD_TEST_CONFIG_DIR = resolve(tmpdir(), `fpai-wizard-cfg-${process.pid}`); beforeAll(() => { realHome = process.env.HOME; + realFpHome = process.env.FAILPROOFAI_HOME; fileHome = mkdtempSync(resolve(tmpdir(), "fpai-cfg-")); process.env.HOME = fileHome; + process.env.FAILPROOFAI_HOME = resolve(fileHome, ".failproofai"); }); afterAll(() => { if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; - try { - rmSync(fileHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - try { - rmSync(WIZARD_TEST_CONFIG_DIR, { recursive: true, force: true }); - } catch { - /* ignore */ - } + if (realFpHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = realFpHome; + rmSync(fileHome, { recursive: true, force: true }); + rmSync(WIZARD_TEST_CONFIG_DIR, { recursive: true, force: true }); }); beforeEach(() => { - vi.mocked(selectOne).mockReset(); - vi.mocked(multiSelect).mockReset(); + // Every run starts on a fresh machine: no saved selection, no credentials, + // no pending request, no marker. + rmSync(fpConfigFile(), { force: true }); + rmSync(credentialsFile(), { force: true }); + rmSync(backfillRequestPath(), { force: true }); + rmSync(launcherMarker(fileHome), { force: true }); + delete process.env.FAILPROOFAI_CLOUD_URL; + vi.mocked(promptCloudKey).mockReset().mockResolvedValue(OPEN_SOURCE); + vi.mocked(multiSelect) + .mockReset() + .mockImplementation(async (opts) => opts.choices.filter((c) => c.checked).map((c) => c.value) as never); vi.mocked(installHooks).mockClear(); vi.mocked(runPostSetupAudit).mockClear(); - vi.mocked(outro).mockClear(); - // Supported-and-already-healthy is the safe default for every test that - // isn't specifically about the daemon step: it makes step 0 a one-line - // no-op ("already installed and running — leaving it alone") without - // demanding sudo — and, now that an unsupported platform hard-fails setup - // before a single prompt is drawn, without aborting every other test in - // this file. Tests that actually exercise the daemon step override these. + vi.mocked(trackHookEvent).mockClear(); + vi.mocked(detectInstalledClis).mockReset().mockReturnValue(["claude"]); + vi.mocked(notEnforcingReason).mockReset().mockReturnValue("no-policies"); + vi.mocked(enforcingPolicyCount).mockReset().mockReturnValue({ count: 0, custom: false }); + vi.mocked(connectToCloud) + .mockReset() + .mockResolvedValue({ policy: { ok: true, policyCount: 2, deployment: 7 }, ingest: { ok: true }, anyConfigured: true }); + vi.mocked(validateIngestKey).mockReset().mockResolvedValue({ ok: true }); + vi.mocked(introspectKey).mockReset().mockResolvedValue({ kind: "unsupported" }); + // Healthy and already running is the default: the DAEMON step is one line + // and needs no password. Daemon tests override these. vi.mocked(isDaemonSupportedPlatform).mockReset().mockReturnValue(true); vi.mocked(daemonServiceStatus).mockReset().mockReturnValue("running"); vi.mocked(daemonServiceNeedsUpgrade).mockReset().mockReturnValue(false); + vi.mocked(ensureDaemonServiceCurrent).mockReset().mockResolvedValue({ outcome: "current" }); vi.mocked(installDaemonService).mockReset().mockResolvedValue({ installed: true }); - // Reset too, or call counts leak across tests and "was never asked for sudo" - // silently passes on history from an earlier one. vi.mocked(primeElevation).mockReset().mockReturnValue(true); - // Same reason: "a healthy daemon was left alone" asserts a call count of - // zero, which the broken-worker test above would otherwise satisfy for it. + vi.mocked(canElevate).mockReset().mockReturnValue(true); vi.mocked(probeDaemonEndToEnd).mockReset().mockResolvedValue(true); vi.mocked(probeDaemon).mockReset().mockResolvedValue({ ok: true }); vi.mocked(uninstallDaemonService).mockReset().mockResolvedValue(undefined); }); - -describe("configure-wizard pure builders", () => { - it("buildAgentChoices pre-checks detected CLIs and sections the rest", () => { - const choices = buildAgentChoices("user", "/tmp/proj"); - const claude = choices.find((c) => c.value === "claude"); - expect(claude?.checked).toBe(true); - expect(claude?.section).toBe("Detected"); - const codex = choices.find((c) => c.value === "codex"); - expect(codex?.section).toBe("Not installed · set up ahead of time"); - // every integration is represented (sourced dynamically from INTEGRATION_TYPES) - expect(choices.length).toBe(INTEGRATION_TYPES.length); - }); - - it("reviewLines summarizes scope, assistants, policy count and target files", () => { - const lines = reviewLines({ - clis: ["claude"], - target: "user", - policies: ["block-sudo", "block-rm-rf"], - cwd: "/tmp/proj", - }).join("\n"); - expect(lines).toContain("Everywhere (global)"); - expect(lines).toContain("Claude Code"); - expect(lines).toContain("2 enabled"); - expect(lines).toContain("policies-config.json"); - expect(lines).toContain("settings.json"); - }); - - it("reviewLines gives a taste of the policies without listing them all", () => { - // Two names say what KIND of thing these are; naming all fourteen turned a - // four-line review into a thirteen-line one, and a screen nobody reads to - // the bottom conveys less than a short one. - const lines = reviewLines({ - clis: ["claude"], - target: "user", - policies: [...FOURTEEN_ENABLED], - cwd: "/tmp/proj", - }); - const joined = lines.join("\n"); - expect(joined).toContain("14 enabled"); - expect(joined).toContain("block-curl-pipe-sh, block-env-files +12"); - // The other thirteen are NOT on screen. - expect(joined).not.toContain("sanitize-private-key-content"); - // One line for the count, one for the taste — never a paragraph. - expect(lines.filter((l) => l.includes("block-curl-pipe-sh"))).toHaveLength(1); - }); - - it("reviewLines keeps every line inside the 80-column budget", () => { - // `writeLines` truncates with a hard cut and no ellipsis, so an over-long - // line does not visibly lose its tail — it ends mid-slug and reads as a - // policy name that does not exist. - for (const line of reviewLines({ - clis: ["claude"], - target: "user", - policies: [...FOURTEEN_ENABLED], - cwd: "/tmp/proj", - })) { - expect(line.length, `too wide: ${line}`).toBeLessThanOrEqual(80); - } - }); - - it("the taste stays two names however large the enabled set gets", () => { - // A pack can enable an unbounded number of policies, so the taste has to be - // bounded by the LINE, not by the set. Generated rather than taken from a - // fixed list: the point is that the count grows and the line does not. - const many = Array.from({ length: 60 }, (_, i) => `block-thing-${i}`); - const lines = reviewLines({ - clis: ["claude"], - target: "user", - policies: many, - cwd: "/tmp/proj", - }).join("\n"); - expect(lines).toContain(`${many.length} enabled`); - expect(lines).toContain(`+${many.length - 2}`); - }); - - it("policyNamesLine drops names rather than overflowing the budget", () => { - // Degrading by dropping a name is recoverable; overflowing is not, because - // the hard cut makes the tail look like a policy name that does not exist. - const long = [ - "sanitize-private-key-content", - "sanitize-connection-strings", - "block-failproofai-commands", - ]; - for (const line of policyNamesLine(long)) { - expect(line.startsWith(" ".repeat(15))).toBe(true); - expect(line.length).toBeLessThanOrEqual(77); - } - expect(policyNamesLine([])).toEqual([]); - // A single policy needs no "+N" at all. - expect(policyNamesLine(["block-sudo"])[0].trim()).toBe("block-sudo"); - }); - - it("reviewLines reports an empty policy set as a choice, not a count of zero", () => { - const lines = reviewLines({ - clis: ["claude"], - target: "user", - policies: [], - cwd: "/tmp/proj", - }).join("\n"); - expect(lines).toContain("none enabled"); - expect(lines).not.toContain("0 enabled"); - // Tell the user where to change their mind, so an intentional "none" does - // not read like the wizard dropped the selection. - expect(lines).toContain("failproofai policies --install"); - }); - - // The 3-column gutter ("└ ") that outro() prepends when rendered. - const GUTTER = 3; - - it("keeps the completion summary within 80 columns for the longest real combination", () => { - // Every builtin policy, every supported CLI, and every optional note - // present at once — the worst case now that an unsupported platform - // aborts before this line is ever reached (so "no daemon" can no longer - // shrink it). - const message = buildCompletionSummary( - 99, // headroom above today's real policy count - INTEGRATION_TYPES.length, - true, // custom enabled - true, // daemon installed - true, // connected - ); - expect(message.length + GUTTER).toBeLessThanOrEqual(80); - expect(message).toContain("custom"); - expect(message).toContain("daemon"); - expect(message).toContain("reporting"); - }); - - it("keeps the completion summary within 80 columns with custom policies explicitly off", () => { - // "off" is the longer of the two custom-policies tags, and stacks with - // the other two notes the same way "custom" does above. - const message = buildCompletionSummary(99, INTEGRATION_TYPES.length, false, true, true); - expect(message.length + GUTTER).toBeLessThanOrEqual(80); - expect(message).toContain("custom off"); - }); - - it("counts the enabled policies rather than naming a bundle it did not pick", () => { - // The summary used to name the bundles the user had just ticked. There are no - // bundles and no policy step any more, so a count is the only thing this line - // can honestly say — and it must say it in the right number, since "1 policies" - // on the last screen of setup reads as a bug in everything above it. - expect(buildCompletionSummary(1, 1, undefined, false, false)).toBe( - "Setup complete — 1 policy · 1 harness", - ); - expect(buildCompletionSummary(3, 1, undefined, false, false)).toBe( - "Setup complete — 3 policies · 1 harness", - ); - }); - - 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 harness"); - }); +afterEach(() => { + vi.restoreAllMocks(); }); -describe("configure-wizard orchestration", () => { - it("installs at the chosen scope, tagged as the wizard, REPLACING the enabled set", async () => { - drive({ connect: "local", review: "apply" }); +const c = paint(false); +const kit = screenKit({ cols: 80, color: false }); - const result = await runConfigureWizard(ttyIO()); +// ── Pure builders ──────────────────────────────────────────────────────────── - expect(result.applied).toBe(true); - expect(installHooks).toHaveBeenCalledTimes(1); - const call = vi.mocked(installHooks).mock.calls[0]; - expect(call[1]).toBe("user"); // scope - expect(call[4]).toBe("configure-wizard"); // source tag - expect(call[7]).toEqual([...INTEGRATION_TYPES]); // every supported agent - expect(call[8]).toEqual({ replace: true, quiet: true }); // options - }); - - // ── Setup enables nothing of its own ───────────────────────────────────── - // - // failproofai ships no policies now: they arrive as packs. A wizard that - // pre-ticks a list makes a product decision for somebody who has not seen the - // list, so the only honest value to write is whatever the scope already had. - // - // `replace: true` makes this load-bearing in BOTH directions. Write more than - // was there and setup silently enables policies nobody chose; write less and - // re-running setup silently switches off policies they did. - it("writes back exactly the policies the scope already had, adding none of its own", async () => { - // Seeded as a real file rather than a mock: `readScopedHooksConfig` is the - // genuine implementation in this suite, and it reads user scope out of the - // HOME this file isolates. - const cfgPath = resolve(fileHome, ".failproofai", "policies-config.json"); - mkdirSync(dirname(cfgPath), { recursive: true }); - const theirs = ["block-kubectl", "some-pack-policy"]; - writeFileSync(cfgPath, JSON.stringify({ enabledPolicies: theirs })); - try { - drive(HAPPY); - - await runConfigureWizard(ttyIO()); - - // Equality, not `toContain`: a single name of ours slipping in is exactly - // the regression this exists for, and `toContain` cannot see it. - expect(vi.mocked(installHooks).mock.calls[0][0]).toEqual(theirs); - } finally { - rmSync(cfgPath, { force: true }); - } +describe("planAgents", () => { + it("first run: ticks what is detected, lists all twelve, detected first", () => { + const plan = planAgents({ detected: ["codex", "claude"], hooked: [] }); + expect(plan.mode).toBe("first"); + expect(plan.initial).toEqual(["claude", "codex"]); + expect(plan.order).toHaveLength(INTEGRATION_TYPES.length); + expect(plan.order.slice(0, 2)).toEqual(["claude", "codex"]); + expect(plan.seen).toEqual(["claude", "codex"]); }); - it("writes an empty policy list when the scope has nothing enabled", async () => { - // No config file at all — the state every brand-new machine is in. Setup - // still completes and still wires the hooks, so a pack added later enforces - // without re-running the wizard. - rmSync(resolve(fileHome, ".failproofai", "policies-config.json"), { force: true }); - drive(HAPPY); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(true); - expect(installHooks).toHaveBeenCalledTimes(1); - const call = vi.mocked(installHooks).mock.calls[0]; - expect(call[0]).toEqual([]); // nothing enabled, and nothing invented - expect(call[7]).toEqual([...INTEGRATION_TYPES]); // every agent, regardless - expect(call[8]).toEqual({ replace: true, quiet: true }); // empty set REPLACES + it("rerun: restores the saved selection exactly, plus anything new, ticked", () => { + const plan = planAgents({ + detected: ["claude", "codex", "goose", "hermes"], + saved: { selected: ["claude", "openclaw"], seen: ["claude", "codex", "goose"] }, + hooked: [], + }); + expect(plan.mode).toBe("rerun"); + // codex and goose were seen and left unticked: they stay unticked. hermes + // was never seen: it arrives ticked. openclaw is not detected but saved. + expect(new Set(plan.initial)).toEqual(new Set(["claude", "hermes", "openclaw"])); + expect([...plan.fresh]).toEqual(["hermes"]); + }); + + it("seen accumulates, so a PATH glitch cannot make an unticked agent new again", () => { + const glitch = planAgents({ + detected: ["claude"], + saved: { selected: ["claude"], seen: ["claude", "goose"] }, + hooked: [], + }); + expect(glitch.seen).toContain("goose"); + const back = planAgents({ detected: ["claude", "goose"], saved: { selected: ["claude"], seen: glitch.seen }, hooked: [] }); + expect(back.fresh.size).toBe(0); + expect(back.initial).toEqual(["claude"]); }); - it("applies globally, always — scope is not a question any more", async () => { - // Scope was a fork, and it is gone: a project-scoped install guards the one - // directory the command was run from and silently leaves every other repo - // on the machine unguarded. `policies --install --scope project` is still - // there for somebody who genuinely wants that and knows they do. - drive({ connect: "local", review: "apply" }); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(true); - const call = vi.mocked(installHooks).mock.calls[0]; - expect(call[1]).toBe("user"); // global, never the cwd's project - expect(call[8]).toEqual({ replace: true, quiet: true }); + it("upgrade: what is protected today, neither widened nor narrowed", () => { + // A machine set up since 1.0.2 has every agent hooked, detected or not. + const plan = planAgents({ detected: ["claude"], hooked: [...INTEGRATION_TYPES] }); + expect(plan.mode).toBe("upgrade"); + expect(new Set(plan.initial)).toEqual(new Set(INTEGRATION_TYPES)); }); - it("asks NOTHING about agents, and wires every supported one", async () => { - // Hooks alone enforce nothing now that no policy ships, so wiring them - // everywhere costs a config entry and changes no behaviour until a pack - // arrives — while an agent installed next week is guarded from its first - // tool call instead of running unguarded until somebody re-runs setup. - // Which agents a PACK guards is chosen at `policies add`, against a real list. - drive({ connect: "local", review: "apply" }); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(true); - expect(vi.mocked(multiSelect)).not.toHaveBeenCalled(); - const clis = vi.mocked(installHooks).mock.calls[0]![7] as IntegrationType[]; - expect(clis.length).toBe(clisSupportingScope("user").length); + it("drops names it does not know instead of offering them", () => { + const plan = planAgents({ detected: ["claude"], saved: { selected: ["claude", "claud"], seen: ["nope"] }, hooked: [] }); + expect(plan.initial).toEqual(["claude"]); + expect(plan.seen).toEqual(["claude"]); }); - it("'Everything available' protects every supported CLI", async () => { - drive({ connect: "local", review: "apply" }); - await runConfigureWizard(ttyIO()); - const call = vi.mocked(installHooks).mock.calls[0]; - // Every supported CLI, detected or not — there is no row to tick any more. - expect(call[7]).toEqual([...INTEGRATION_TYPES]); + it("keeps a saved empty selection: an unattended re-run puts nothing back", () => { + const plan = planAgents({ detected: ["claude"], saved: { selected: [], seen: ["claude"] }, hooked: [] }); + expect(defaultSelection(plan)).toEqual([]); + expect(agentsCollapsedLines(kit, plan, [], false, c)).toEqual(["AGENTS", "▲ Tracing no agents. · failproofai config"]); }); - it("asks no multi-select at all — neither policies nor agents", async () => { - drive({ connect: "local", review: "apply" }); - - await runConfigureWizard(ttyIO()); - - // Both multi-selects setup used to run are gone. A call here means one of - // them came back. - expect(vi.mocked(multiSelect).mock.calls).toHaveLength(0); + it("defaults to every agent only when nothing is detected, hooked or saved", () => { + expect(defaultSelection(planAgents({ detected: [], hooked: [] }))).toHaveLength(INTEGRATION_TYPES.length); + expect(defaultSelection(planAgents({ detected: ["claude"], hooked: [] }))).toEqual(["claude"]); }); - it("never writes into the repository's own config when applying at project scope", async () => { - // The defect this pins: project scope resolves its config from - // process.cwd(), which under test is this repo, so an applied run wrote - // `customPoliciesEnabled: false` into the tracked dogfood config — and the - // next `git add -A` committed custom policies switched off for everyone. - // Isolating HOME did not help, because project scope never reads HOME. - const repoConfig = resolve(process.cwd(), ".failproofai", "policies-config.json"); - const before = existsSync(repoConfig) ? readFileSync(repoConfig, "utf8") : null; - - drive({ connect: "local", review: "apply" }); - await runConfigureWizard(ttyIO()); - - const after = existsSync(repoConfig) ? readFileSync(repoConfig, "utf8") : null; - expect(after).toBe(before); + it("names what a selection adds and removes against the baseline", () => { + const plan = planAgents({ detected: ["claude", "goose"], saved: { selected: ["claude", "goose"], seen: ["claude", "goose"] }, hooked: [] }); + expect(agentChanges(plan, ["claude", "codex"])).toEqual({ added: ["codex"], removed: ["goose"] }); + // A first run adds nothing back: nothing was traced before it. + expect(agentChanges(planAgents({ detected: ["claude"], hooked: [] }), ["claude"]).added).toEqual([]); }); +}); - it("cancelling at the review step makes no changes", async () => { - drive({ connect: "local", review: "cancel" }); - const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(false); - expect(installHooks).not.toHaveBeenCalled(); +describe("agent rows and tags", () => { + it("one row per agent, ticked as the plan opens", () => { + const rows = agentChoices(planAgents({ detected: ["claude"], hooked: [] })); + expect(rows).toHaveLength(INTEGRATION_TYPES.length); + expect(rows[0]).toMatchObject({ label: "Claude Code", value: "claude", checked: true }); + expect(rows.filter((r) => r.checked)).toHaveLength(1); }); - it("cancelling at the first question makes no changes", async () => { - // That question is the HARNESS step now — the scope and mode forks that used - // to precede it are gone, so a ctrl-c lands on `multiSelect`, not `selectOne`. - vi.mocked(multiSelect).mockResolvedValueOnce(null as never); // harnesses → quit - const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(false); - expect(installHooks).not.toHaveBeenCalled(); + it("tags new, will be added, will be removed and not installed, in that order", () => { + const plan = planAgents({ + detected: ["claude", "hermes", "goose"], + saved: { selected: ["claude", "goose"], seen: ["claude", "goose"] }, + hooked: [], + }); + const tag = agentTag(plan, c); + expect(tag("hermes", true)).toBe("new on this machine"); + expect(tag("openclaw", true)).toBe("will be added"); + expect(tag("goose", false)).toBe("will be removed"); + expect(tag("pi", false)).toBe("not installed"); + expect(tag("claude", true)).toBeUndefined(); }); - it("applies in a non-TTY context instead of returning guidance", async () => { - // It used to print "needs an interactive terminal" and do nothing, so no - // CI job, container or agent could configure a machine at all. There is - // nothing to confirm when nobody is watching, and `failproofai config` is - // itself the authorisation — somebody typed the command whose entire job - // is to configure this machine. Requiring a flag on top of that asked the - // same question twice. - // - // Safe because the IMPLICIT path is guarded separately: - // `maybeFirstRunConfigure` has its own TTY check and returns before ever - // reaching the wizard, so this never fires off the back of another command. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - const stdout = mkTtyStdout(); - const result = await runConfigureWizard({ - stdin: { isTTY: false } as unknown as TTYIn, - stdout, - }); - expect(result.applied).toBe(true); - expect(installHooks).toHaveBeenCalled(); + it("puts no change tags on a first run, where every tick would read as added", () => { + const tag = agentTag(planAgents({ detected: ["claude"], hooked: [] }), c); + expect(tag("claude", true)).toBeUndefined(); + expect(tag("codex", true)).toBe("not installed"); }); }); -describe("first-run redirect", () => { - let origHome: string | undefined; - let tmp: string; - - beforeEach(() => { - origHome = process.env.HOME; - delete process.env.FAILPROOFAI_NO_FIRST_RUN; - tmp = mkdtempSync(resolve(tmpdir(), "fpai-firstrun-")); - process.env.HOME = tmp; +describe("agentsCollapsedLines", () => { + it("names the agents when nothing changed, wrapped inside the width", () => { + const plan = planAgents({ detected: [...INTEGRATION_TYPES], hooked: [] }); + const lines = agentsCollapsedLines(kit, plan, plan.initial, false, c); + expect(lines[0]).toBe("AGENTS"); + expect(lines[1]).toMatch(/^✓ Tracing 12 agents: Claude Code, /); + for (const line of lines) expect(line.length).toBeLessThanOrEqual(80); + expect(lines.slice(2).every((l) => l.startsWith(" "))).toBe(true); + }); + + it("says what was added and removed, promising only what the code does", () => { + const plan = planAgents({ detected: ["claude", "goose"], saved: { selected: ["claude", "goose"], seen: ["claude", "goose"] }, hooked: [] }); + const connected = agentsCollapsedLines(kit, plan, ["claude", "openclaw", "hermes"], true, c).join("\n"); + expect(connected).toContain("✓ Tracing 3 agents"); + expect(connected).toContain("Adding Hermes and OpenClaw."); + expect(connected).toContain( + "Removing Goose: it will no longer be hooked or collected. Sessions already collected are kept.", + ); + // No history clause anywhere (decision D7). + expect(connected).not.toMatch(/days|history/); + // Open source collects nothing, so it promises nothing about collection. + const local = agentsCollapsedLines(kit, plan, ["claude"], false, c).join("\n"); + expect(local).toContain("Removing Goose: it will no longer be hooked."); + expect(local).not.toContain("collected"); }); +}); - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - try { - rmSync(tmp, { recursive: true, force: true }); - } catch { - /* ignore */ - } +describe("cloud helpers", () => { + it("resolves --url, then FAILPROOFAI_CLOUD_URL, then the existing connection, then hosted", () => { + expect(resolveCloudUrl(undefined, undefined)).toEqual({ ok: true, url: "https://app.befailproof.ai" }); + expect(resolveCloudUrl(undefined, "https://self.example.com")).toEqual({ ok: true, url: "https://self.example.com" }); + process.env.FAILPROOFAI_CLOUD_URL = "http://localhost:8080"; + expect(resolveCloudUrl(undefined, "https://self.example.com")).toEqual({ ok: true, url: "http://localhost:8080", source: "FAILPROOFAI_CLOUD_URL" }); + expect(resolveCloudUrl("http://localhost:9911", undefined)).toMatchObject({ url: "http://localhost:9911", source: "--url" }); }); - it("does nothing when FAILPROOFAI_NO_FIRST_RUN=1", async () => { - process.env.FAILPROOFAI_NO_FIRST_RUN = "1"; - const handled = await maybeFirstRunConfigure(ttyIO()); - expect(handled).toBe(false); - expect(selectOne).not.toHaveBeenCalled(); - delete process.env.FAILPROOFAI_NO_FIRST_RUN; + it("refuses an unusable override instead of falling back to hosted", () => { + const r = resolveCloudUrl("http://cloud.example.com", undefined); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain("--url is"); }); - it("prints a hint but does not redirect in a non-TTY context", async () => { - const stdout = mkTtyStdout(); - const handled = await maybeFirstRunConfigure({ - stdin: { isTTY: false } as unknown as TTYIn, - stdout, - }); - expect(handled).toBe(false); - const written = vi.mocked(stdout.write).mock.calls.map((c) => String(c[0])).join(""); - expect(written).toContain("failproofai config"); - expect(hasSeenLauncher()).toBe(false); // not marked in non-TTY - }); - - it("runs the wizard on a fresh first run but does NOT mark seen if cancelled", async () => { - vi.mocked(multiSelect).mockResolvedValueOnce(null as never); // wizard cancels immediately - const handled = await maybeFirstRunConfigure(ttyIO()); - expect(handled).toBe(true); // it took over the turn (no dashboard) - expect(hasSeenLauncher()).toBe(false); // cancelled → not marked → redirects again next time - expect(existsSync(resolve(tmp, ".failproofai", ".launcher-configured"))).toBe(false); - expect(installHooks).not.toHaveBeenCalled(); - expect(runPostSetupAudit).not.toHaveBeenCalled(); // no apply → no auto-audit + it("accepts a key that can do both, naming its organisation", async () => { + vi.mocked(introspectKey).mockResolvedValue({ kind: "ok", identity: { orgName: "Acme", permissions: ["events:add", "policies:pull"] } }); + expect(await checkCloudKey("https://x.example.com", "k")).toEqual({ ok: true, org: "Acme", note: undefined }); + expect(validateIngestKey).not.toHaveBeenCalled(); }); - it("marks the launcher seen only after a completed apply", async () => { - drive({ connect: "local", review: "apply" }); - const handled = await maybeFirstRunConfigure(ttyIO()); - expect(handled).toBe(true); - expect(installHooks).toHaveBeenCalledTimes(1); - expect(hasSeenLauncher()).toBe(true); - expect(runPostSetupAudit).toHaveBeenCalledTimes(1); // completed apply → auto-audit handoff + it("accepts half a key with a note, and refuses one that can do neither", async () => { + vi.mocked(introspectKey).mockResolvedValueOnce({ kind: "ok", identity: { permissions: ["events:add"] } }); + expect(await checkCloudKey("u", "k")).toMatchObject({ ok: true, note: expect.stringContaining("policies:pull") }); + vi.mocked(introspectKey).mockResolvedValueOnce({ kind: "ok", identity: { permissions: ["jev:evaluate"] } }); + expect(await checkCloudKey("u", "k")).toEqual({ ok: false, refused: true, reason: "it is missing the events:add permission" }); }); - it("does not redirect again once the launcher has been seen", async () => { - markLauncherSeen(); // simulate a prior completed setup - const handled = await maybeFirstRunConfigure(ttyIO()); - expect(handled).toBe(false); - expect(selectOne).not.toHaveBeenCalled(); + it("tells a refusal from not being able to ask", async () => { + vi.mocked(introspectKey).mockResolvedValueOnce({ kind: "rejected" }); + expect(await checkCloudKey("u", "k")).toMatchObject({ ok: false, refused: true }); + vi.mocked(introspectKey).mockResolvedValueOnce({ kind: "unreachable", reason: "ENOTFOUND" }); + expect(await checkCloudKey("u", "k")).toEqual({ ok: false, refused: false, reason: "ENOTFOUND" }); }); - it("hard-fails cleanly on an unsupported platform, and does not nag again next command", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); - - const first = await maybeFirstRunConfigure(ttyIO()); - expect(first).toBe(true); // took over the turn - expect(hasSeenLauncher()).toBe(false); // never completed - expect(installHooks).not.toHaveBeenCalled(); + it("falls back to the ingest probe on a server too old for introspect", async () => { + vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "the server rejected that key (401)" }); + expect(await checkCloudKey("https://x.example.com", "k")).toMatchObject({ ok: false, refused: true }); + expect(vi.mocked(validateIngestKey).mock.calls[0]![0]).toEqual({ url: "https://x.example.com/v1/events", key: "k" }); + vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "could not reach the server (fetch failed)" }); + expect(await checkCloudKey("u", "k")).toMatchObject({ ok: false, refused: false }); + }); - // The next command must not relaunch the wizard and hard-fail again. - const second = await maybeFirstRunConfigure(ttyIO()); - expect(second).toBe(false); // a one-line hint instead of a relaunch - expect(selectOne).not.toHaveBeenCalled(); + it("collapses the CONNECT step to what was checked, never what was written", () => { + const at = { host: "app.befailproof.ai", savedHost: "app.befailproof.ai", savedOrg: "acme" }; + expect(cloudCollapsedLines(kit, { kind: "oss" }, at)).toEqual(["CONNECT TO CLOUD", "✓ Using open source."]); + expect(cloudCollapsedLines(kit, { kind: "kept", how: "skip", verified: false }, at)[1]).toBe( + "✓ Not connecting. The connection to app.befailproof.ai is unchanged.", + ); + expect(cloudCollapsedLines(kit, { kind: "kept", how: "saved", verified: true }, at)[1]).toBe( + "✓ Connected as acme to app.befailproof.ai", + ); + expect(cloudCollapsedLines(kit, { kind: "connect", token: "k", org: "acme" }, at)[1]).toBe( + "✓ Key accepted for acme on app.befailproof.ai", + ); + expect(cloudCollapsedLines(kit, { kind: "oss" }, { ...at, source: "--url" })[0]).toBe( + "CONNECT TO CLOUD app.befailproof.ai · from --url", + ); }); }); -describe("assistant selection summary", () => { - // The "Everything available" row is a selector, not an assistant. Counting it - // produced "13 assistants · Everything available, Claude Code, …" for the 12 - // supported CLIs — an off-by-one visible on the wizard's own summary line. - it("excludes the Everything-available selector from the assistant count", () => { - const real = INTEGRATION_TYPES.map((t) => getIntegration(t).displayName); - // What the wizard used to pass: the selector row alongside every CLI. - expect(summarize(["Everything available", ...real], "assistants")).toContain( - `${real.length + 1} assistants`, +describe("doneLines", () => { + it("gives the one generic warning, and our pack, when nothing enforces", () => { + const lines = doneLines(kit, { elapsedMs: 2100, done: [], cwd: "/tmp/x", agents: 9, connected: false }); + expect(lines[0]).toBe("DONE in 2.1s"); + expect(lines).toContain("▲ Policies are not enforcing yet."); + expect(lines).toContain(" Turn on ours: failproofai policies add FailproofAI/policies"); + expect(lines).toContain(" Connect to cloud any time: failproofai config"); + }); + + it("counts what enforces, on how many agents", () => { + vi.mocked(notEnforcingReason).mockReturnValue(null); + vi.mocked(enforcingPolicyCount).mockReturnValue({ count: 10, custom: false }); + const lines = doneLines(kit, { elapsedMs: 800, done: [], cwd: "/tmp/x", agents: 8, connected: true }); + expect(lines).toContain("✓ 10 policies are enforcing on 8 agents."); + expect(lines.join("\n")).not.toContain("Connect to cloud any time"); + vi.mocked(enforcingPolicyCount).mockReturnValue({ count: 0, custom: true }); + expect(doneLines(kit, { elapsedMs: 1, done: [], cwd: "/x", agents: 1, connected: true })).toContain( + "✓ Your custom policies are enforcing on 1 agent.", ); - // What it passes now — the selector is marked summaryExclude. - const fixed = summarize(real, "assistants"); - expect(fixed).toContain(`${real.length} assistants`); - expect(fixed).not.toContain("Everything available"); }); -}); -describe("scope-aware assistant selection", () => { - // Regression: the wizard offered all 12 CLIs at project scope and expanded - // "Everything available" to all 12, so applying died with - // `Scope "project" is not supported by Hermes` — after every question had - // been answered, with nothing written. - it("locks CLIs that cannot be configured at the chosen scope", () => { - const projectRows = buildAgentChoices("project", "/tmp/proj"); - const locked = projectRows.filter((c) => c.locked); - expect(locked.length).toBeGreaterThan(0); - for (const row of locked) { - expect(row.checked).toBe(false); // never selectable, so never installed - expect(getIntegration(row.value).scopes).not.toContain("project"); + it("names a pack that failed to load, and claims no custom policies there are none of", () => { + // Enforcing only because the refused pack fails closed: nothing counted, + // no custom files. + vi.mocked(notEnforcingReason).mockReturnValue(null); + vi.mocked(enforcingPolicyCount).mockReturnValue({ count: 0, custom: false }); + const packDir = mkdtempSync(resolve(tmpdir(), "fpai-wizard-packs-")); + vi.stubEnv("FAILPROOFAI_PACK_DIR", packDir); + try { + writeFileSync(resolve(packDir, "installed.json"), "{not json"); + const lines = doneLines(kit, { elapsedMs: 1, done: [], cwd: "/x", agents: 3, connected: true }); + expect(lines.join("\n")).not.toContain("custom policies"); + expect(lines).toContain("✕ A pack failed to load, so the calls it covers are denied. · failproofai policies"); + + // Alongside what does count, it is still said. + vi.mocked(enforcingPolicyCount).mockReturnValue({ count: 4, custom: false }); + const counted = doneLines(kit, { elapsedMs: 1, done: [], cwd: "/x", agents: 3, connected: true }); + expect(counted).toContain("✓ 4 policies are enforcing on 3 agents."); + expect(counted.some((l) => l.startsWith("✕ A pack failed to load"))).toBe(true); + } finally { + vi.unstubAllEnvs(); + rmSync(packDir, { recursive: true, force: true }); } }); +}); - it("locks nothing at user scope, which every CLI supports", () => { - expect(buildAgentChoices("user", "/tmp/proj").filter((c) => c.locked)).toHaveLength(0); +describe("doneLines — custom policy files", () => { + it("names a policy file that will not load, which the review step used to", () => { + const dir = mkdtempSync(resolve(tmpdir(), "fpai-done-")); + try { + mkdirSync(resolve(dir, ".failproofai", "policies"), { recursive: true }); + writeFileSync(resolve(dir, ".failproofai", "policies", "team.mjs"), "export {};\n"); + const lines = doneLines(kit, { elapsedMs: 1, done: [], cwd: dir, agents: 1, connected: true }).join("\n"); + expect(lines).toContain("is NOT loaded — rename to team-policies.mjs"); + expect(lines).toMatch(/▲ .*team\.mjs/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); +}); - it("clisSupportingScope excludes the user-only gateways from project scope", () => { - const project = clisSupportingScope("project"); - const user = clisSupportingScope("user"); - expect(user).toHaveLength(INTEGRATION_TYPES.length); - expect(project.length).toBeLessThan(user.length); - for (const id of project) expect(getIntegration(id).scopes).toContain("project"); +describe("hookedAtUserScope — Hermes", () => { + let hermes: string; + beforeEach(() => { + hermes = resolve(fileHome, ".hermes"); + mkdirSync(hermes, { recursive: true }); + vi.stubEnv("HERMES_HOME", hermes); + }); + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(hermes, { recursive: true, force: true }); }); - // writeLines truncates with a hard cut and no ellipsis, so an over-long - // closing line does not merely lose its tail — it reads as broken output. - // Naming all ten CLIs made it 182 characters against an 80-column terminal, - // cutting off the custom-policy note entirely and then stopping mid-word. - it("keeps the closing line inside an 80-column terminal", async () => { - const stdout = mkTtyStdout(); - // The widest line this can produce: user scope, which every CLI supports, - // and a policy count wide enough to be worth measuring. The count is no - // longer bounded by a builtin list — a pack can enable any number — so it is - // seeded rather than assumed. User scope, not project, because project reads - // its config from `process.cwd()`, which under test is this repo: the count - // would then be whatever the dogfood config happens to hold that week. - const userConfig = resolve(fileHome, ".failproofai", "policies-config.json"); - mkdirSync(dirname(userConfig), { recursive: true }); + it("counts a profile still on the legacy shell hooks: its tool calls reach failproofai today", () => { writeFileSync( - userConfig, - JSON.stringify({ - enabledPolicies: Array.from({ length: 999 }, (_, i) => `block-thing-${i}`), - }), + resolve(hermes, "config.yaml"), + ["hooks:", " pre_tool_call:", " - command: failproofai --hook pre_tool_call --cli hermes", ""].join("\n"), ); - try { - drive({ connect: "local", review: "apply" }); - - await runConfigureWizard({ stdin: mkTtyStdin(), stdout }); - - const message = vi.mocked(outro).mock.calls[0]![0]; - expect(message).toContain("Setup complete"); - expect(message).toContain("999 policies"); - // 3 columns of gutter ("└ ") sit in front of it when rendered. - expect(message.length + 3).toBeLessThanOrEqual(80); - expect(message).toContain("harnesses"); // the tail survived - } finally { - rmSync(userConfig, { force: true }); - } + expect(hookedAtUserScope("hermes", "/tmp")).toBe(true); + // So an upgrade keeps it ticked, and selecting it moves it onto the plugin. + const plan = planAgents({ detected: ["claude", "hermes"], hooked: ["claude", "hermes"] }); + expect(plan.initial).toEqual(["claude", "hermes"]); }); - it("applies to only the scope-supported CLIs when Everything available is ticked", async () => { - // Measured against USER scope now, because that is the only scope setup - // writes. Under the old project/both options this had to exclude the - // gateways with no project config; at user scope every integration - // qualifies, and the assertion is that none is silently dropped. - drive({ connect: "local", review: "apply" }); - - await runConfigureWizard(ttyIO()); + it("does not count a profile with nothing routed to failproofai", () => { + writeFileSync(resolve(hermes, "config.yaml"), "model: gpt-5\n"); + expect(hookedAtUserScope("hermes", "/tmp")).toBe(false); + }); - const clis = vi.mocked(installHooks).mock.calls[0]![7] as IntegrationType[]; - expect(clis.length).toBe(clisSupportingScope("user").length); - for (const id of clis) expect(getIntegration(id).scopes).toContain("user"); + it("does not count a plugin directory with the plugin not enabled", () => { + writeFileSync(resolve(hermes, "config.yaml"), "plugins:\n enabled: [failproofai]\n disabled: [failproofai]\n"); + expect(hookedAtUserScope("hermes", "/tmp")).toBe(false); }); }); -describe("configure-wizard daemon integration", () => { - function globalConfigPath(): string { - return fpConfigFile(); - } - function readGlobalConfig(): Record { - // Layout 2 moved this flag out of policies-config.json and into - // config.toml [daemon]. Shaped back to the old key so the assertions below - // keep reading as statements about daemonConfigured rather than about TOML. - const cfg = readFpConfig(); - return cfg.daemon.configured ? { daemonConfigured: true } : {}; - } - - // fileHome (and therefore the global config file) is shared across every - // test in this file — a prior test's daemonConfigured: true write would - // otherwise leak into a later test that expects it to be absent. - beforeEach(() => { - rmSync(globalConfigPath(), { force: true }); - // Same leak, one file over: an earlier test's completed apply leaves the - // marker behind, so an abort test asserting "not marked seen" would read a - // previous test's success as its own. - rmSync(launcherMarker(fileHome), { force: true }); - vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); - vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(false); - vi.mocked(ensureDaemonServiceCurrent).mockClear(); - // The telemetry assertions below locate their event with `.find()`, which - // would otherwise match an identically-named event emitted by an earlier - // test in this file and assert against the wrong run's props. - vi.mocked(trackHookEvent).mockClear(); +describe("removeUserScopeHooks", () => { + it("takes hooks out of that agent's user settings and touches no policy config", () => { + const userCfg = resolve(fileHome, ".failproofai", "policies-config.json"); + mkdirSync(dirname(userCfg), { recursive: true }); + writeFileSync(userCfg, JSON.stringify({ enabledPolicies: ["block-sudo"], policyParams: { x: { y: 1 } } })); + const projectCfg = resolve(WIZARD_TEST_CONFIG_DIR, "policies-config.json"); + writeFileSync(projectCfg, JSON.stringify({ enabledPolicies: ["block-rm-rf"] })); + const before = [readFileSync(userCfg, "utf8"), readFileSync(projectCfg, "utf8")]; + const goose = getIntegration("goose"); + const settings = goose.getSettingsPath("user"); + mkdirSync(dirname(settings), { recursive: true }); + writeFileSync(settings, "{}"); + const remove = vi.spyOn(goose, "removeHooksFromFile").mockReturnValue(3); + const claudeRemove = vi.spyOn(getIntegration("claude"), "removeHooksFromFile"); + + expect(removeUserScopeHooks(["goose"], "/tmp/x")).toBe(3); + + expect(remove).toHaveBeenCalledWith(settings); + expect(claudeRemove).not.toHaveBeenCalled(); + expect([readFileSync(userCfg, "utf8"), readFileSync(projectCfg, "utf8")]).toEqual(before); + rmSync(userCfg, { force: true }); + rmSync(projectCfg, { force: true }); }); +}); - it("installs the daemon and marks it configured, with no question asked", async () => { - // The daemon is REQUIRED now, so there is no step-0 prompt to answer — - // a supported platform simply gets one. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - drive(HAPPY); +// ── The run ────────────────────────────────────────────────────────────────── +describe("runConfigureWizard", () => { + it("asks two things — the key, then the agents — and installs at user scope", async () => { const result = await runConfigureWizard(ttyIO()); expect(result.applied).toBe(true); - expect(result.daemonInstalled).toBe(true); - expect(installDaemonService).toHaveBeenCalledTimes(1); - expect(readGlobalConfig().daemonConfigured).toBe(true); + expect(promptCloudKey).toHaveBeenCalledTimes(1); + expect(multiSelect).toHaveBeenCalledTimes(1); + expect(agentOpts().message).toBe("Which agents should failproofai trace?"); + expect(installHooks).toHaveBeenCalledTimes(1); + const call = vi.mocked(installHooks).mock.calls[0]!; + expect(call[1]).toBe("user"); + expect(call[4]).toBe("configure-wizard"); + expect(call[7]).toEqual(["claude"]); + expect(call[8]).toEqual({ replace: true, quiet: true }); }); - it("primes sudo before anything is drawn", async () => { - // `sudo -v` must prompt on a clean terminal. Fired from underneath a - // rendered TUI the prompt is invisible and the typed password lands in a - // redrawn frame. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - const order: string[] = []; - vi.mocked(primeElevation).mockImplementation(() => { - order.push("sudo"); - return true; - }); - vi.mocked(selectOne).mockImplementation(async () => { - order.push("select"); - return order.filter((o) => o === "select").length === 1 ? "user" : "apply"; - }); - vi.mocked(multiSelect).mockImplementation(async () => { - order.push("multi"); - return []; - }); - + it("saves the selection, and what it saw", async () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + drive({ agents: ["goose"] }); await runConfigureWizard(ttyIO()); - - expect(order[0]).toBe("sudo"); + expect(readFpConfig().agents).toEqual({ selected: ["goose"], seen: ["claude", "goose"] }); }); - it("ABORTS without writing anything when sudo cannot be obtained", async () => { - // Required means required. A machine that cannot install the service is - // left exactly as it was found rather than carrying half a config — and - // critically, `daemonConfigured` is never set, because a machine with that - // flag and no reachable daemon denies every tool call on it. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(primeElevation).mockReturnValue(false); - drive(HAPPY); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(false); - expect(result.abort).toBe("needs_root"); - expect(installHooks).not.toHaveBeenCalled(); - expect(installDaemonService).not.toHaveBeenCalled(); - expect(readGlobalConfig().daemonConfigured).toBeUndefined(); - // Not marked seen: the next command must offer setup again, because none - // of it happened. - expect(hasSeenLauncher()).toBe(false); + it("opens a rerun on the saved selection, with what is new ticked", async () => { + updateConfig({ agents: { selected: ["claude"], seen: ["claude"] } }); + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "hermes"]); + await runConfigureWizard(ttyIO()); + const opts = agentOpts(); + expect(opts.choices.filter((ch) => ch.checked).map((ch) => ch.value)).toEqual(["claude", "hermes"]); + const meta = typeof opts.meta === "function" ? opts.meta(["claude", "hermes"]) : ""; + expect(meta).toBe("2 of 12 selected · from last setup"); + // The longest heading this can draw still fits 80 columns. + expect(`${opts.message.toUpperCase()} ${typeof opts.meta === "function" ? opts.meta([...INTEGRATION_TYPES]) : ""}`.length).toBeLessThanOrEqual(80); + expect(opts.minSelected).toBe(1); + expect(opts.minMessage).toBe("Pick at least one agent."); + }); + + it("seeds an upgrade from the agents hooked at user scope today", async () => { + vi.spyOn(getIntegration("codex"), "hooksInstalledInSettings").mockImplementation((scope) => scope === "user"); + await runConfigureWizard(ttyIO()); + expect(agentOpts().choices.filter((ch) => ch.checked).map((ch) => ch.value)).toEqual(["codex"]); + const meta = agentOpts().meta; + expect(typeof meta === "function" && meta([...INTEGRATION_TYPES])).toBe("12 of 12 selected · as protected today"); }); - it("runs with no terminal at all when the answers were supplied", async () => { - // The whole point of headless setup: `--yes` answers the one question that - // is left, so the terminal requirement no longer applies. Before this there - // was NO non-interactive path to a configured machine at all — - // `installDaemonService` had exactly one caller, the wizard, and the wizard - // refused without a TTY. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - const result = await runConfigureWizard(headlessIO()); - - expect(result.applied).toBe(true); - expect(installHooks).toHaveBeenCalled(); + it("writes back exactly the policies already enabled, adding none of its own", async () => { + const cfgPath = resolve(fileHome, ".failproofai", "policies-config.json"); + mkdirSync(dirname(cfgPath), { recursive: true }); + writeFileSync(cfgPath, JSON.stringify({ enabledPolicies: ["block-kubectl", "some-pack-policy"] })); + try { + await runConfigureWizard(ttyIO()); + expect(vi.mocked(installHooks).mock.calls[0]![0]).toEqual(["block-kubectl", "some-pack-policy"]); + } finally { + rmSync(cfgPath, { force: true }); + } }); - it("draws no prompt of any kind when answered", async () => { - // Not "the prompts default sensibly" — they must not RUN. A prompt that - // degrades to its default on a non-TTY is how a script silently gets a - // decision nobody made. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - await runConfigureWizard(headlessIO()); - - expect(vi.mocked(selectOne)).not.toHaveBeenCalled(); - expect(vi.mocked(multiSelect)).not.toHaveBeenCalled(); - expect(vi.mocked(promptText)).not.toHaveBeenCalled(); - }); + it("removes hooks only for agents unticked, at user scope", async () => { + updateConfig({ agents: { selected: ["claude", "goose"], seen: ["claude", "goose"] } }); + const goose = vi.spyOn(getIntegration("goose"), "removeHooksFromFile").mockReturnValue(1); + const settings = getIntegration("goose").getSettingsPath("user"); + mkdirSync(dirname(settings), { recursive: true }); + writeFileSync(settings, "{}"); + drive({ agents: ["claude"] }); - it("asks sudo for nothing it cannot be answered for", async () => { - // `primeElevation` runs `sudo -v`, which PROMPTS — the one gate that would - // hang an unattended run. It goes straight to the non-interactive check - // instead, which is what `failproofai update` already does. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - await runConfigureWizard(headlessIO()); + const result = await runConfigureWizard(ttyIO()); - expect(vi.mocked(primeElevation)).not.toHaveBeenCalled(); - expect(vi.mocked(canElevate)).toHaveBeenCalled(); + expect(result.agents).toEqual({ selected: ["claude"], added: [], removed: ["goose"] }); + expect(goose).toHaveBeenCalledWith(settings); + expect(vi.mocked(installHooks).mock.calls[0]![7]).toEqual(["claude"]); }); - it("stays local when no key was given, and connects when one was", async () => { - // Supplying a key IS the request to connect; there is no other reason to - // pass one, so there is no second flag to remember. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - const local = await runConfigureWizard(headlessIO()); - expect(local.connected).toBeFalsy(); - - const connected = await runConfigureWizard(headlessIO(), { token: "k".repeat(20) }); - expect(connected.connected).toBe(true); + it("never writes into the repository's own config", async () => { + const repoConfig = resolve(process.cwd(), ".failproofai", "policies-config.json"); + const before = existsSync(repoConfig) ? readFileSync(repoConfig, "utf8") : null; + await runConfigureWizard(ttyIO()); + expect(existsSync(repoConfig) ? readFileSync(repoConfig, "utf8") : null).toBe(before); }); - it("fails rather than saving a key the server refused", async () => { - // The interactive path offers "save it anyway", because a person can weigh - // an outage against their own impatience. A script cannot, and one that - // exited 0 here would leave a fleet believing it was reporting. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "401" } as never); - - const result = await runConfigureWizard(headlessIO(), { token: "k".repeat(20) }); - - expect(result.applied).toBe(false); - expect(result.abort).toBe("cloud_unverified"); + it("Esc at the key leaves everything untouched, the daemon included", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + drive({ cloud: null }); + const result = await runConfigureWizard(ttyIO()); + expect(result).toEqual({ applied: false, abort: "cancelled" }); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(connectToCloud).not.toHaveBeenCalled(); expect(installHooks).not.toHaveBeenCalled(); }); - it("asks NOTHING when a key was supplied, terminal or not", async () => { - // Somebody typing `failproofai config --token ` has said: set this - // machine up, connect it, send its data. Asking "Connect to Cloud?", then - // for the key, then "Ready to apply?" made the flag look unread — reported - // from a real run. The only thing left worth stopping for is the sudo - // password, which is a credential rather than a question. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - const result = await runConfigureWizard(ttyIO(), { token: "k".repeat(20) }); + it("Esc at the agents leaves everything untouched, a typed key included", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + drive({ cloud: { kind: "typed", key: "a-real-looking-key" }, agents: null }); + const result = await runConfigureWizard(ttyIO()); + expect(result.abort).toBe("cancelled"); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(connectToCloud).not.toHaveBeenCalled(); + expect(readFpConfig().agents).toBeUndefined(); + }); - expect(vi.mocked(selectOne)).not.toHaveBeenCalled(); - expect(vi.mocked(promptText)).not.toHaveBeenCalled(); - expect(result.applied).toBe(true); - expect(result.connected).toBe(true); + it("draws the design: logo, header, then a section per step, then DONE", async () => { + const io = ttyIO(); + await runConfigureWizard(io); + const out = printed(io.stdout); + expect(out).toContain("Set up this machine"); + expect(out.indexOf("DAEMON")).toBeLessThan(out.indexOf("DONE")); + expect(out).toContain("✓ failproofaid"); + expect(out).toContain("▲ Policies are not enforcing yet."); }); - it("still asks on a terminal when NOTHING was supplied", async () => { - // The flag suppresses the questions it answers. With no flag there is a - // person there and the questions are the point. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - drive(HAPPY); + it("sends the agent counts with the completion event", async () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "codex"]); await runConfigureWizard(ttyIO()); - expect(vi.mocked(selectOne)).toHaveBeenCalled(); + const props = vi.mocked(trackHookEvent).mock.calls.find((call) => call[1] === "configure_applied")![2] as Record; + expect(props).toMatchObject({ mode: "oss", agents_selected: 2, agents_detected: 2, agents_changed: false }); }); +}); - it("sets the machine up when there is no terminal, rather than refusing", async () => { - // It used to print "needs an interactive terminal" and decline — so a CI - // job, a container or an agent could not configure a machine at all. There - // is nothing to confirm when nobody is watching, and `failproofai config` - // is itself the authorisation: somebody typed the command whose whole job - // is to configure this machine. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); +describe("headless runs", () => { + it("sets the machine up with no terminal, asking nothing", async () => { const result = await runConfigureWizard(headlessIO()); - expect(result.applied).toBe(true); + expect(promptCloudKey).not.toHaveBeenCalled(); + expect(multiSelect).not.toHaveBeenCalled(); + expect(primeElevation).not.toHaveBeenCalled(); expect(installHooks).toHaveBeenCalled(); }); - it("reports WHY it configured nothing when run under sudo", async () => { - // Same class: it configured nothing and it was not a cancellation, so it - // must not read as success. `running_as_sudo` was likewise declared and - // never assigned. - const getuid = process.getuid; - Object.defineProperty(process, "getuid", { value: () => 0, configurable: true }); - vi.stubEnv("SUDO_USER", "chetan"); - try { - const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(false); - expect(result.abort).toBe("running_as_sudo"); - expect(installHooks).not.toHaveBeenCalled(); - } finally { - Object.defineProperty(process, "getuid", { value: getuid, configurable: true }); - vi.unstubAllEnvs(); - } + it("keeps the saved selection, or takes the detected agents", async () => { + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "codex"]); + await runConfigureWizard(headlessIO()); + expect(vi.mocked(installHooks).mock.calls[0]![7]).toEqual(["claude", "codex"]); + updateConfig({ agents: { selected: ["codex"], seen: ["claude", "codex"] } }); + vi.mocked(installHooks).mockClear(); + await runConfigureWizard(headlessIO()); + expect(vi.mocked(installHooks).mock.calls[0]![7]).toEqual(["codex"]); }); - it("ABORTS without writing anything when the service will not install", async () => { - // The reason the daemon installs BEFORE any user config: a failure here - // has to be undoable, and the only way to guarantee that is to have - // written nothing yet. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(installDaemonService).mockResolvedValue({ - installed: false, - reason: "systemctl enable failed", - }); - drive(HAPPY); - - const result = await runConfigureWizard(ttyIO()); + it("changes nothing about a connection when no key was given", async () => { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "s".repeat(20) }); + const result = await runConfigureWizard(headlessIO()); + expect(result.mode).toBe("cloud"); + expect(connectToCloud).not.toHaveBeenCalled(); + }); - expect(result.applied).toBe(false); - expect(result.abort).toBe("daemon_failed"); + it("hooks nothing on a re-run whose saved selection is empty", async () => { + updateConfig({ agents: { selected: [], seen: ["claude"] } }); + const result = await runConfigureWizard(headlessIO()); + expect(result.applied).toBe(true); expect(installHooks).not.toHaveBeenCalled(); - expect(readGlobalConfig().daemonConfigured).toBeUndefined(); - expect(hasSeenLauncher()).toBe(false); + expect(readFpConfig().agents?.selected).toEqual([]); }); - it("hard-fails on an unsupported platform, before drawing a single prompt", async () => { - // A Windows machine (or any non-linux/darwin platform) has nothing - // running failproofaid — completing setup anyway used to leave it - // reading as configured while enforcing in-process, with no fail-closed - // guarantee. Refusing outright is the honest failure. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); - drive(HAPPY); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(false); - expect(result.abort).toBe("unsupported_platform"); - expect(selectOne).not.toHaveBeenCalled(); + it("takes --agents as given and skips the step", async () => { + await runConfigureWizard(ttyIO(), { agents: ["goose", "claude"] }); expect(multiSelect).not.toHaveBeenCalled(); - expect(primeElevation).not.toHaveBeenCalled(); - expect(installDaemonService).not.toHaveBeenCalled(); - expect(installHooks).not.toHaveBeenCalled(); - expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + expect(vi.mocked(installHooks).mock.calls[0]![7]).toEqual(["claude", "goose"]); }); +}); - it("explains why, naming the platform, when it hard-fails on an unsupported platform", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); - const stdout = mkTtyStdout(); - - await runConfigureWizard({ stdin: mkTtyStdin(), stdout }); +describe("connect step", () => { + it("draws the field with the host, and the saved key when there is one", async () => { + await runConfigureWizard(ttyIO()); + expect(cloudOpts()).toMatchObject({ message: "Connect to cloud", meta: "app.befailproof.ai", tabHint: "use open source instead" }); + expect(cloudOpts().saved).toBeUndefined(); + }); - const written = vi.mocked(stdout.write).mock.calls.map((c) => String(c[0])).join(""); - expect(written).toContain("Linux"); - expect(written).toContain("macOS"); + it("connects a typed key at apply, with transcripts on", async () => { + drive({ cloud: { kind: "typed", key: "a-real-looking-key" } }); + const result = await runConfigureWizard(ttyIO()); + expect(result.connected).toBe(true); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ + url: "https://app.befailproof.ai", + token: "a-real-looking-key", + sessions: true, + }); }); - it("skips the install, and the password prompt, when a daemon is already running", async () => { - // Re-running setup on a configured machine must not demand sudo for work - // that is already done. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - drive(HAPPY); + it("checks a typed key without writing it, and reports a refusal", async () => { + vi.mocked(validateIngestKey).mockResolvedValue({ ok: false, reason: "the server rejected that key (401)" }); + await runConfigureWizard(ttyIO()); + expect(await cloudOpts().check("bad-key-123")).toEqual({ + ok: false, + line: "That key was refused: the server rejected that key (401).", + }); + expect(connectToCloud).not.toHaveBeenCalled(); + }); + it("keeps a saved connection as it is on enter, without reconnecting", async () => { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "saved-key-3f2a" }); + drive({ cloud: { kind: "saved", verified: true } }); const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(true); - expect(primeElevation).not.toHaveBeenCalled(); - expect(installDaemonService).not.toHaveBeenCalled(); - // Still configured — the daemon is there, it just did not need installing. - expect(result.daemonInstalled).toBe(true); - expect(readGlobalConfig().daemonConfigured).toBe(true); + expect(cloudOpts().saved?.masked).toBe("****3f2a"); + expect(cloudOpts().tabHint).toBe("skip"); + expect(connectToCloud).not.toHaveBeenCalled(); + expect(result.mode).toBe("cloud"); + expect(readFpConfig().mode).toBe("cloud"); }); - it("refreshes a running daemon whose unit predates FAILPROOFAI_CLI_CMD", async () => { - // The upgrade case. Nothing else on the machine ever rewrites the unit — - // `npm i -g failproofai@latest` does not touch /etc/systemd/system — so - // without this the daemon has no way to spawn an audit for the rest of the - // machine's life while config.toml says the scheduled scan is on. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); - vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ outcome: "rewritten" }); - vi.mocked(daemonServiceFilePath).mockReturnValue("/etc/systemd/system/failproofaid@test.service"); - drive(HAPPY); + it("checks the saved key in the background and says what it found", async () => { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "saved-key-3f2a" }); + await runConfigureWizard(ttyIO()); + expect(await cloudOpts().saved!.check()).toEqual({ ok: true, line: "Your saved key works: ****3f2a" }); + vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "the server rejected that key (401)" }); + expect(await cloudOpts().saved!.check()).toEqual({ ok: false, line: "Your saved key no longer works: the server rejected that key (401)" }); + vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "could not reach the server (offline)" }); + expect(await cloudOpts().saved!.check()).toMatchObject({ ok: false, usable: true }); + }); + + it("tab never disconnects a connected machine (decision D4)", async () => { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "saved-key-3f2a" }); + const io = ttyIO(); + const result = await runConfigureWizard(io); + expect(result.mode).toBe("cloud"); + expect(connectToCloud).not.toHaveBeenCalled(); + const collapsed = cloudOpts().collapsed!(OPEN_SOURCE).map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")); + expect(collapsed[1]).toBe("✓ Not connecting. The connection to app.befailproof.ai is unchanged."); + }); - const result = await runConfigureWizard(ttyIO()); + it("--oss skips the step and connects nothing", async () => { + const result = await runConfigureWizard(ttyIO(), { oss: true }); + expect(promptCloudKey).not.toHaveBeenCalled(); + expect(result.mode).toBe("oss"); + expect(connectToCloud).not.toHaveBeenCalled(); + }); - // The refresh rewrites a root-owned file and restarts the daemon, so the - // confirmation screen has to say so — a review that lists everything a run - // will touch except the privileged bit is worse than no review. - const review = vi.mocked(selectOne).mock.calls.find((c) => c[0].message === "Ready to apply?"); - expect(String(review?.[0].body)).toContain("/etc/systemd/system/failproofaid@test.service"); - vi.mocked(daemonServiceFilePath).mockReturnValue(null); + it("takes the endpoint from FAILPROOFAI_CLOUD_URL, and names it", async () => { + process.env.FAILPROOFAI_CLOUD_URL = "http://localhost:8080"; + drive({ cloud: { kind: "typed", key: "a-real-looking-key" } }); + await runConfigureWizard(ttyIO()); + expect(cloudOpts().meta).toBe("localhost:8080 · from FAILPROOFAI_CLOUD_URL"); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ url: "http://localhost:8080" }); + }); + it("refuses an unusable FAILPROOFAI_CLOUD_URL instead of falling back to hosted", async () => { + process.env.FAILPROOFAI_CLOUD_URL = "http://cloud.example.com"; + const io = ttyIO(); + drive({ cloud: OPEN_SOURCE }); + const result = await runConfigureWizard(io); + // Said above the field, which stays open: a typed key is refused there, + // without anything being sent anywhere. + expect(printed(io.stdout)).toContain('FAILPROOFAI_CLOUD_URL is set to "http://cloud.example.com", which cannot be used'); + const verdict = await cloudOpts().check!("a-real-looking-key"); + expect(verdict.ok).toBe(false); + expect(introspectKey).not.toHaveBeenCalled(); + expect(validateIngestKey).not.toHaveBeenCalled(); + // Tab still finishes setup as open source. expect(result.applied).toBe(true); - expect(ensureDaemonServiceCurrent).toHaveBeenCalledTimes(1); - // A refresh, never a reinstall: the daemon is up, and reinstalling would - // re-resolve a binary path that after a CLI upgrade is not on disk yet. - expect(installDaemonService).not.toHaveBeenCalled(); - expect(primeElevation).toHaveBeenCalled(); - expect(readGlobalConfig().daemonConfigured).toBe(true); + expect(result.mode).toBe("oss"); + expect(connectToCloud).not.toHaveBeenCalled(); }); - it("finishes setup anyway when the unit refresh fails", async () => { - // Only the scheduled audit is out of reach here — the daemon is up and - // hooks are enforcing. Aborting would make upgrading the package the thing - // that locked someone out of `failproofai config`. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); - vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ - outcome: "failed", - reason: "sudo: a password is required", - }); - drive(HAPPY); + it("does not stop --oss, or a keyless unattended run, over a URL they never use", async () => { + process.env.FAILPROOFAI_CLOUD_URL = "http://10.0.0.5:3000"; + const oss = await runConfigureWizard(headlessIO(), { oss: true }); + expect(oss).toMatchObject({ applied: true, mode: "oss" }); + expect(installHooks).toHaveBeenCalledTimes(1); + const keyless = await runConfigureWizard(headlessIO()); + expect(keyless).toMatchObject({ applied: true, mode: "oss" }); + expect(connectToCloud).not.toHaveBeenCalled(); + }); - const result = await runConfigureWizard(ttyIO()); + it("fails, not cancels, a --token run whose URL cannot be used", async () => { + process.env.FAILPROOFAI_CLOUD_URL = "http://10.0.0.5:3000"; + for (const io of [headlessIO(), ttyIO()]) { + const result = await runConfigureWizard(io, { token: "k".repeat(20) }); + // Not "cancelled": the CLI exits 0 for that, and nothing was set up. + expect(result).toEqual({ applied: false, abort: "bad_cloud_url" }); + } + expect(installHooks).not.toHaveBeenCalled(); + expect(introspectKey).not.toHaveBeenCalled(); + expect(connectToCloud).not.toHaveBeenCalled(); + }); + it("survives a key revoked between the check and the apply", async () => { + vi.mocked(connectToCloud).mockResolvedValue({ policy: { ok: false, reason: "403" }, ingest: { ok: false, reason: "403" }, anyConfigured: false }); + drive({ cloud: { kind: "typed", key: "a-real-looking-key" } }); + const io = ttyIO(); + const result = await runConfigureWizard(io); expect(result.applied).toBe(true); - expect(readGlobalConfig().daemonConfigured).toBe(true); - }); - - it("stops claiming a daemon when the refresh left it stopped", async () => { - // The refresh restarts a HEALTHY daemon, which is the one thing the - // "already running — leaving it alone" branch never used to do. If it - // cannot bring it back, keeping daemonConfigured set is not "no scheduled - // audit", it is every tool call across all 12 CLIs denied against a socket - // nothing is listening on, with no recovery short of hand-editing - // policies-config.json. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); - vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ - outcome: "failed", - reason: "failproofaid did not come back", - daemonRunning: false, - }); - drive(HAPPY); + expect(result.connected).toBe(false); + expect(printed(io.stdout)).toContain("▲ Not connected: 403"); + }); + it("does not fail setup when connecting throws outright", async () => { + vi.mocked(connectToCloud).mockRejectedValue(new Error("network down")); + drive({ cloud: { kind: "typed", key: "a-real-looking-key" } }); const result = await runConfigureWizard(ttyIO()); - - // Setup still completes — the hooks it wrote enforce in-process. expect(result.applied).toBe(true); - // Without the switch-back this reads `true`: the wizard seeds - // daemonInstalled from daemonAlreadyRunning, so a machine that was running - // a daemon re-asserts the flag at the end of every apply. - expect(readGlobalConfig().daemonConfigured).toBeUndefined(); - expect(readFpConfig().daemon.configured).toBe(false); - expect(result.daemonInstalled).toBe(false); + expect(result.connected).toBe(false); }); - it("rebuilds a daemon that runs but cannot evaluate anything", async () => { - // The lockout with no route back: `ExecStart` bakes in `process.execPath`, - // so an `nvm uninstall 20` months after setup leaves a unit systemd still - // calls active whose worker dies on every spawn. Every existing check - // passes that machine — the service manager says running, `Ping` is - // answered without touching the worker — so this wizard, the documented - // remedy, took the "already installed and running — leaving it alone" - // branch and changed nothing, while `daemonConfigured` denied every tool - // call including UserPromptSubmit. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - // False for the health check that classifies the machine as broken, then - // true for the post-install probe — the rebuilt daemon answers. - // Two different call sites: the boolean form DETECTS the broken daemon - // (first call, false), and the richer form VERIFIES the rebuild afterwards - // (must be ok, or the wizard aborts and nothing is applied). - vi.mocked(probeDaemonEndToEnd).mockResolvedValueOnce(false).mockResolvedValue(true); - vi.mocked(probeDaemon).mockResolvedValue({ ok: true }); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - drive(HAPPY); - - const result = await runConfigureWizard(ttyIO()); + it("asks nothing but sudo when a key is on the command line", async () => { + const result = await runConfigureWizard(ttyIO(), { token: "k".repeat(20) }); + expect(promptCloudKey).not.toHaveBeenCalled(); + expect(multiSelect).not.toHaveBeenCalled(); + expect(result.connected).toBe(true); + }); + it("asks again on a terminal when the --token key is refused", async () => { + vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "the server rejected that key (401)" }); + drive({ cloud: OPEN_SOURCE }); + const result = await runConfigureWizard(ttyIO(), { token: "k".repeat(20) }); + expect(promptCloudKey).toHaveBeenCalledTimes(1); expect(result.applied).toBe(true); - // Torn down BEFORE the reinstall: the dead unit holds the singleton flock - // the replacement needs, so installing over the top would start a daemon - // that loses the lock race and leave the machine exactly as broken. - expect(uninstallDaemonService).toHaveBeenCalled(); - expect(installDaemonService).toHaveBeenCalled(); - expect(result.daemonInstalled).toBe(true); - vi.mocked(probeDaemonEndToEnd).mockResolvedValue(true); - vi.mocked(probeDaemon).mockResolvedValue({ ok: true }); - }); - - it("refuses to finish setup when the freshly installed daemon cannot answer", async () => { - // The gap the probe exists for: `installDaemonService` can only report that - // the SERVICE is running, which systemd will happily say about a unit whose - // worker dies on every spawn. Setting `daemonConfigured` against it denies - // every tool call on the machine, `UserPromptSubmit` included. The daemon - // step runs before anything user-facing is written, so aborting here leaves - // the machine exactly as it was found. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - vi.mocked(probeDaemonEndToEnd).mockResolvedValue(false); - vi.mocked(probeDaemon).mockResolvedValue({ ok: false, reason: "worker" }); - drive(HAPPY); - - const result = await runConfigureWizard(ttyIO()); + expect(connectToCloud).not.toHaveBeenCalled(); + }); - expect(result.applied).toBe(false); - expect(result.abort).toBe("daemon_failed"); - // Nothing written, and above all the fail-closed flag never set. + it("fails a headless run whose key was refused, writing nothing", async () => { + vi.mocked(validateIngestKey).mockResolvedValueOnce({ ok: false, reason: "401" }); + const result = await runConfigureWizard(headlessIO(), { token: "k".repeat(20) }); + expect(result).toEqual({ applied: false, abort: "cloud_unverified" }); expect(installHooks).not.toHaveBeenCalled(); - expect(readFpConfig().daemon.configured).not.toBe(true); }); +}); - it("does not touch a daemon that is running AND answering", async () => { - // The other half of the branch above: the probe must not turn every - // healthy re-run into an uninstall/reinstall cycle that demands a password. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - vi.mocked(probeDaemonEndToEnd).mockResolvedValue(true); - vi.mocked(probeDaemon).mockResolvedValue({ ok: true }); - drive(HAPPY); +describe("backfill for agents traced again", () => { + function connectMachine(): void { + writeIngestCredential({ url: "https://app.befailproof.ai/v1/events", key: "s".repeat(20) }); + writeCollectorSettings({ sessions: true, hooks: true }); + } - const result = await runConfigureWizard(ttyIO()); + it("asks the daemon to start an added agent fresh, before the selection lands", async () => { + connectMachine(); + updateConfig({ agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + drive({ agents: ["claude", "goose"] }); - expect(result.applied).toBe(true); - expect(uninstallDaemonService).not.toHaveBeenCalled(); - expect(installDaemonService).not.toHaveBeenCalled(); + await runConfigureWizard(ttyIO()); + + const request = JSON.parse(readFileSync(backfillRequestPath(), "utf8")); + expect(request).toMatchObject({ kind: "added", agents: ["goose"] }); + expect(request.sinceMs).toBeUndefined(); }); - it("leaves a stale unit alone, without aborting, when root is unavailable", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("running"); - vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); - vi.mocked(primeElevation).mockReturnValue(false); - drive(HAPPY); + it("writes none on an upgrade: with no saved list the daemon was already collecting every agent", async () => { + connectMachine(); + vi.spyOn(getIntegration("claude"), "hooksInstalledInSettings").mockImplementation((scope) => scope === "user"); + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + drive({ agents: ["claude", "goose"] }); const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(true); - expect(result.abort).toBeUndefined(); - // Never attempted: the privileged write would fail, and a `sudo -n` - // failure per privileged command is noise, not information. - expect(ensureDaemonServiceCurrent).not.toHaveBeenCalled(); - vi.mocked(primeElevation).mockReturnValue(true); + // Goose is added to the hooks, and the screen says so, but it was never + // untraced: forgetting its cursors would only re-send what was sent. + expect(result.agents?.added).toEqual(["goose"]); + expect(existsSync(backfillRequestPath())).toBe(false); }); - it("reinstalls a daemon that is installed but NOT running", async () => { - // A crash-looped unit is exactly the machine that needs repair. Treating - // "installed" as good enough would skip it and then set daemonConfigured - // against a service that never answers — fail-closed on every tool call. - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(daemonServiceStatus).mockReturnValue("stopped"); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - drive(HAPPY); - + it("writes no request on an open-source machine, or when nothing was added", async () => { + updateConfig({ agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + vi.mocked(detectInstalledClis).mockReturnValue(["claude", "goose"]); + drive({ agents: ["claude", "goose"] }); await runConfigureWizard(ttyIO()); + expect(existsSync(backfillRequestPath())).toBe(false); - expect(primeElevation).toHaveBeenCalled(); - expect(installDaemonService).toHaveBeenCalledTimes(1); + connectMachine(); + await runConfigureWizard(ttyIO()); + expect(existsSync(backfillRequestPath())).toBe(false); }); +}); - it("installs the daemon before anything else, because it is the only step needing a password", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - drive(HAPPY); - - await runConfigureWizard(ttyIO()); +describe("daemon step", () => { + const readFlag = (): boolean => readFpConfig().daemon.configured; + it("installs a missing daemon at apply and marks it configured", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + const io = ttyIO(); + const result = await runConfigureWizard(io); + expect(result.daemonInstalled).toBe(true); expect(installDaemonService).toHaveBeenCalledTimes(1); + expect(readFlag()).toBe(true); + const out = printed(io.stdout); + expect(out).toContain("✓ Root granted to install failproofaid"); + expect(out).toContain("✓ failproofaid installed and running"); }); - it("sends a classification, never the raw reason, in the daemon-install telemetry", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(installDaemonService).mockResolvedValue({ - installed: false, - // Carries an OS username and the local filesystem layout — exactly what - // must not leave the machine. - reason: "EACCES: permission denied, open '/home/alice/.config/systemd/user/x.service'", - }); - drive(HAPPY); - + it("asks for sudo before any prompt draws", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + const order: string[] = []; + vi.mocked(primeElevation).mockImplementation(() => (order.push("sudo"), true)); + vi.mocked(promptCloudKey).mockImplementation(async () => (order.push("key"), OPEN_SOURCE)); await runConfigureWizard(ttyIO()); - - const call = vi - .mocked(trackHookEvent) - .mock.calls.find((c) => c[1] === "configure_daemon_install"); - expect(call).toBeDefined(); - const props = call![2] as Record; - expect(props.installed).toBe(false); - expect(props.reason).not.toContain("alice"); - expect(props.reason).not.toContain("/home/"); + expect(order).toEqual(["sudo", "key"]); }); - it("mentions the daemon in the outro when one is actually there", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); - drive(HAPPY); - await runConfigureWizard(ttyIO()); - expect(vi.mocked(outro).mock.calls[0]![0]).toContain("daemon"); - }); - - it("shows the daemon row in the review only when one will be installed", async () => { - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - const withDaemon = reviewLines({ - clis: ["claude"], - target: "user", - policies: ["block-sudo"], - cwd: "/tmp/proj", - installDaemon: true, - }).join("\n"); - expect(withDaemon).toContain("Daemon"); - expect(withDaemon).toContain("failproofaid"); - - // Promising a service the apply will not install is the failure mode here. - const declined = reviewLines({ - clis: ["claude"], - target: "user", - policies: ["block-sudo"], - cwd: "/tmp/proj", - installDaemon: false, - }).join("\n"); - expect(declined).not.toContain("Daemon"); - - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); - const unsupported = reviewLines({ - clis: ["claude"], - target: "user", - policies: ["block-sudo"], - cwd: "/tmp/proj", - }).join("\n"); - expect(unsupported).not.toContain("Daemon"); - }); - - it("states plainly whether anything will be reported", async () => { - // Bundling transcripts into "connect" is only acceptable if the review - // screen says so in as many words. - const local = reviewLines({ - clis: ["claude"], - target: "user", - policies: [], - cwd: "/tmp/proj", - connect: false, - }).join("\n"); - expect(local).toContain("nothing leaves this machine"); - - const connected = reviewLines({ - clis: ["claude"], - target: "user", - policies: [], - cwd: "/tmp/proj", - connect: true, - }).join("\n"); - expect(connected).toContain("transcripts"); + it("stops before writing anything when sudo cannot be had", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + vi.mocked(primeElevation).mockReturnValue(false); + const io = ttyIO(); + const result = await runConfigureWizard(io); + expect(result).toEqual({ applied: false, abort: "needs_root" }); + expect(promptCloudKey).not.toHaveBeenCalled(); + expect(installHooks).not.toHaveBeenCalled(); + expect(readFlag()).toBe(false); + expect(hasSeenLauncher()).toBe(false); + expect(printed(io.stdout)).toContain("✕ Couldn't get root, so setup stopped before changing anything."); }); -}); -describe("scope", () => { - // The wizard can no longer produce "project" or "both": scope was a fork, and - // the fork is gone. What used to be tested here — the union across scopes, the - // per-scope filtering of a user-scope-only gateway like Hermes — is still real - // in `installHooks`, but it is no longer REACHABLE from setup, so asserting - // the wizard does it would be asserting a path nobody can take. Those live on - // in `manager`'s own tests, against the function that still has them. - it("installs exactly once, at user scope", async () => { - drive(HAPPY); - const result = await runConfigureWizard(ttyIO()); - expect(result.scopes).toEqual(["user"]); - expect(installHooks).toHaveBeenCalledTimes(1); - expect(vi.mocked(installHooks).mock.calls[0][1]).toBe("user"); + + it("uses sudo -n, never a prompt, without a terminal", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + await runConfigureWizard(headlessIO()); + expect(primeElevation).not.toHaveBeenCalled(); + expect(canElevate).toHaveBeenCalled(); }); - it("writes nothing when cancelled at the harness step, the first question asked", async () => { - // The scope step was the old first cancellation point. With it gone, the - // harness step is where a ctrl-c lands, and it must still leave the machine - // untouched — the daemon is installed BEFORE this, so "nothing was changed" - // has to mean nothing about hooks or config. - drive({ connect: null }); + it("stops before writing anything when the service will not install", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + vi.mocked(installDaemonService).mockResolvedValue({ installed: false, reason: "systemctl enable failed" }); const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(false); - expect(result.abort).toBe("cancelled"); + expect(result).toEqual({ applied: false, abort: "daemon_failed" }); expect(installHooks).not.toHaveBeenCalled(); + expect(readFlag()).toBe(false); }); -}); -describe("connect step", () => { - beforeEach(() => { - vi.mocked(connectToCloud).mockClear(); - vi.mocked(validateIngestKey).mockClear().mockResolvedValue({ ok: true }); - // ONE prompt now, not two. The endpoint is no longer asked for: there is - // one right answer for the hosted product, and asking made it look like a - // decision — which is how a key gets pasted into the URL field and how the - // dashboard's own address gets typed at a prompt that wants the API server. - vi.mocked(promptText).mockReset().mockResolvedValueOnce("a-real-looking-key"); - delete process.env.FAILPROOFAI_CLOUD_URL; + it("refuses to finish when the fresh daemon cannot answer", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + vi.mocked(probeDaemonEndToEnd).mockResolvedValue(false); + vi.mocked(probeDaemon).mockResolvedValue({ ok: false, reason: "worker" }); + const result = await runConfigureWizard(ttyIO()); + expect(result.abort).toBe("daemon_failed"); + expect(installHooks).not.toHaveBeenCalled(); + expect(readFlag()).toBe(false); }); - afterEach(() => { - delete process.env.FAILPROOFAI_CLOUD_URL; + it("needs no password and installs nothing when the daemon is healthy", async () => { + const io = ttyIO(); + const result = await runConfigureWizard(io); + expect(primeElevation).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(uninstallDaemonService).not.toHaveBeenCalled(); + expect(result.daemonInstalled).toBe(true); + expect(readFlag()).toBe(true); + expect(printed(io.stdout)).toMatch(/✓ failproofaid \S+ is already running/); }); - it("connects with transcripts ON, as disclosed at the question", async () => { - // The product decision: connecting bundles decisions AND transcripts - // behind one clear disclosure. If sessions ever silently became false, - // the disclosure would be a lie in the other direction. - drive({ ...HAPPY, connect: "key" }); - - const result = await runConfigureWizard(ttyIO()); - + it("rebuilds a daemon that runs but cannot evaluate, keeping the consequence on screen", async () => { + vi.mocked(probeDaemonEndToEnd).mockResolvedValueOnce(false).mockResolvedValue(true); + const io = ttyIO(); + const result = await runConfigureWizard(io); expect(result.applied).toBe(true); - expect(result.connected).toBe(true); - expect(connectToCloud).toHaveBeenCalledTimes(1); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ - url: "https://app.befailproof.ai", - token: "a-real-looking-key", - sessions: true, - }); + expect(uninstallDaemonService).toHaveBeenCalled(); + expect(installDaemonService).toHaveBeenCalled(); + const out = printed(io.stdout); + expect(out).toContain("failproofaid can't evaluate — every tool call is denied. Rebuilding needs sudo."); + expect(out).toContain("✓ failproofaid rebuilt and running"); }); - it("never asks for the endpoint — only the key", async () => { - // The regression this guards: re-introducing the URL prompt silently makes - // the key the SECOND answer again, so every scripted or muscle-memory - // paste lands in the wrong field. - drive({ ...HAPPY, connect: "key" }); - + it("reinstalls a daemon that is installed but stopped", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("stopped"); await runConfigureWizard(ttyIO()); - - expect(vi.mocked(promptText)).toHaveBeenCalledTimes(1); - expect(vi.mocked(promptText).mock.calls[0][0].message).toMatch(/API key/); - }); - - it("takes the endpoint from FAILPROOFAI_CLOUD_URL when it is set", async () => { - // The local-development and self-hosting path. Deliberately the SAME - // variable the daemon already reads for cloud-managed policy, so one export - // points the whole machine at one place rather than leaving the wizard and - // the daemon disagreeing about where this machine reports. - process.env.FAILPROOFAI_CLOUD_URL = "http://localhost:8080"; - drive({ ...HAPPY, connect: "key" }); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.connected).toBe(true); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ - url: "http://localhost:8080", - token: "a-real-looking-key", - }); - }); - - it("refuses an unusable FAILPROOFAI_CLOUD_URL instead of falling back to hosted", async () => { - // Falling back would report the machine to the hosted service — the one - // outcome someone who exported this variable did not ask for, and one they - // would only discover by going looking for data that never arrived. - // `http://` to a NON-loopback host is refused for the original reason: it - // puts the machine's bearer token on the wire in clear. - process.env.FAILPROOFAI_CLOUD_URL = "http://cloud.example.com"; - drive({ ...HAPPY, connect: "key" }); - - const result = await runConfigureWizard(ttyIO()); - - expect(result.applied).toBe(false); - expect(connectToCloud).not.toHaveBeenCalled(); + expect(primeElevation).toHaveBeenCalled(); + expect(installDaemonService).toHaveBeenCalledTimes(1); }); - it("probes the key BEFORE the review screen", async () => { - // A typo is worth catching while the user is still thinking about - // credentials, not three screens later after they accepted a review. - drive({ ...HAPPY, connect: "key" }); - await runConfigureWizard(ttyIO()); - expect(validateIngestKey).toHaveBeenCalledTimes(1); + it("refreshes a stale unit instead of reinstalling", async () => { + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ outcome: "rewritten" }); + const io = ttyIO(); + const result = await runConfigureWizard(io); + expect(result.applied).toBe(true); + expect(ensureDaemonServiceCurrent).toHaveBeenCalledTimes(1); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(printed(io.stdout)).toContain("✓ failproofaid's service definition is up to date"); }); - it("lets a bad key be skipped, and still applies everything else", async () => { - vi.mocked(validateIngestKey).mockResolvedValue({ ok: false, reason: "401" }); - // connect -> key, then the retry question -> skip, then review. Queued - // positionally rather than through `drive()` because the retry prompt is - // conditional and has no name there. Two answers shorter than it was: the - // mode fork and the scope question are both gone. - vi.mocked(selectOne) - .mockResolvedValueOnce("key") - .mockResolvedValueOnce("skip") - .mockResolvedValueOnce("apply"); - vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]); - + it("finishes anyway when the unit refresh fails", async () => { + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ outcome: "failed", reason: "sudo: a password is required" }); const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(true); - expect(result.connected).toBe(false); - expect(connectToCloud).not.toHaveBeenCalled(); - expect(installHooks).toHaveBeenCalledTimes(1); + expect(readFlag()).toBe(true); }); - it("survives a key revoked between the probe and the apply", async () => { - // connectToCloud re-verifies and writes only what works, so this degrades - // to a reported partial rather than a connection the machine lacks. - vi.mocked(connectToCloud).mockResolvedValue({ - policy: { ok: false, reason: "403" }, - ingest: { ok: false, reason: "403" }, - anyConfigured: false, - }); - drive({ ...HAPPY, connect: "key" }); - + it("stops claiming a daemon the refresh left stopped", async () => { + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ outcome: "failed", reason: "did not come back", daemonRunning: false }); const result = await runConfigureWizard(ttyIO()); - - // Enforcement does not depend on the dashboard: setup still succeeded. expect(result.applied).toBe(true); - expect(result.connected).toBe(false); - expect(installHooks).toHaveBeenCalledTimes(1); + expect(result.daemonInstalled).toBe(false); + expect(readFlag()).toBe(false); }); - it("does not fail setup when connecting throws outright", async () => { - vi.mocked(connectToCloud).mockRejectedValue(new Error("network down")); - drive({ ...HAPPY, connect: "key" }); - + it("leaves a stale unit alone, without aborting, when root is unavailable", async () => { + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(primeElevation).mockReturnValue(false); const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(true); - expect(result.connected).toBe(false); + expect(ensureDaemonServiceCurrent).not.toHaveBeenCalled(); }); - it("writes nothing when cancelled at the connect step", async () => { - drive({ connect: null }); - const result = await runConfigureWizard(ttyIO()); - expect(result.applied).toBe(false); - expect(installHooks).not.toHaveBeenCalled(); - expect(connectToCloud).not.toHaveBeenCalled(); + it("sends a classification, never the raw reason, in the install telemetry", async () => { + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + reason: "EACCES: permission denied, open '/home/alice/.config/systemd/user/x.service'", + }); + await runConfigureWizard(ttyIO()); + const props = vi.mocked(trackHookEvent).mock.calls.find((call) => call[1] === "configure_daemon_install")![2] as Record; + expect(props.installed).toBe(false); + expect(String(props.reason)).not.toContain("alice"); }); -}); -describe("wizard back-navigation", () => { - // The three tests that stood here drove a ← from the harness step back to the - // policy step, and pinned that both answers survived the round trip. Both the - // step and the ← are gone: with nothing before the harness step inside setup - // — the scope question is frequently stated rather than asked — a ← would - // sometimes go nowhere, which is worse than not offering one. - it("has no step to go back from — every remaining question is a single choice", async () => { - // Back-navigation existed for the policy and harness multi-selects, both of - // which are gone. What is left is the daemon, connect, and the review. - drive({ connect: "local", review: "apply" }); - - await runConfigureWizard(ttyIO()); + it("refuses an unsupported platform before drawing anything", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + const io = ttyIO(); + const result = await runConfigureWizard(io); + expect(result).toEqual({ applied: false, abort: "unsupported_platform" }); + expect(promptCloudKey).not.toHaveBeenCalled(); + expect(primeElevation).not.toHaveBeenCalled(); + expect(printed(io.stdout)).toContain("Linux and macOS"); + }); - expect(vi.mocked(multiSelect)).not.toHaveBeenCalled(); - for (const [opts] of vi.mocked(selectOne).mock.calls) { - expect(opts.allowBack).toBeFalsy(); + it("reports why it configured nothing when run under sudo", async () => { + const getuid = process.getuid; + Object.defineProperty(process, "getuid", { value: () => 0, configurable: true }); + vi.stubEnv("SUDO_USER", "someone"); + try { + expect(await runConfigureWizard(ttyIO())).toEqual({ applied: false, abort: "running_as_sudo" }); + expect(installHooks).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, "getuid", { value: getuid, configurable: true }); + vi.unstubAllEnvs(); } }); }); describe("config --token with --url", () => { - // `config --token --url ` read X into `answers.url` and then used it - // only as a CONDITION — never as the URL — so the machine connected to - // FAILPROOFAI_CLOUD_URL or the hosted default and reported somewhere its - // operator had just said not to. The CLI fills `answers.url` from `--url`, - // falling back to the variable. let fpHome: string; let prevFpHome: string | undefined; beforeEach(() => { - // An earlier block leaves this mock rejecting; this block needs it to connect. - vi.mocked(connectToCloud) - .mockReset() - .mockResolvedValue({ policy: { ok: true, policyCount: 2, deployment: 7 }, ingest: { ok: true }, anyConfigured: true }); - vi.mocked(validateIngestKey).mockClear().mockResolvedValue({ ok: true }); - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - delete process.env.FAILPROOFAI_CLOUD_URL; prevFpHome = process.env.FAILPROOFAI_HOME; fpHome = mkdtempSync(resolve(tmpdir(), "fpai-wizard-url-")); process.env.FAILPROOFAI_HOME = fpHome; }); afterEach(() => { - delete process.env.FAILPROOFAI_CLOUD_URL; if (prevFpHome === undefined) delete process.env.FAILPROOFAI_HOME; else process.env.FAILPROOFAI_HOME = prevFpHome; rmSync(fpHome, { recursive: true, force: true }); }); - it("connects to the URL it was given", async () => { - const stdout = headlessIO().stdout; - const result = await runConfigureWizard( - { stdin: headlessIO().stdin, stdout }, - { token: "k".repeat(20), url: "http://localhost:9911" }, - ); + it("connects to the URL it was given, checks the key there, and names the source", async () => { + const io = headlessIO(); + const result = await runConfigureWizard(io, { token: "k".repeat(20), url: "http://localhost:9911" }); expect(result.connected).toBe(true); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ url: "http://localhost:9911" }); - // The key probe went there too, not to the hosted default. - expect(vi.mocked(validateIngestKey).mock.calls[0][0]).toMatchObject({ url: "http://localhost:9911/v1/events" }); - const written = vi.mocked(stdout.write).mock.calls.map((c) => String(c[0])).join(""); - expect(written).toContain("Using http://localhost:9911 (from --url)."); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ url: "http://localhost:9911" }); + expect(vi.mocked(validateIngestKey).mock.calls[0]![0]).toMatchObject({ url: "http://localhost:9911/v1/events" }); + expect(printed(io.stdout)).toContain("localhost:9911 · from --url"); }); it("prefers --url to FAILPROOFAI_CLOUD_URL", async () => { process.env.FAILPROOFAI_CLOUD_URL = "http://localhost:7000"; await runConfigureWizard(headlessIO(), { token: "k".repeat(20), url: "http://localhost:9911" }); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ url: "http://localhost:9911" }); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ url: "http://localhost:9911" }); }); - it("accepts the ingest endpoint as --url, like --connect does", async () => { + it("accepts the ingest endpoint as --url", async () => { await runConfigureWizard(headlessIO(), { token: "k".repeat(20), url: "https://cloud.example.com/v1/events" }); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ url: "https://cloud.example.com" }); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ url: "https://cloud.example.com" }); }); it("refuses an unusable --url rather than connecting somewhere else", async () => { - // Plain http to a non-loopback host would put the key on the wire in clear. const result = await runConfigureWizard(headlessIO(), { token: "k".repeat(20), url: "http://cloud.example.com" }); expect(result.applied).toBe(false); + expect(result.abort).toBe("bad_cloud_url"); expect(connectToCloud).not.toHaveBeenCalled(); }); - it("an already-enrolled machine given --url enrols THERE, not where it was", async () => { + it("an already-enrolled machine given --url enrols THERE", async () => { const { writeCloudCredentials } = await import("../../src/hooks/cloud-enrollment"); writeCloudCredentials({ url: "https://old.example.com", machineId: "m-1", token: "o".repeat(20) }); await runConfigureWizard(headlessIO(), { token: "k".repeat(20), url: "http://localhost:9911" }); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ url: "http://localhost:9911", token: "k".repeat(20) }); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ url: "http://localhost:9911", token: "k".repeat(20) }); }); - it("with neither, still uses the hosted default", async () => { + it("with neither, uses the hosted default", async () => { await runConfigureWizard(headlessIO(), { token: "k".repeat(20) }); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ url: "https://app.befailproof.ai" }); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ url: "https://app.befailproof.ai" }); }); }); describe("config --token with --no-transcripts", () => { - // The flag was parsed into `answers.noTranscripts` and never read: the apply - // step passed a literal `sessions: true`, so a run that asked for decisions - // only shipped every prompt, file and command output. - beforeEach(() => { - vi.mocked(connectToCloud) - .mockReset() - .mockResolvedValue({ policy: { ok: true, policyCount: 2, deployment: 7 }, ingest: { ok: true }, anyConfigured: true }); - vi.mocked(validateIngestKey).mockClear().mockResolvedValue({ ok: true }); - vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); - delete process.env.FAILPROOFAI_CLOUD_URL; - }); - - it("connects with transcripts OFF, and says so", async () => { + it("connects with transcripts off, and says so", async () => { const io = headlessIO(); const result = await runConfigureWizard(io, { token: "k".repeat(20), noTranscripts: true }); expect(result.connected).toBe(true); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ sessions: false }); - const written = vi.mocked(io.stdout.write).mock.calls.map((c) => String(c[0])).join(""); - expect(written).toContain("Session transcripts are NOT being sent (--no-transcripts)"); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ sessions: false }); + expect(printed(io.stdout)).toContain("Decisions only: session transcripts are not sent (--no-transcripts)."); }); - it("with a key that carries Jev, says Jev is available and prints nothing that reads as \"on\"", async () => { + it("says Jev is available, and nothing that reads as on", async () => { vi.mocked(connectToCloud).mockResolvedValue({ policy: { ok: true, policyCount: 2, deployment: 7 }, ingest: { ok: true }, @@ -1620,19 +1146,80 @@ describe("config --token with --no-transcripts", () => { }); const io = headlessIO(); await runConfigureWizard(io, { token: "k".repeat(20), noTranscripts: true }); - const written = vi.mocked(io.stdout.write).mock.calls.map((c) => String(c[0])).join(""); - const jevLines = written.split("\n").filter((l) => /\bJev\b/.test(l)); - expect(jevLines).toHaveLength(1); - expect(jevLines[0]).toContain("available on this key"); - expect(jevLines[0]).toContain("jev setup --provider failproofai"); - expect(written).not.toMatch(/Jev\s+on\b/); + const jev = printed(io.stdout).split("\n").filter((l) => /\bJev\b/.test(l)); + expect(jev).toHaveLength(1); + expect(jev[0]).toContain("available on this key"); }); - it("still defaults to transcripts ON without the flag", async () => { + it("defaults to transcripts on without the flag", async () => { const io = headlessIO(); await runConfigureWizard(io, { token: "k".repeat(20) }); - expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ sessions: true }); - const written = vi.mocked(io.stdout.write).mock.calls.map((c) => String(c[0])).join(""); - expect(written).not.toContain("--no-transcripts"); + expect(vi.mocked(connectToCloud).mock.calls[0]![0]).toMatchObject({ sessions: true }); + expect(printed(io.stdout)).not.toContain("--no-transcripts"); + }); +}); + +describe("first-run redirect", () => { + let origHome: string | undefined; + let origFpHome: string | undefined; + let tmp: string; + beforeEach(() => { + origHome = process.env.HOME; + origFpHome = process.env.FAILPROOFAI_HOME; + delete process.env.FAILPROOFAI_NO_FIRST_RUN; + tmp = mkdtempSync(resolve(tmpdir(), "fpai-firstrun-")); + // A fresh machine per test: the failproofai home moves with HOME, or the + // file-wide one carries what earlier tests applied. + process.env.HOME = tmp; + process.env.FAILPROOFAI_HOME = resolve(tmp, ".failproofai"); + }); + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origFpHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = origFpHome; + rmSync(tmp, { recursive: true, force: true }); + }); + + it("does nothing when FAILPROOFAI_NO_FIRST_RUN=1", async () => { + process.env.FAILPROOFAI_NO_FIRST_RUN = "1"; + expect(await maybeFirstRunConfigure(ttyIO())).toBe(false); + expect(promptCloudKey).not.toHaveBeenCalled(); + delete process.env.FAILPROOFAI_NO_FIRST_RUN; + }); + + it("prints a hint but does not redirect without a terminal", async () => { + const stdout = mkTtyStdout(); + expect(await maybeFirstRunConfigure({ stdin: { isTTY: false } as unknown as TTYIn, stdout })).toBe(false); + expect(printed(stdout)).toContain("failproofai config"); + expect(hasSeenLauncher()).toBe(false); + }); + + it("does not mark seen when the first run is cancelled", async () => { + drive({ cloud: null }); + expect(await maybeFirstRunConfigure(ttyIO())).toBe(true); + expect(hasSeenLauncher()).toBe(false); + expect(installHooks).not.toHaveBeenCalled(); + expect(runPostSetupAudit).not.toHaveBeenCalled(); + }); + + it("marks seen after a completed apply, then hands off to the audit", async () => { + expect(await maybeFirstRunConfigure(ttyIO())).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + expect(hasSeenLauncher()).toBe(true); + expect(runPostSetupAudit).toHaveBeenCalledTimes(1); + }); + + it("does not redirect again once seen", async () => { + markLauncherSeen(); + expect(await maybeFirstRunConfigure(ttyIO())).toBe(false); + expect(promptCloudKey).not.toHaveBeenCalled(); + }); + + it("hard-fails cleanly on an unsupported platform, and does not nag again", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + expect(await maybeFirstRunConfigure(ttyIO())).toBe(true); + expect(installHooks).not.toHaveBeenCalled(); + expect(await maybeFirstRunConfigure(ttyIO())).toBe(false); }); }); diff --git a/__tests__/hooks/custom-policy-discovery.test.ts b/__tests__/hooks/custom-policy-discovery.test.ts index 0320512bc..2d44b1365 100644 --- a/__tests__/hooks/custom-policy-discovery.test.ts +++ b/__tests__/hooks/custom-policy-discovery.test.ts @@ -17,7 +17,6 @@ import { discoverPolicyFiles, findSkippedPolicyFiles } from "../../src/hooks/cus import { describeCustomPolicies, setCustomPoliciesEnabled, - reviewLines, } from "../../src/hooks/configure-wizard"; let dir: string; @@ -171,36 +170,3 @@ describe("disabling custom policies", () => { expect(JSON.parse(readFileSync(cfg, "utf8")).customPoliciesEnabled).toBe(false); }); }); - -describe("the Custom choice is visible to the user", () => { - // The toggle worked but nothing on screen changed: the review screen said - // "(auto-loaded)" whether or not custom policies had been switched off. With - // no feedback anywhere, a working toggle is indistinguishable from a broken - // one — which is why the review screen has to reflect the DECISION and not - // merely what is on disk. - it("review screen says DISABLED when custom policies are switched off", () => { - write("team-policies.mjs"); - const off = reviewLines({ - target: "project", - clis: ["claude"], - policies: [], - cwd: dir, - customEnabled: false, - }).join("\n"); - expect(off).toContain("DISABLED"); - expect(off).not.toContain("(auto-loaded)"); - }); - - it("review screen says auto-loaded when custom policies are left on", () => { - write("team-policies.mjs"); - const on = reviewLines({ - target: "project", - clis: ["claude"], - policies: [], - cwd: dir, - customEnabled: true, - }).join("\n"); - expect(on).toContain("(auto-loaded)"); - expect(on).not.toContain("DISABLED"); - }); -}); diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts index cf9ee6947..4b6eb3f85 100644 --- a/__tests__/hooks/daemon-service.test.ts +++ b/__tests__/hooks/daemon-service.test.ts @@ -1165,6 +1165,42 @@ describe("refreshDaemonToCliVersion", () => { expect(result.lines.join("\n")).not.toContain("restarted and holding"); expect(result.lines.join("\n")).toContain("previous daemon is untouched"); }); + + it("says what to run when it needs root: get sudo, then THIS command again", async () => { + // The install's own reason is written for the wizard and the log — it says + // to re-run `failproofai config` and carries a whole unit file to paste. + // From `update` the answer is two lines, and names `update`. + const { version } = await import("../../package.json"); + const result = await svc.refreshDaemonToCliVersion({ + status: () => "running", + interactive: () => false, + install: async () => ({ + installed: false, + reason: + "root privileges are required to install the failproofaid system service, and sudo credentials were not available. " + + "Re-run `failproofai config` and approve the sudo prompt — To install by hand: sudo tee /etc/systemd/system/x.service <<'EOF'\n[Unit]\nEOF", + }), + }); + + expect(result.lines).toEqual([ + `✕ failproofaid ${version} was not installed: root privileges are required. The previous daemon is untouched.`, + " Run `sudo -v` and then `failproofai update` again.", + ]); + expect(result.lines.join("\n")).not.toMatch(/failproofai config|\[Unit\]|sudo tee/); + }); + + it("keeps any other failure's reason, and where to look", async () => { + const { version } = await import("../../package.json"); + const result = await svc.refreshDaemonToCliVersion({ + status: () => "running", + install: async () => ({ installed: false, reason: "failed to download failproofaid 9.9.9." }), + }); + + expect(result.lines[0]).toBe( + `✕ failproofaid ${version} could not be installed: failed to download failproofaid 9.9.9. The previous daemon is untouched.`, + ); + expect(result.lines[1]).toMatch(/^ {2}See why with `.+`\.$/); + }); }); /** diff --git a/__tests__/hooks/did-you-mean.test.ts b/__tests__/hooks/did-you-mean.test.ts new file mode 100644 index 000000000..e746988f3 --- /dev/null +++ b/__tests__/hooks/did-you-mean.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment node +/** + * "Did you mean …?" for a word or a top-level flag the CLI does not know. + * + * The suggester this replaced always answered with the nearest spelling of a + * subcommand, so `status` got `flush`, `install` got `uninstall` — the opposite + * of what was asked — and `-i` got `audit`. These pin the three things that + * fixed it: a table of words people type, a distance cut-off, and silence when + * nothing is close. + */ +import { describe, it, expect } from "vitest"; +import { nearestName, suggestCommand, suggestFlag } from "../../src/hooks/did-you-mean"; +import { INTEGRATION_TYPES } from "../../src/hooks/types"; + +/** The CLI's own list, as bin/failproofai.mjs declares it. */ +const SUBCOMMANDS = ["policies", "audit", "config", "uninstall", "backfill", "flush", "harness", "jev", "publish", "update", "migrate", "help"]; + +describe("suggestCommand — the words people type", () => { + it.each([ + ["status", "failproofai config --status"], + ["pause", "failproofai config --pause"], + ["resume", "failproofai config --resume"], + ["login", "failproofai config"], + ["setup", "failproofai config"], + ["install", "failproofai policies --install"], + ["logout", "failproofai config --disconnect"], + ["upgrade", "failproofai update"], + ["hook", "failproofai help hook"], + ["dashboard", "failproofai"], + ])("%s → %s", (word, suggestion) => { + expect(suggestCommand(word, SUBCOMMANDS)).toBe(suggestion); + }); + + it("answers `install` with what it asked for, never with `uninstall`", () => { + // Two edits apart, so a nearest-spelling guess within the cut-off would + // still pick it. The table is asked first. + expect(suggestCommand("install", SUBCOMMANDS)).not.toMatch(/uninstall/); + }); + + it("finds a table word through a typo of it", () => { + expect(suggestCommand("statsu", SUBCOMMANDS)).toBe("failproofai config --status"); + expect(suggestCommand("instal", SUBCOMMANDS)).toBe("failproofai policies --install"); + }); + + it("points a stale `auth` at the audit, where its sign-in lives now", () => { + expect(suggestCommand("auth", SUBCOMMANDS)).toBe("failproofai audit"); + }); + + it("is not fooled by case", () => { + expect(suggestCommand("Config", SUBCOMMANDS)).toBe("failproofai config"); + expect(suggestCommand("STATUS", SUBCOMMANDS)).toBe("failproofai config --status"); + }); +}); + +describe("suggestCommand — typos of real commands", () => { + it.each([ + ["confg", "failproofai config"], + ["audits", "failproofai audit"], + ["cofnig", "failproofai config"], // a swap is one slip, not two + ["polcies", "failproofai policies"], + ["unistall", "failproofai uninstall"], + ["hlep", "failproofai help"], + ])("%s → %s", (word, suggestion) => { + expect(suggestCommand(word, SUBCOMMANDS)).toBe(suggestion); + }); + + it("takes a unique prefix over a nearer spelling: `back` is backfill, not `pack`", () => { + expect(suggestCommand("back", SUBCOMMANDS)).toBe("failproofai backfill"); + expect(suggestCommand("pub", SUBCOMMANDS)).toBe("failproofai publish"); + }); + + it("keeps an uninstall typo on uninstall", () => { + expect(suggestCommand("uninstal", SUBCOMMANDS)).toBe("failproofai uninstall"); + }); +}); + +describe("suggestCommand — the distance cut-off", () => { + it.each(["unknowncommand", "frobnicate", "xyz", "q"])("suggests nothing for %s, which is close to nothing", (word) => { + expect(suggestCommand(word, SUBCOMMANDS)).toBeNull(); + }); + + it("matches two letters or fewer exactly, since almost anything is one edit from `ls`", () => { + expect(suggestCommand("ls", SUBCOMMANDS)).toBe("failproofai policies"); + expect(suggestCommand("lz", SUBCOMMANDS)).toBeNull(); + }); + + it("allows one edit up to four letters, two beyond", () => { + expect(suggestCommand("jevv", SUBCOMMANDS)).toBe("failproofai jev"); + expect(suggestCommand("hepp", SUBCOMMANDS)).toBe("failproofai help"); // one edit + expect(suggestCommand("hxlx", SUBCOMMANDS)).toBeNull(); // two edits from help + expect(suggestCommand("pubilsh", SUBCOMMANDS)).toBe("failproofai publish"); + }); + + it("suggests nothing for an empty word", () => { + expect(suggestCommand("", SUBCOMMANDS)).toBeNull(); + }); +}); + +describe("suggestFlag", () => { + it("never answers `-i` with `audit`", () => { + expect(suggestFlag("-i")).not.toMatch(/audit/); + expect(suggestFlag("-i")).toBe("failproofai policies --install"); + }); + + it("maps a flag that belongs to a command to that command", () => { + expect(suggestFlag("--status")).toBe("failproofai config --status"); + expect(suggestFlag("--install")).toBe("failproofai policies --install"); + expect(suggestFlag("--list")).toBe("failproofai policies"); + }); + + it("suggests a top-level flag as a whole command, runnable as printed", () => { + expect(suggestFlag("--versoin")).toBe("failproofai --version"); + expect(suggestFlag("--hlep")).toBe("failproofai --help"); + expect(suggestFlag("-V")).toBe("failproofai --version"); + }); + + it.each(["--verbose", "--json", "--unknownflag", "-x"])("suggests nothing for %s", (flag) => { + expect(suggestFlag(flag)).toBeNull(); + }); + + it("never offers --hook, the flag only an agent's hook command passes", () => { + for (const flag of ["--token", "--json", "--hooks", "--hok"]) { + expect(suggestFlag(flag) ?? "").not.toMatch(/--hook/); + } + }); +}); + +describe("nearestName — an agent id", () => { + it("finds the agent a typo meant", () => { + expect(nearestName("claud", INTEGRATION_TYPES)).toBe("claude"); + expect(nearestName("Codex", INTEGRATION_TYPES)).toBe("codex"); + expect(nearestName("gose", INTEGRATION_TYPES)).toBe("goose"); + }); + + it("names no agent for a word that is none of them", () => { + expect(nearestName("block-sudo", INTEGRATION_TYPES)).toBeNull(); + expect(nearestName("", INTEGRATION_TYPES)).toBeNull(); + }); +}); + +describe("a negated word never gets the command it negates", () => { + // `uninstall` is two edits from `install` and `unpause` two from `pause`, + // inside the cut-off for words that long — so before these were pinned, a + // request to take hooks out was answered with `policies --install`, and a + // request to end a pause with `config --pause`. + it("answers --uninstall and -u with --uninstall, never --install", () => { + expect(suggestFlag("--uninstall")).toBe("failproofai policies --uninstall"); + expect(suggestFlag("-u")).toBe("failproofai policies --uninstall"); + expect(suggestFlag("--install")).toBe("failproofai policies --install"); + }); + + it("answers unpause with --resume, never --pause", () => { + expect(suggestCommand("unpause", SUBCOMMANDS)).toBe("failproofai config --resume"); + expect(suggestFlag("--unpause")).toBe("failproofai config --resume"); + expect(suggestCommand("pause", SUBCOMMANDS)).toBe("failproofai config --pause"); + expect(suggestFlag("--pause")).toBe("failproofai config --pause"); + }); + + it.each([ + ["--deinstall", "--install"], + ["--disinstall", "--install"], + ["--unresume", "--resume"], + ["--unstatus", "--status"], + ])("refuses a fuzzy match from %s to its opposite %s", (typed, opposite) => { + expect(suggestFlag(typed) ?? "").not.toContain(opposite); + }); + + it.each([ + ["depause", "--pause"], + ["unresume", "--resume"], + ["unsetup", "failproofai config"], + ])("refuses a fuzzy match from the word %s to %s", (typed, opposite) => { + expect(suggestCommand(typed, SUBCOMMANDS) ?? "").not.toContain(opposite); + }); + + it("still finds a plain typo of a word that happens to start with a prefix", () => { + expect(suggestCommand("disconect", SUBCOMMANDS)).toBe("failproofai config --disconnect"); + expect(suggestCommand("unistall", SUBCOMMANDS)).toBe("failproofai uninstall"); + }); +}); diff --git a/__tests__/hooks/first-run-hint.test.ts b/__tests__/hooks/first-run-hint.test.ts index b58445328..991b38103 100644 --- a/__tests__/hooks/first-run-hint.test.ts +++ b/__tests__/hooks/first-run-hint.test.ts @@ -45,17 +45,19 @@ function cli(...args: string[]): string { describe("a machine with no policies says how to get some", () => { it("`policies` names the pack, in full", () => { + // The redesign's one generic line (D18) and the design's next step (D14). const out = cli("policies"); - expect(out).toMatch(/Nothing is enforcing yet/); - expect(out).toContain(`failproofai policies add ${CORE_SOURCE}`); + expect(out).toMatch(/Policies are not enforcing yet\./); + expect(out).toContain(`Turn on ours: failproofai policies add ${CORE_SOURCE}`); }); it("`policies` points at everyone else's packs too, not only ours", () => { // Ours being FIRST is a convenience, not a channel. If the listing named - // only ours it would read as the place policies come from. + // only ours it would read as the place policies come from. Deliberately + // narrowed to the one command (D14, D6: one line per fact) — looking at a + // pack first is `policies show`, which `policies --help` documents. const out = cli("policies"); - expect(out).toMatch(/policies add \//); - expect(out).toMatch(/policies show \//); + expect(out).toContain("Or anyone's: failproofai policies add /"); }); it("offers no short name of our own anywhere in that hint", () => { diff --git a/__tests__/hooks/flush-cli.test.ts b/__tests__/hooks/flush-cli.test.ts index d6d9a91b1..923aeef35 100644 --- a/__tests__/hooks/flush-cli.test.ts +++ b/__tests__/hooks/flush-cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -15,7 +15,7 @@ vi.mock("../../src/hooks/daemon-service", () => ({ isDaemonSupportedPlatform: vi.fn(() => true), })); -import { runFlushCommand, pendingBatches, flushRequestPath } from "../../src/hooks/flush-cli"; +import { runFlushCommand, pendingBatches, spoolBacklog, 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"; @@ -72,6 +72,25 @@ describe("pendingBatches, against the layout the daemon writes", () => { }); }); +describe("spoolBacklog", () => { + it("counts what pendingBatches counts, and says how long the oldest has waited", () => { + flat("state/spool", ["young.jsonl", "old.jsonl", "half.tmp"]); + flat("custom-agents/events", ["sdk.jsonl"]); + const now = Date.now(); + const old = new Date(now - 20 * 60_000); + utimesSync(join(home, ".failproofai", "state", "spool", "old.jsonl"), old, old); + const backlog = spoolBacklog(home, now); + expect(backlog.count).toBe(3); + expect(backlog.count).toBe(pendingBatches(home)); + expect(backlog.oldestAgeMs).toBeGreaterThanOrEqual(20 * 60_000 - 1_000); + expect(backlog.oldestAgeMs).toBeLessThan(21 * 60_000); + }); + + it("has no age when nothing is queued", () => { + expect(spoolBacklog(home)).toEqual({ count: 0 }); + }); +}); + describe("runFlushCommand with parked batches", () => { it("does not call them delivered, and says how they are retried", async () => { flat("state/failed", ["hooks-activity-1-0.a1.jsonl", "b.a3.jsonl.poison"]); @@ -177,4 +196,18 @@ describe("runFlushCommand request", () => { expect(r.pending).toBe(1); expect(r.lines.join(" ")).toContain("Delivery continues in the background"); }); + + it("marks the timeout line itself, so the report's failure glyph is not guessed onto the first line", async () => { + spool("claude", ["a.jsonl"]); + 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: 2, sleep }); + vi.mocked(Date.now).mockRestore(); + expect(r.lines[0]).toMatch(/^1 batch spooled\. Requested delivery\.$/); + expect(r.lines.find((l) => l.includes("Still 1 batch"))).toBe("▲ Still 1 batch spooled after 2s."); + }); }); diff --git a/__tests__/hooks/fp-reset.test.ts b/__tests__/hooks/fp-reset.test.ts index e2269213a..00b4eca2b 100644 --- a/__tests__/hooks/fp-reset.test.ts +++ b/__tests__/hooks/fp-reset.test.ts @@ -1324,3 +1324,93 @@ describe("resetHome carries the layout-1 JSON credentials", () => { expect(readCredentials().cloud?.token).toBe("tok-one"); }); }); + +// The banners print above whatever command was typed, so each is ONE state line +// and ONE fix line in the 2026-10 screen language: `✕` for the one that stops +// the command, `▲` for one that needs attention, `✓` for a repair already made. +describe("the banners printed before a command", () => { + const TRUECOLOR = { COLORTERM: "truecolor", TERM: "xterm-256color" }; + const saved: Record = {}; + const truecolor = () => { + for (const [k, v] of Object.entries(TRUECOLOR)) { + saved[k] = process.env[k]; + process.env[k] = v; + } + }; + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + const noUnit = { daemonStatus: () => "not-installed" as const }; + const installedDaemon = (ver: string) => { + mkdirSync(binDir(), { recursive: true }); + writeFileSync(resolve(binDir(), `failproofaid-${ver}`), "ELF"); + }; + const cliVersion = (): string => + (JSON.parse(readFileSync(resolve(__dirname, "../../package.json"), "utf8")) as { version: string }).version; + + it("a daemon the machine requires: one ▲ line naming the consequence, one fix line naming the command", async () => { + installedDaemon("0.0.1-old"); + writeVersionFile({ daemon: "0.0.1-old" }); + updateConfig({ daemon: { configured: true } }); + + // Stopped, not gone: the self-heal leaves the flag alone for a stopped unit + // (usually a restart in progress), so the machine still requires the daemon. + const { lines } = await checkLayoutForCli({ daemonStatus: () => "stopped" as const }); + const at = lines.findIndex((l) => l.startsWith("▲ failproofaid does not match this CLI")); + expect(lines.slice(at, at + 2)).toEqual([ + "▲ failproofaid does not match this CLI, and a restart can stop it, which denies every tool call.", + ` Bring it from 0.0.1-old to ${cliVersion()}: failproofai update`, + ]); + }); + + it("the self-heal: what was repaired, with the phrase on one line, then how to reinstall", async () => { + writeVersionFile(); + updateConfig({ daemon: { configured: true } }); + + const { lines } = await checkLayoutForCli(noUnit); + + expect(lines.slice(0, 2)).toEqual([ + "▲ failproofaid is no longer installed, and requiring it denies every tool call, so policies now evaluate in-process.", + " Reinstall the daemon: failproofai config", + ]); + }); + + it("a newer layout: a ✕ that stops the command, and the upgrade", async () => { + writeFileSync(resolve(home, "VERSION"), JSON.stringify({ layout: 99, cli: "9.9.9" })); + + const check = await checkLayoutForCli(); + + expect(check.fatal).toBe(true); + expect(check.lines.slice(0, 2)).toEqual([ + `✕ This machine's failproofai directory was written by a newer version (layout 99; this build reads ${LAYOUT_VERSION}).`, + " Your data is fine. Upgrade to read it: npm install -g failproofai@latest", + ]); + }); + + it("the reorganisation: a ✓, then what was rebuilt and what was kept", async () => { + seedLayoutOne(); + + const { lines } = await checkLayoutForCli(); + + expect(lines[0]).toBe(`✓ failproofai reorganised ${home} for this version.`); + expect(lines[1]).toMatch(/^ {2}Removed \d+ items? that this version rebuilds: the audit cache/); + expect(lines[2]).toMatch(/^ {2}Kept your settings, cloud enrolment, policy selection/); + }); + + it("paints by role with colour on — warn glyph, pink command — and writes no escape with it off", async () => { + writeVersionFile(); + updateConfig({ daemon: { configured: true } }); + const plain = await checkLayoutForCli(noUnit); + expect(plain.lines.join("\n")).not.toMatch(/\x1B\[/); + + writeVersionFile(); + updateConfig({ daemon: { configured: true } }); + truecolor(); + const coloured = await checkLayoutForCli({ ...noUnit, render: { color: true } }); + expect(coloured.lines[0]).toMatch(/^\x1B\[38;2;227;179;65m▲\x1B\[0m failproofaid is no longer installed/); + expect(coloured.lines[1]).toBe(" Reinstall the daemon: \x1B[38;2;228;88;125mfailproofai config\x1B[0m"); + }); +}); diff --git a/__tests__/hooks/harness-extra-paths.test.ts b/__tests__/hooks/harness-extra-paths.test.ts index 6fbb505ed..a1de56023 100644 --- a/__tests__/hooks/harness-extra-paths.test.ts +++ b/__tests__/hooks/harness-extra-paths.test.ts @@ -221,12 +221,12 @@ describe("harness extra paths", () => { addPath("claude", "work=/srv/team"); addPath("hermes", "/srv/hermes-prod/state.db"); const out = listPaths().lines.join("\n"); - // A section rule now, not a `claude:` prose heading — the same shape every - // other listing uses. - expect(out).toContain("━━ claude"); + // An UPPERCASE heading per agent with its count, the shape every listing + // has in the 2026-10 design (it was a `━━ claude` rule before that). + expect(out).toMatch(/^CLAUDE {2}1 extra path$/m); expect(out).toContain("/srv/team"); expect(out).toContain("work-*"); - expect(out).toContain("━━ hermes"); + expect(out).toMatch(/^HERMES {2}1 extra path$/m); expect(out).toContain("derived from the folder name"); }); @@ -385,3 +385,35 @@ describe("extra paths survive every config.json write that `config` and `update` expect(readFileSync(configFile(), "utf-8")).toContain("/srv/hermes-prod/state.db"); }); }); + +describe("an agent that is not traced", () => { + // The daemon starts no task for an agent outside agents.selected, extra + // paths included, so a configured path would otherwise read as captured. + let home: string; + let prevHome: string | undefined; + beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(join(tmpdir(), "fpai-hx-untraced-")); + process.env.FAILPROOFAI_HOME = home; + }); + afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + }); + + it("is said next to its paths and when one is added", async () => { + const { updateConfig } = await import("../../src/hooks/fp-config"); + updateConfig({ agents: { selected: ["codex"], seen: ["codex"] } }); + const added = addPath("claude", "work=/srv/team").lines.join("\n"); + expect(added).toContain("▲ Claude Code isn't traced, so nothing is captured from its paths until it is."); + expect(listPaths().lines.join("\n")).toContain("Claude Code isn't traced"); + updateConfig({ agents: { selected: ["codex", "claude"], seen: ["codex"] } }); + expect(listPaths().lines.join("\n")).not.toContain("isn't traced"); + }); + + it("no longer claims harness list shows what is captured", () => { + const text = addPath("claude", "/srv/x").lines.join("\n"); + expect(text).not.toContain("shows what is actually being captured"); + }); +}); diff --git a/__tests__/hooks/help-index.test.ts b/__tests__/hooks/help-index.test.ts index e7503dc70..9b1baae78 100644 --- a/__tests__/hooks/help-index.test.ts +++ b/__tests__/hooks/help-index.test.ts @@ -1,32 +1,32 @@ // @vitest-environment node // // The top-level help used to be the reference manual: 152 lines, six screens at -// 80x24, every flag of every command inlined. It is now ONE screen of what -// exists, plus a `failproofai help ` router that dispatches straight to -// ` --help`, so each command's documentation has exactly one copy. +// 80x24, every flag of every command inlined. It is now the 2026-10 redesign's +// index: what to do first (GET STARTED), every command by what it is for, three +// examples — plus a `failproofai help ` router that dispatches straight +// to ` --help`, so each command's documentation has exactly one copy. // -// The thing that will regress is not the wording — it is the SIZE and the -// LAYOUT. Both are properties nobody re-measures: the first person to add a -// command adds a row, the screen quietly becomes two, and nothing anywhere -// notices. So these drive the real binary and measure the rendered bytes. +// The redesign is taller than the old one-screen index (decision D10 allows it), +// but it still fits 80 columns: nothing on it is cut or wrapped in a default +// terminal. What will regress is the SIZE, the LAYOUT and the COMPLETENESS — +// a command added to the CLI and never to the index is invisible. So these +// drive the real binary and measure the rendered text. // -// A note on the measurement, because getting it wrong makes the test lie: the -// section rules are U+2501, three bytes each, so a line's UTF-8 byte length is -// far larger than the width it occupies on screen. Terminal columns are what -// matters, so every width here is `String.length` on the DECODED string, and -// the premise that those two agree — no emoji, no wide characters — is itself -// asserted below rather than assumed. +// Widths are `String.length` on the decoded string, because a terminal column +// is a character, not a byte; the premise that the two agree here (no emoji, no +// wide characters) is itself asserted below rather than assumed. import { describe, it, expect, afterAll } from "vitest"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; const BINARY = resolve(__dirname, "..", "..", "bin", "failproofai.mjs"); -/** The contract: one screen, in a terminal nobody has resized. */ -const MAX_LINES = 30; +/** The redesign's index, with room for a row or two more — not a second screen's worth. */ +const MAX_LINES = 36; const MAX_COLUMNS = 80; +const HEADINGS = ["GET STARTED", "SET UP", "ENFORCE", "OBSERVE", "LESS OFTEN", "EXAMPLES"]; // An isolated HOME so a first-run gate, an onboarding lock, or a migration // resolves `~/.failproofai` under a throwaway dir rather than the developer's @@ -63,66 +63,60 @@ function cli(...args: string[]): Run { }; } -/** The index, as lines, with the trailing blank `console.log` adds removed. */ -function indexLines(): string[] { - const run = cli("--help"); - expect(run.exitCode).toBe(0); - return run.stdout.replace(/\n+$/, "").split("\n"); +// Rendered once, and asserted inside the tests below rather than here: an +// expect at module scope makes a layout change fail the whole FILE at +// collection, taking every unrelated routing test down with it. +const INDEX_RUN = cli("--help"); +const INDEX = INDEX_RUN.stdout.replace(/\n+$/, "").split("\n"); + +/** The flush-left UPPERCASE headings, in order. */ +const headings = (lines: string[]) => lines.filter((l) => /^[A-Z][A-Z ]+$/.test(l)); + +/** The indented rows under one heading, up to the next blank line. */ +function rowsUnder(lines: string[], heading: string): string[] { + const at = lines.indexOf(heading); + if (at === -1) return []; + const out: string[] = []; + for (const line of lines.slice(at + 1)) { + if (line.trim() === "") break; + out.push(line); + } + return out; } -/** - * The command words the index advertises. - * - * The rows sit between the first section rule and the footer, which opens with - * the `failproofai help ` pointer; each is ` `, - * separated by a run of two or more spaces. A spec may name alternatives - * (`harness, flush, backfill`) or a command plus its flags (`policies add`, - * `config --status`) — the command word is the first token of each - * alternative, and `(no args)` names no command at all. - * - * Blank lines are SKIPPED, not a terminator. They used to be one, because the - * sections were flush against each other and the only blank on the screen was - * the one before the footer. The sections breathe now, so stopping at the first - * blank would have read section one and reported the other three as commands - * the index does not advertise. - */ +/** The command words the index advertises: the first token of every row in the four command sections. */ function indexCommands(lines: string[]): string[] { - const firstRule = lines.findIndex((l) => l.includes("━")); - expect(firstRule).toBeGreaterThan(-1); - - const rows: string[] = []; - for (const line of lines.slice(firstRule)) { - if (line.includes("failproofai help")) break; - if (line.trim() === "") continue; - if (line.includes("━")) continue; - rows.push(line); - } - // Not vacuous: the loop above must actually have found the body, not stopped - // on the first line it saw. - expect(rows.length).toBeGreaterThan(5); - - const commands = new Set(); - for (const row of rows) { - const spec = row.trim().split(/\s{2,}/)[0]; - for (const alternative of spec.split(",")) { - const word = alternative.trim().split(/\s+/)[0]; - if (!word || word.startsWith("(") || word.startsWith("-")) continue; - commands.add(word); + const words = new Set(); + for (const heading of ["SET UP", "ENFORCE", "OBSERVE", "LESS OFTEN"]) { + for (const row of rowsUnder(lines, heading)) { + const word = row.trim().split(/\s+/)[0]; + if (word && !word.startsWith("(")) words.add(word); } } - return [...commands]; + return [...words]; +} + +/** The commands bin dispatches, read from its own SUBCOMMANDS list. */ +function dispatchedCommands(): string[] { + const source = readFileSync(BINARY, "utf8"); + const match = source.match(/const SUBCOMMANDS = \[([^\]]+)\]/); + if (!match) return []; + return [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).filter((c) => c !== "help"); } -const INDEX = indexLines(); const INDEXED_COMMANDS = indexCommands(INDEX); -describe("failproofai --help — the screen that replaced the manual", () => { - it(`stays inside one screen — at most ${MAX_LINES} lines`, () => { - // The number this replaced was 152. The slack above the current height is - // deliberate: a few more rows are fine, a second screen is not. +describe("failproofai --help — the index", () => { + it("prints, and exits 0", () => { + expect(INDEX_RUN.exitCode).toBe(0); + expect(INDEX_RUN.stdout.trim().length).toBeGreaterThan(0); + }); + + it(`stays at most ${MAX_LINES} lines`, () => { + // The number this replaced was 152. expect(INDEX.length).toBeLessThanOrEqual(MAX_LINES); // Not vacuous — an empty or truncated help must not read as "small enough". - expect(INDEX.length).toBeGreaterThan(10); + expect(INDEX.length).toBeGreaterThan(25); }); it("wraps to no terminal — every line fits 80 display columns", () => { @@ -132,41 +126,52 @@ describe("failproofai --help — the screen that replaced the manual", () => { expect(tooWide).toEqual([]); }); - it("measures those columns in characters, because the rules are multibyte", () => { - // The premise the width check rests on, asserted rather than trusted: the - // only non-ASCII character on the screen is the box rule, which is one - // column wide, so `String.length` IS the display width. An emoji or a - // full-width character here would make the check above silently wrong. - const exotic = [...INDEX.join("\n")].filter( - (ch) => ch.codePointAt(0)! > 126 && ch !== "━", - ); + it("measures those columns in characters, and the only wide-byte character is the separator dot", () => { + // `·` is one column and two bytes, so `String.length` IS the display width. + // An emoji or a full-width character here would make the check above + // silently wrong; so would an escape code, which a pipe must never carry. + const text = INDEX.join("\n"); + expect(text).not.toContain("\x1b"); + const exotic = [...text].filter((ch) => ch.codePointAt(0)! > 126 && ch !== "·"); expect(exotic).toEqual([]); + const header = INDEX.find((line) => line.includes("·") && line.startsWith("failproof ai")); + expect(header).toBeDefined(); + expect(Buffer.byteLength(header!, "utf8")).toBeGreaterThan(header!.length); + }); + + it("opens with the wordmark and the version", () => { + const version = (JSON.parse(readFileSync(resolve(__dirname, "..", "..", "package.json"), "utf8")) as { version: string }).version; + expect(INDEX.find((l) => l.trim() !== "")).toContain(`failproof ai v${version}`); + }); - // And the distinction is live, not theoretical: a rule line really does - // carry more bytes than columns, so measuring a Buffer would have failed - // the 80-column check on a screen that fits perfectly. - const rule = INDEX.find((line) => line.includes("━")); - expect(rule).toBeDefined(); - expect(Buffer.byteLength(rule!, "utf8")).toBeGreaterThan(rule!.length); + it("keeps its six sections, in order", () => { + expect(headings(INDEX)).toEqual(HEADINGS); }); - it("keeps the four sections it groups the commands into", () => { - const rules = INDEX.filter((line) => line.includes("━")); - expect(rules).toHaveLength(4); + it("gets a newcomer started in three numbered steps, each ending in a command they can run", () => { + const steps = rowsUnder(INDEX, "GET STARTED"); + expect(steps).toHaveLength(3); + steps.forEach((row, i) => { + expect(row).toMatch(new RegExp(`^\\s+${i + 1}\\s+\\S.*\\s{2,}failproofai\\b`)); + }); }); - it("is the same screen from `help`, `--help` and `-h`", () => { - const long = cli("--help"); + it("says where per-command help lives, and how to print the version", () => { + expect(INDEX_RUN.stdout).toContain("failproofai help "); + expect(INDEX_RUN.stdout).toContain("failproofai -v"); + }); + + it("is the same screen from `help`, `--help`, `-h` and `help help`", () => { const short = cli("-h"); const bare = cli("help"); + const twice = cli("help", "help"); - expect(long.exitCode).toBe(0); expect(short.exitCode).toBe(0); expect(bare.exitCode).toBe(0); - expect(long.stdout).toContain("failproofai help "); - - expect(short.stdout).toBe(long.stdout); - expect(bare.stdout).toBe(long.stdout); + expect(twice.exitCode).toBe(0); + expect(short.stdout).toBe(INDEX_RUN.stdout); + expect(bare.stdout).toBe(INDEX_RUN.stdout); + expect(twice.stdout).toBe(INDEX_RUN.stdout); }); }); @@ -237,27 +242,33 @@ describe("failproofai help — one copy of each command's help", () => expect(run.exitCode).not.toBe(0); expect(run.stderr).toContain("nonsense"); expect(run.stderr).toContain("failproofai help"); + // The notice shape: one `✕` sentence, then the fix on its own line. + expect(run.stderr).toContain("✕ There is no help for 'nonsense'."); + expect(run.stderr).toContain(" Run failproofai help to see every command."); // A clean CliError, not a stack trace. expect(run.stderr).not.toContain("node:internal"); }); }); describe("the index advertises nothing it cannot explain", () => { - it("names the commands this parse is about to check", () => { - // The guard against the whole suite below passing on an empty list: if the - // index layout changes shape, this fails loudly instead of checking nothing. - expect(INDEXED_COMMANDS.length).toBeGreaterThanOrEqual(10); - expect(INDEXED_COMMANDS).toEqual( - expect.arrayContaining(["config", "policies", "audit", "uninstall"]), - ); + it("names every command the CLI dispatches, each once — none is undiscoverable", () => { + // The guard against the whole suite below passing on an empty list, and + // against a command that exists but is on no screen anyone reads. + const dispatched = dispatchedCommands(); + expect(dispatched.length).toBeGreaterThanOrEqual(10); + expect([...INDEXED_COMMANDS].sort()).toEqual([...dispatched].sort()); }); - it.each(INDEXED_COMMANDS)("`help %s` reaches real help", (command) => { + it.each(INDEXED_COMMANDS)("`help %s` reaches real help, the same bytes as its own --help", (command) => { const run = cli("help", command); + const direct = cli(command, "--help"); expect(run.exitCode).toBe(0); expect(run.stdout.trim().length).toBeGreaterThan(0); - expect(run.stderr).not.toContain("No help for"); + // The unknown-topic error's words, as the test above pins them: checking + // for the old wording here would pass forever and test nothing. + expect(run.stderr).not.toContain("There is no help for"); + expect(run.stdout).toBe(direct.stdout); }); }); @@ -280,8 +291,11 @@ describe("a bare command runs, it does not describe itself", () => { const out = `${run.stdout ?? ""}${run.stderr ?? ""}`; // It has nothing to publish in an empty directory, so it must FAIL — // but as the command failing, not as a manual. - expect(out).not.toMatch(/two commands, from nothing/i); - expect(out).not.toMatch(/what --init does/i); + // Anchored on markers the help page really prints today, so this can + // never pass simply because the help stopped containing them. + expect(cli("publish", "--help").stdout).toContain("Build and release it on GitHub"); + expect(out).not.toMatch(/^USAGE$/m); + expect(out).not.toContain("Build and release it on GitHub"); } finally { rmSync(empty, { recursive: true, force: true }); } @@ -290,7 +304,8 @@ describe("a bare command runs, it does not describe itself", () => { it("publish --help still prints it", () => { const run = cli("publish", "--help"); expect(run.exitCode).toBe(0); - expect(run.stdout).toMatch(/two commands, from nothing/i); + expect(run.stdout).toMatch(/^USAGE$/m); + expect(run.stdout).toContain("failproofai publish --init [file]"); }); // Behaviour changed and the screen describing it did not. `publish` now @@ -303,8 +318,9 @@ describe("a bare command runs, it does not describe itself", () => { const run = cli("publish", "--help"); expect(run.exitCode).toBe(0); - expect(run.stdout).toMatch(/--allow-private/); - expect(run.stdout).toMatch(/REFUSED/); + // One line per flag now (decision D11), and no shouting: the row says the + // default refusal is something you have to override, and what that costs. + expect(run.stdout).toMatch(/--allow-private\s+Publish to a private repo anyway; installs will fail/); // The stale promise, in the words it was written in. expect(run.stdout).not.toMatch(/still publishes/); }); diff --git a/__tests__/hooks/help-page-wrap.test.ts b/__tests__/hooks/help-page-wrap.test.ts new file mode 100644 index 000000000..d576fd83d --- /dev/null +++ b/__tests__/hooks/help-page-wrap.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +/** + * Help option descriptions are never cut on a narrow terminal. + * + * They name flags and agent ids: at 70 columns the policies page printed + * `hermes, openclaw, factory, devin, antigravity,…` (goose gone) and + * `Add custom policy files (repeatable); -u -c cl…`. Ids and flags are never + * cut, so an option description that does not fit wraps under its column. + * And a coloured cell that IS shortened still ends on `…`, instead of being + * cut bare at the column. + */ +import { describe, it, expect } from "vitest"; +import { screenKit, visibleWidth } from "../../src/hooks/tui"; + +const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); + +const SPEC = { + name: "policies", + usage: [["failproofai policies add [name | owner/repo]", "Turn one on, install a pack, or pick from a list"]] as Array< + [string, string?] + >, + options: [ + ["--policy, --only ", "Install only these policies from a pack"], + ["--cli ", "Apply to these agents only"], + ["", "hermes, openclaw, factory, devin, antigravity, goose"], + ["--scope ", "user, project, local (default user); all when removing"], + ] as Array<[string, string]>, +}; + +describe("helpPage options on a narrow terminal", () => { + it("wraps an option description under its column instead of cutting it", () => { + const page = screenKit({ cols: 70, color: false, fit: true, version: "1.0.11" }).helpPage(SPEC); + const options = page.slice(page.indexOf("OPTIONS") + 1); + const text = options.join("\n"); + expect(text).not.toContain("…"); + expect(text).toMatch(/\bgoose\b/); + expect(text).toMatch(/\ball\s+when\s+removing\b/); + for (const line of options) expect(visibleWidth(line)).toBeLessThanOrEqual(70); + // Continuations sit under the description column (2 + 22 + 2 = 26). + const goose = options.find((l) => l.includes("goose"))!; + expect(goose).toMatch(/^ {26}\S/); + }); + + it("leaves a description that fits on one line", () => { + const page = screenKit({ cols: 80, color: false, fit: true, version: "1.0.11" }).helpPage(SPEC); + expect(page).toContain(" --scope user, project, local (default user); all when removing"); + }); + + it("never wraps or cuts in a pipe", () => { + const page = screenKit({ cols: 40, color: false, version: "1.0.11" }).helpPage(SPEC); + expect(page).toContain(" hermes, openclaw, factory, devin, antigravity, goose"); + }); + + it("still shortens usage prose with `…` on a terminal", () => { + const page = screenKit({ cols: 70, color: false, fit: true, version: "1.0.11" }).helpPage(SPEC); + const usage = page.find((l) => l.includes("failproofai policies add"))!; + expect(usage.endsWith("…")).toBe(true); + }); +}); + +/** Colour is decided from the environment too; pin one that renders it. */ +function withEnv(env: Record, fn: () => T): T { + const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]])); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +describe("a coloured cell that is shortened", () => { + it("ends on `…` rather than being cut bare at the column", () => { + const [desc, row] = withEnv({ COLORTERM: "truecolor", TERM: "xterm-256color", NO_COLOR: undefined }, () => { + const k = screenKit({ cols: 40, color: true, fit: true }); + const cell = `Redact JWTs in tool output${k.sep}${k.meta("observe")}`; + return [cell, k.rows([["sanitize-jwt", cell]])[0]]; + }); + // The cell carries colour, which is the case that was cut with no `…`. + expect(desc).toContain("\x1b["); + expect(strip(row).endsWith("…")).toBe(true); + expect(visibleWidth(row)).toBeLessThanOrEqual(40); + }); +}); diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index 1559a6bab..efc5dec1f 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -13,80 +13,6 @@ describe("hooks/install-prompt", () => { vi.restoreAllMocks(); }); - /** - * `manager.ts` writes whatever this returns straight into `enabledPolicies`, - * and then prints only what survived — so anything this function drops is - * configuration destroyed with nothing on screen to say so. - */ - describe("never drops a configured policy name it does not recognise", () => { - const nonTty = () => - Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true, configurable: true }); - - it("carries qualified, beta and pack names through the non-TTY path", async () => { - nonTty(); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - - const configured = [ - "block-sudo", - // A form the ENFORCEMENT path explicitly accepts — - // `registerBuiltinPolicies` canonicalizes both spellings — yet the - // catalog is keyed by the bare name, so an intersection deleted it. - "failproofai/block-sudo", - "pack/acme/finance@1.2.0/block-refunds", - "some-policy-this-build-has-never-heard-of", - ]; - - expect(await promptPolicySelection(configured)).toEqual(configured); - }); - - it("still returns the defaults when nothing was configured", async () => { - // The path every fresh install takes must be unchanged. - nonTty(); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const { BUILTIN_POLICIES } = await import("../../src/hooks/builtin-policies"); - - const expected = BUILTIN_POLICIES.filter((p) => p.defaultEnabled && !p.beta).map((p) => p.name); - expect(await promptPolicySelection()).toEqual(expected); - }); - }); - - it("returns default-enabled policies when stdin is not a TTY", async () => { - Object.defineProperty(process.stdin, "isTTY", { - value: false, - writable: true, - configurable: true, - }); - - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const selected = await promptPolicySelection(); - - expect(selected).toContain("sanitize-jwt"); - expect(selected).toContain("protect-env-vars"); - expect(selected).toContain("block-env-files"); - expect(selected).toContain("block-sudo"); - expect(selected).toContain("block-curl-pipe-sh"); - expect(selected).toContain("block-push-master"); - expect(selected).toContain("block-failproofai-commands"); - expect(selected).not.toContain("block-rm-rf"); - expect(selected).not.toContain("block-force-push"); - expect(selected).not.toContain("block-secrets-write"); - // 12 before `block-self-pause` merged into `block-failproofai-commands`. - expect(selected).toHaveLength(11); - }); - - it("returns preSelected when stdin is not a TTY and preSelected is provided", async () => { - Object.defineProperty(process.stdin, "isTTY", { - value: false, - writable: true, - configurable: true, - }); - - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const selected = await promptPolicySelection(["block-sudo", "block-rm-rf"]); - - expect(selected).toEqual(["block-sudo", "block-rm-rf"]); - }); - describe("resolveTargetClis", () => { it("returns explicit cli list as-is regardless of action", async () => { const { resolveTargetClis } = await import("../../src/hooks/install-prompt"); @@ -184,7 +110,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); - expect(options[0].label).toBe("Install for all 2 detected"); + expect(options[0].label).toBe("All 2 agents"); // Detected rows preserve order and carry detected=true expect(options.slice(1, 3)).toEqual([ @@ -209,7 +135,7 @@ describe("hooks/install-prompt", () => { ]); }); - it("uninstall action: only detected rows, no undetected (and verb is 'Remove from')", async () => { + it("uninstall action: only detected rows, no undetected", async () => { const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); const { options, undetected } = buildCliMenuOptions( ["claude", "codex", "copilot"], @@ -218,7 +144,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual([]); expect(options).toHaveLength(4); // 1 aggregate + 3 detected - expect(options[0].label).toBe("Remove from all 3 detected"); + expect(options[0].label).toBe("All 3 agents"); expect(options.every((o) => o.detected)).toBe(true); }); @@ -231,7 +157,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual([]); expect(options).toHaveLength(13); // aggregate + 12 detected - expect(options[0].label).toBe("Install for all 12 detected"); + expect(options[0].label).toBe("All 12 agents"); }); it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { diff --git a/__tests__/hooks/jev-cli-bin.test.ts b/__tests__/hooks/jev-cli-bin.test.ts index 305028975..98f1ddaac 100644 --- a/__tests__/hooks/jev-cli-bin.test.ts +++ b/__tests__/hooks/jev-cli-bin.test.ts @@ -115,6 +115,11 @@ describe("failproofai config --token (real binary)", () => { expect(r.stderr).toContain("shell history"); expect(r.stderr).toContain("FAILPROOFAI_CLOUD_TOKEN"); expect(r.stdout + r.stderr).not.toContain(KEY); + // One `▲` line and one fix line, the command plain in a pipe. + expect(r.stderr).toContain( + "▲ --token put the key in your shell history and, while this ran, the process list. Rotate it if that matters.\n" + + " Next time, set FAILPROOFAI_CLOUD_TOKEN instead: read -rs FAILPROOFAI_CLOUD_TOKEN && export FAILPROOFAI_CLOUD_TOKEN", + ); }); }); diff --git a/__tests__/hooks/jev-cli-cloud.test.ts b/__tests__/hooks/jev-cli-cloud.test.ts index 15dc18d61..b9b8225db 100644 --- a/__tests__/hooks/jev-cli-cloud.test.ts +++ b/__tests__/hooks/jev-cli-cloud.test.ts @@ -154,9 +154,11 @@ describe("jev CLI: FailproofAI Cloud", () => { expect(json(await runJevCommand(["status", "--json"], RENDER))).toMatchObject({ status: "off", cloudConnected: true, keyCarriesJev: false }); }); - // Each command on its own line, under the lead that introduces it: the + // Each command on its own line, after the lead that introduces it: the // reconnect used to sit right under "…keep Jev off for good: failproofai - // jev setup --mode off", reading as the off switch. + // jev setup --mode off", reading as the off switch. In the redesign a step + // is ONE line — `lead: command` — so the reconnect line must end on its own + // command, carry no off switch, and come before the line that does. it.each([ ["key-lacks-jev", () => writeCredentials({ ingest: { url: `${ORIGIN}/v1/events`, key: KEY } }), "failproofai jev setup --mode off"], ["not-connected", () => undefined, "failproofai jev remove"], @@ -164,11 +166,10 @@ describe("jev CLI: FailproofAI Cloud", () => { arrange(); writeJev({ provider: "failproofai", baseUrl: BASE, mode: "observe" }); const lines = (await runJevCommand(["status"], RENDER)).lines; - const i = lines.findIndex((l) => l.trim() === "failproofai config --token "); + const i = lines.findIndex((l) => l.endsWith(": failproofai config --token ")); expect(i).toBeGreaterThan(0); - expect(lines[i - 1].trimEnd()).toMatch(/:$/); - expect(lines[i - 1]).not.toMatch(/--mode off|jev remove/); - expect(lines.slice(i + 1).some((l) => l.trim() === offCmd)).toBe(true); + expect(lines[i]).not.toMatch(/--mode off|jev remove/); + expect(lines.slice(i + 1).some((l) => l.endsWith(`: ${offCmd}`))).toBe(true); }); it("off: switched off, with the mode, for the Cloud route and for BYOK", async () => { diff --git a/__tests__/hooks/jev-cli-status-reviewable.test.ts b/__tests__/hooks/jev-cli-status-reviewable.test.ts index b466ad03e..b8e891150 100644 --- a/__tests__/hooks/jev-cli-status-reviewable.test.ts +++ b/__tests__/hooks/jev-cli-status-reviewable.test.ts @@ -134,7 +134,8 @@ describe("failproofai jev status — what Jev may clear", () => { const r = await runJevCommand(["status"], RENDER); expect(r.exitCode).toBe(0); const out = text(r); - expect(out).toContain("Jev is off"); + // The status row says it, in the redesign's `label ○ value` shape. + expect(out).toContain("status ○ off: there is no"); expect(out).not.toContain("reviewable"); expect(out).not.toContain(RETAKE_PACK_COMMAND); diff --git a/__tests__/hooks/jev-cli-status-stats.test.ts b/__tests__/hooks/jev-cli-status-stats.test.ts index 2e4374e1f..08fb7b3b2 100644 --- a/__tests__/hooks/jev-cli-status-stats.test.ts +++ b/__tests__/hooks/jev-cli-status-stats.test.ts @@ -100,7 +100,8 @@ describe("failproofai jev status — activity comes from jevStats()", () => { it("absent: activity is still shown when there is no config", async () => { const human = await runJevCommand(["status"], RENDER); expect(human.exitCode).toBe(0); - expect(text(human)).toContain("Jev is off"); + // The status row says it, in the redesign's `label ○ value` shape. + expect(text(human)).toContain("status ○ off: there is no"); expectStatsShown(human); expectOneDefaultCall(); diff --git a/__tests__/hooks/jev-cli.test.ts b/__tests__/hooks/jev-cli.test.ts index c7d231509..dff3268ba 100644 --- a/__tests__/hooks/jev-cli.test.ts +++ b/__tests__/hooks/jev-cli.test.ts @@ -231,8 +231,10 @@ describe("failproofai jev", () => { it("says Jev is off, and how to turn it on, when there is no config", async () => { const r = await runJevCommand(["status"], RENDER); expect(r.exitCode).toBe(0); - expect(text(r)).toContain("Jev is off"); - expect(text(r)).toContain("regex policies exactly as before"); + // The status row, in the redesign's `label ○ value` shape, names the + // missing file; the old "Hooks run the regex policies exactly as before" + // paragraph went with the rest of the explanatory prose (D6). + expect(text(r)).toContain(`status ○ off: there is no ${jevConfigPath()}`); expect(text(r)).toContain("failproofai jev setup"); expect(text(r)).toContain("No Jev evaluations recorded"); }); @@ -303,6 +305,51 @@ describe("failproofai jev", () => { expect(text(r)).toContain("FAILPROOFAI_EVALUATOR=legacy"); }); + it("draws the design's jevStatus: the header, then label/value rows, with no `last test` row", async () => { + await runJevCommand(["setup", "--provider", "openrouter", "--mode", "observe", "--key-stdin"], withKey(KEY)); + const { lines } = await runJevCommand(["status"], RENDER); + + expect(lines[0]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Jev$/); + // One value column for the block, two past its widest label (`reviewable`). + expect(lines.slice(2, 9)).toEqual([ + " status ● on · observe", + " provider openrouter, model typesafe/jev-1.13 (provider default)", + " endpoint https://openrouter.ai/api/v1/systemone", + " mode observe — Jev is asked and logged; the regex result is what is enforced", + " timeout 3000 ms", + " key set in the config file", + ` config ${jevConfigPath()} · ${posix ? "0600 (owner-only)" : lines[8].split(" · ")[1]}`, + ]); + expect(lines[9]).toMatch(/^ {2}reviewable {2}\d+ of \d+ enabled polic/); + // Nothing records a test, so no row claims one (D16). + expect(lines.join("\n")).not.toMatch(/last test/); + // The activity block is a heading of its own, last before any next step. + expect(lines).toContain("ACTIVITY last 24 hours"); + }); + + it("paints by role with colour on, and writes no escape with it off", async () => { + await runJevCommand(["setup", "--provider", "typesafe", "--key-stdin"], withKey(KEY)); + const plain = await runJevCommand(["status"], RENDER); + expect(plain.lines.join("\n")).not.toMatch(/\x1B\[/); + + const saved = { COLORTERM: process.env.COLORTERM, TERM: process.env.TERM }; + process.env.COLORTERM = "truecolor"; + process.env.TERM = "xterm-256color"; + try { + const coloured = await runJevCommand(["status"], { ...RENDER, render: { cols: 100, color: true } }); + const out = coloured.lines.join("\n"); + // Grey labels, the mint ● for on, and the wordmark's pink `il`. + expect(out).toContain("\x1B[38;2;118;127;139mstatus\x1B[0m"); + expect(out).toContain("\x1B[38;2;102;209;181m●\x1B[0m on · enforce"); + expect(out).toContain("\x1B[1;38;2;228;88;125mil\x1B[0m"); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); + it("renders activity from jevStats()", () => { const lines = jevStatsLines( { diff --git a/__tests__/hooks/list-convention-column.test.ts b/__tests__/hooks/list-convention-column.test.ts index c0183e9d7..b15314c1d 100644 --- a/__tests__/hooks/list-convention-column.test.ts +++ b/__tests__/hooks/list-convention-column.test.ts @@ -78,9 +78,10 @@ describe("listHooks — convention policy column width", () => { const row = lines.find((l) => l.includes(LONG_NAME)); expect(row).toBeDefined(); - // The regression: `...policies.mjs1 hook(s)` with no separator. + // The regression: `...policies.mjs1 hook(s)` with no separator. The count + // reads "1 hook" since the redesign (D14); the gutter is what is pinned. expect(row).not.toMatch(new RegExp(`${LONG_NAME.replace(/\./g, "\\.")}\\d`)); - expect(row).toMatch(/\.mjs\s+1 hook\(s\)/); + expect(row).toMatch(/\.mjs {2,}1 hook$/); }); it("aligns short and long filenames to the same column", async () => { @@ -93,7 +94,7 @@ describe("listHooks — convention policy column width", () => { const columnOf = (filename: string) => { const row = lines.find((l) => l.includes(filename))!; - return row.replace(/\[[0-9;]*m/g, "").indexOf("1 hook(s)"); + return row.replace(/\[[0-9;]*m/g, "").indexOf("1 hook"); }; expect(columnOf(LONG_NAME)).toBeGreaterThan(0); @@ -147,10 +148,10 @@ describe("listHooks — convention policy column width", () => { vi.stubEnv("USERPROFILE", otherHome); try { await listHooks(tmp); - const headers = lines.filter((l) => l.includes("Convention Policies")); + // UPPERCASE heading, scope as its meta (D13, D14). + const headers = lines.filter((l) => l.includes("CONVENTION POLICIES")); expect(headers).toHaveLength(1); - expect(headers[0]).toContain("Project"); - expect(headers[0]).not.toContain("Project + User"); + expect(headers[0]).toBe("CONVENTION POLICIES project"); } finally { rmSync(otherHome, { recursive: true, force: true }); } @@ -172,9 +173,12 @@ describe("listHooks — convention policy column width", () => { await listHooks(tmp); + // ○ is off in the redesign's two states (D14). Positive on the glyph the + // row must carry, so this cannot pass by the old "OFF" label simply no + // longer being printed. const row = lines.find((line) => line.includes("custom-rule")); - expect(row?.replace(/\x1B\[[0-9;]*m/g, "")).toContain("OFF"); - expect(row?.replace(/\x1B\[[0-9;]*m/g, "")).not.toContain("✓"); + expect(row?.replace(/\x1B\[[0-9;]*m/g, "")).toMatch(/^ {2}○ custom-rule\b/); + expect(row?.replace(/\x1B\[[0-9;]*m/g, "")).not.toContain("●"); }); it("renders disabled convention policies as OFF while leaving siblings enabled", async () => { @@ -192,10 +196,10 @@ describe("listHooks — convention policy column width", () => { await listHooks(tmp); + // One row per file since the redesign (D14): ● while any hook in it is on, + // and a count of the ones switched off. Mixed is "on, with k off". const row = lines.find((line) => line.includes(SHORT_NAME)); const plain = row?.replace(/\x1B\[[0-9;]*m/g, ""); - expect(plain).toContain("MIXED"); - expect(plain).toContain("enabled-rule"); - expect(plain).toContain("disabled-rule (OFF)"); + expect(plain).toMatch(/^ {2}● team-policies\.mjs {2,}2 hooks \(1 off\)$/); }); }); diff --git a/__tests__/hooks/machine-label-setup.test.ts b/__tests__/hooks/machine-label-setup.test.ts new file mode 100644 index 000000000..0bac1114e --- /dev/null +++ b/__tests__/hooks/machine-label-setup.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment node +/** + * `config --oss|--agents --machine-label ` on a machine setup does not + * connect. The label is the name to connect under, setup applies it only when + * it connects, and on every other path it was dropped without a word while the + * run exited 0. Now it is refused before setup, or applied as a rename after. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { applyMachineLabelAfterSetup, machineLabelRefusal } from "../../src/hooks/cloud-enrollment-cli"; +import { readCloudCredentials, writeCloudCredentials } from "../../src/hooks/cloud-enrollment"; + +let dir: string; +let realHome: string | undefined; +const verify = async () => ({ ok: true, policyCount: 0, deployment: 1 }) as const; + +beforeEach(() => { + dir = mkdtempSync(resolve(tmpdir(), "fpai-label-setup-")); + process.env.FAILPROOFAI_CLOUD_CREDENTIALS = resolve(dir, "cloud.json"); + process.env.FAILPROOFAI_HOME = resolve(dir, "home"); + realHome = process.env.HOME; + process.env.HOME = resolve(dir, "home"); +}); + +afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_CREDENTIALS; + delete process.env.FAILPROOFAI_HOME; + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("machineLabelRefusal — before setup writes anything", () => { + it("refuses --oss on a machine that is not connected: nothing would carry the name", () => { + const refusal = machineLabelRefusal({ oss: true, keyGiven: false, interactive: true }); + expect(refusal).toMatch(/--oss leaves this one unconnected/); + }); + + it("refuses an unattended run with no key on a machine that is not connected", () => { + const refusal = machineLabelRefusal({ oss: false, keyGiven: false, interactive: false }); + expect(refusal).toMatch(/this one is not connected/); + expect(refusal).toContain("FAILPROOFAI_CLOUD_TOKEN"); + }); + + it("lets a run with a key through: setup connects under the name", () => { + expect(machineLabelRefusal({ oss: false, keyGiven: true, interactive: false })).toBeNull(); + }); + + it("lets a run at the prompt through: a key can still be pasted there", () => { + expect(machineLabelRefusal({ oss: false, keyGiven: false, interactive: true })).toBeNull(); + }); + + it("lets every run through on a connected machine, where the name is a rename", () => { + writeCloudCredentials({ url: "https://x", machineId: "m-1", token: "t", machineLabel: "old" }); + expect(machineLabelRefusal({ oss: true, keyGiven: false, interactive: false })).toBeNull(); + expect(machineLabelRefusal({ oss: false, keyGiven: false, interactive: false })).toBeNull(); + }); +}); + +describe("applyMachineLabelAfterSetup — after setup ran", () => { + it("renames a machine setup left connected as it was (--agents, no new key)", async () => { + writeCloudCredentials({ url: "https://x", machineId: "m-1", token: "t", machineLabel: "old" }); + const r = await applyMachineLabelAfterSetup("prod-01", { verify }); + expect(r?.exitCode).toBe(0); + expect(r?.lines.join("\n")).toContain("Renamed old to prod-01"); + expect(readCloudCredentials()?.machineLabel).toBe("prod-01"); + }); + + it("says nothing when setup already connected under the name", async () => { + writeCloudCredentials({ url: "https://x", machineId: "m-1", token: "t", machineLabel: "prod-01" }); + expect(await applyMachineLabelAfterSetup("prod-01", { verify })).toBeNull(); + }); + + it("says nothing when no label was given", async () => { + expect(await applyMachineLabelAfterSetup(undefined, { verify })).toBeNull(); + }); + + it("says the name was not applied when open source was chosen at the prompt", async () => { + const r = await applyMachineLabelAfterSetup("prod-01", { verify }); + expect(r?.lines[0]).toBe("▲ prod-01 was not applied: this machine is not connected to FailproofAI Cloud."); + expect(readCloudCredentials()).toBeNull(); + }); +}); diff --git a/__tests__/hooks/manager-cloud-listing.test.ts b/__tests__/hooks/manager-cloud-listing.test.ts index b6714d02e..be8c491f7 100644 --- a/__tests__/hooks/manager-cloud-listing.test.ts +++ b/__tests__/hooks/manager-cloud-listing.test.ts @@ -1,5 +1,8 @@ // @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("../../src/hooks/cloud-managed-policies", () => ({ readActiveCloudManagedPolicies: vi.fn(), @@ -13,8 +16,24 @@ const readActive = vi.mocked(readActiveCloudManagedPolicies); describe("failproofai policies — cloud-managed section", () => { let out: string[]; let spy: ReturnType; + let home: string; + let project: string; + let saved: Record; beforeEach(() => { + // A throwaway home, project and pack directory: the listing reads hook + // settings, packs and convention files, and mirrors convention policies into + // the user config. None of that may be the developer's own. + home = mkdtempSync(join(tmpdir(), "fpai-cloud-listing-home-")); + project = mkdtempSync(join(tmpdir(), "fpai-cloud-listing-proj-")); + saved = { + FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, + FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR, + }; + process.env.FAILPROOFAI_HOME = home; + process.env.FAILPROOFAI_PACK_DIR = join(home, "packs"); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); out = []; spy = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { out.push(a.map(String).join(" ")); @@ -26,52 +45,99 @@ describe("failproofai policies — cloud-managed section", () => { return true; }); }); - afterEach(() => spy.mockRestore()); + afterEach(() => { + spy.mockRestore(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const dir of [home, project]) rmSync(dir, { recursive: true, force: true }); + }); const text = () => out.join("\n").replace(/\x1B\[[0-9;]*m/g, ""); + /** Wire Claude Code, so whether policies enforce is decided by the policies alone. */ + function wireClaude(): void { + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "npx -y failproofai --hook PreToolUse", __failproofai_hook__: true }] }, + ], + }, + }), + ); + } + it("lists a deployed policy, with its version and the deployment number", async () => { readActive.mockReturnValue([ { id: "org-guard", version: 3, effect: "enforce", sha256: "a", path: "p", deployment: 7 }, ]); - await listHooks(); - expect(text()).toContain("Cloud-managed — deployment 7"); - expect(text()).toContain("org-guard"); - expect(text()).toContain("v3"); + await listHooks(project); + expect(text()).toMatch(/^CLOUD-MANAGED {2}deployment 7$/m); + expect(text()).toMatch(/● org-guard\s+v3$/m); }); - it("marks an observe policy as OBS, never ON — its verdict is discarded", async () => { + it("marks an observe policy as observing, never as enforcing — its verdict is discarded", async () => { + // The row says observe, and a wired machine whose only policy observes is + // told it is not enforcing: two positive facts, where the old pin was the + // absence of a chip label the redesign no longer prints anywhere. + wireClaude(); readActive.mockReturnValue([ { id: "watch-only", version: 1, effect: "observe", sha256: "a", path: "p", deployment: 2 }, ]); - await listHooks(); + await listHooks(project); const line = text().split("\n").find((l) => l.includes("watch-only")) ?? ""; - expect(line).toContain("OBS"); - expect(line).not.toContain("✓ ON"); + expect(line).toMatch(/v1 {2}· {2}observe$/); + expect(text()).toContain("▲ Policies are not enforcing yet."); + }); + + it("does not tag an enforcing one, and counts it as enforcing", async () => { + wireClaude(); + readActive.mockReturnValue([ + { id: "org-guard", version: 1, effect: "enforce", sha256: "a", path: "p", deployment: 1 }, + ]); + await listHooks(project); + expect(text()).not.toContain("observe"); + expect(text()).not.toContain("Policies are not enforcing yet."); }); - it("says these are not switchable locally, because --uninstall cannot touch them", async () => { + it("lists them under the deployment's own heading, apart from anything switchable here", async () => { + // Deliberately changed (D6): the "Managed from the dashboard — not + // switchable with `failproofai policies`" note is gone. What says these + // belong to the deployment, and that `--uninstall ` cannot touch + // them, is the heading they sit under — never a pack or custom section. readActive.mockReturnValue([ { id: "org-guard", version: 1, effect: "enforce", sha256: "a", path: "p", deployment: 1 }, ]); - await listHooks(); - expect(text()).toContain("Managed from the dashboard"); + await listHooks(project); + const lines = text().split("\n"); + const heading = lines.findIndex((l) => l === "CLOUD-MANAGED deployment 1"); + const row = lines.findIndex((l) => l.includes("org-guard")); + expect(heading).toBeGreaterThan(-1); + expect(row).toBe(heading + 1); + expect(text()).not.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"); + await listHooks(project); + expect(text()).toContain("Turn on ours:"); + 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. The heading is the - // command's own name now, like every other surface. - expect(text()).toContain("failproofai policies"); + await expect(listHooks(project)).resolves.not.toThrow(); + expect(text()).not.toContain("CLOUD-MANAGED"); + // The rest of the listing must still have printed: it opens with the + // wordmark header, like every other screen. + expect(text()).toMatch(/^failproof ai {2}v\S+ {2}· {2}Policies$/m); }); }); diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index 2139903d3..89abf4c12 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -33,12 +33,6 @@ vi.mock("../../src/hooks/safe-config-write", async () => { }; }); -vi.mock("../../src/hooks/install-prompt", () => ({ - promptPolicySelection: vi.fn(() => - Promise.resolve(["block-sudo", "block-env-files", "sanitize-jwt"]), - ), -})); - vi.mock("../../src/hooks/hooks-config", () => ({ readHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), // listHooks resolves the project root by walking up to the nearest @@ -194,12 +188,10 @@ describe("hooks/manager", () => { vi.mocked(readScopedHooksConfig).mockReturnValue({ enabledPolicies: ["block-sudo"] }); const { installHooks } = await import("../../src/hooks/manager"); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); await installHooks(); - expect(promptPolicySelection).not.toHaveBeenCalled(); // Written back UNCHANGED — not re-derived, not defaulted, not widened. expect(writeScopedHooksConfig).toHaveBeenCalledWith( { enabledPolicies: ["block-sudo"] }, @@ -214,11 +206,9 @@ describe("hooks/manager", () => { const { installHooks } = await import("../../src/hooks/manager"); const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); await installHooks(["all"]); - expect(promptPolicySelection).not.toHaveBeenCalled(); expect(writeScopedHooksConfig).toHaveBeenCalledWith( { enabledPolicies: expect.arrayContaining(["block-sudo", "block-rm-rf", "sanitize-jwt"]) }, "user", @@ -498,7 +488,7 @@ describe("hooks/manager", () => { const logs = vi.mocked(console.log).mock.calls.map((c) => c[0]); expect(logs.some((l: unknown) => typeof l === "string" && (l as string).includes("npx -y failproofai"))).toBe(true); expect(logs.some((l: unknown) => typeof l === "string" && (l as string).includes("committed to git"))).toBe(true); - expect(logs.some((l: unknown) => typeof l === "string" && (l as string).includes("Binary:"))).toBe(false); + expect(logs.some((l: unknown) => typeof l === "string" && /^ {2}binary +\S/.test(l as string))).toBe(false); }); it("install at local scope writes to {cwd}/.claude/settings.local.json", async () => { @@ -566,7 +556,7 @@ describe("hooks/manager", () => { await installHooks(["all"], "user"); expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("Warning: Failproof AI hooks are also installed"), + expect.stringContaining("Hooks are also installed at"), ); }); @@ -1179,7 +1169,9 @@ describe("hooks/manager", () => { // it off. What the listing renders now — packs, convention files, cloud — // is covered against real files in `policies-listing.test.ts`; these keep // the mock-level contract that survived. - it("says nothing is installed, without naming policies this build no longer runs", async () => { + it("says nothing is enforcing on an empty machine, without naming policies this build no longer runs", async () => { + // "nothing installed" became the redesign's generic line and next step + // (D14, D18) — deliberately; the half that matters here is unchanged. const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config"); vi.mocked(readMergedHooksConfig).mockReturnValue({ enabledPolicies: [] }); vi.mocked(existsSync).mockReturnValue(false); @@ -1188,15 +1180,38 @@ describe("hooks/manager", () => { await listHooks(); const output = vi.mocked(console.log).mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("nothing installed"); + expect(output).toContain("Policies are not enforcing yet."); + expect(output).toContain("Turn on ours: failproofai policies add FailproofAI/policies"); // Naming a builtin here would advertise enforcement that is not happening. expect(output).not.toContain("sanitize-jwt"); expect(output).not.toContain("block-sudo"); }); it("warns when hooks exist in multiple scopes", async () => { + // The screen has one attention line (D14), and "not enforcing" outranks + // this one — so the machine gets a pack with a policy on, which leaves the + // scopes as the most severe thing to say. const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config"); + const { readInstalledPacks } = await import("../../src/hooks/pack-manifest"); vi.mocked(readMergedHooksConfig).mockReturnValue({ enabledPolicies: [] }); + vi.mocked(readInstalledPacks).mockReturnValue({ + packs: [ + { + id: "acme/ops", + version: "1.0.0", + source: "github:acme/ops@v1.0.0", + path: "/tmp/none.mjs", + sha256: "0".repeat(64), + effect: "enforce", + enabled: null, + clis: null, + policies: [ + { name: "block-prod-deploy", description: "d", category: "Ops", defaultEnabled: true, match: {} }, + ], + }, + ], + errors: [], + } as never); vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue( JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ command: "failproofai --hook PreToolUse" }] }] } }), @@ -1207,6 +1222,7 @@ describe("hooks/manager", () => { const output = vi.mocked(console.log).mock.calls.map((c) => c[0]).join("\n"); expect(output).toMatch(/multiple scopes/i); + expect(output).toContain("failproofai policies --uninstall --scope "); }); }); }); diff --git a/__tests__/hooks/new-telemetry.test.ts b/__tests__/hooks/new-telemetry.test.ts index 96742e6e3..8a4ba1b4c 100644 --- a/__tests__/hooks/new-telemetry.test.ts +++ b/__tests__/hooks/new-telemetry.test.ts @@ -28,16 +28,6 @@ vi.mock("node:child_process", () => ({ execSync: vi.fn(), })); -vi.mock("../../src/hooks/install-prompt", async () => { - const actual = await vi.importActual( - "../../src/hooks/install-prompt", - ); - return { - ...actual, - promptPolicySelection: vi.fn(() => Promise.resolve(["block-sudo"])), - }; -}); - vi.mock("../../src/hooks/integrations", () => ({ detectInstalledClis: vi.fn(() => ["claude"]), getIntegration: vi.fn((id: string) => ({ diff --git a/__tests__/hooks/not-enforcing-semantic-only.test.ts b/__tests__/hooks/not-enforcing-semantic-only.test.ts new file mode 100644 index 000000000..90af5625a --- /dev/null +++ b/__tests__/hooks/not-enforcing-semantic-only.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +/** + * `notEnforcingReason` and a refused Jev-checks-only pack. + * + * Such a pack (its regex `policies` list is empty) has nothing in the regex + * tier to fail closed on: pack-failclosed.ts denies nothing for it, and the + * policies listing leaves it out of its "failed to load" line. So it must not + * make a machine read as enforcing either, or the listing and the launch screen + * disagree about whether this machine is protected. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let home: string; +let project: string; +let packRoot: string; +let saved: Record; + +function wireClaude(): void { + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "npx -y failproofai --hook PreToolUse", __failproofai_hook__: true }] }, + ], + }, + }), + ); +} + +/** One pack whose artifact digest does not match, so it is refused at read. */ +function installRefusedPack(policies: unknown[]): void { + writeFileSync( + join(packRoot, "installed.json"), + JSON.stringify({ + schemaVersion: 1, + packs: [ + { + id: "acme/jev-only", + version: "1.0.0", + source: "github:acme/jev-only@v1.0.0", + entry: `artifacts/${"0".repeat(64)}.mjs`, + sha256: "0".repeat(64), + policies, + }, + ], + }), + ); +} + +async function reason() { + const { notEnforcingReason } = await import("@/src/hooks/manager"); + return notEnforcingReason(project); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-semantic-home-")); + project = mkdtempSync(join(tmpdir(), "fpai-semantic-proj-")); + packRoot = mkdtempSync(join(tmpdir(), "fpai-semantic-packs-")); + mkdirSync(join(packRoot, "artifacts"), { recursive: true }); + saved = { FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR }; + process.env.FAILPROOFAI_HOME = home; + process.env.FAILPROOFAI_PACK_DIR = packRoot; + // Agent settings resolve from the OS home, not FAILPROOFAI_HOME. + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const dir of [home, project, packRoot]) rmSync(dir, { recursive: true, force: true }); +}); + +describe("notEnforcingReason with a refused Jev-checks-only pack", () => { + it("does not count it as enforcing: it denies nothing", async () => { + wireClaude(); + installRefusedPack([]); + const { readInstalledPacks } = await import("@/src/hooks/pack-manifest"); + // The premise: the pack is refused, and refused as semantic-only. + expect(readInstalledPacks().errors).toEqual([expect.objectContaining({ id: "acme/jev-only", semanticOnly: true })]); + expect(await reason()).toBe("no-policies"); + }); + + it("still counts a refused pack with regex policies, which fails closed", async () => { + wireClaude(); + installRefusedPack([{ name: "block-x", description: "Block x", category: "X", defaultEnabled: true, match: {} }]); + expect(await reason()).toBeNull(); + }); +}); diff --git a/__tests__/hooks/not-enforcing.test.ts b/__tests__/hooks/not-enforcing.test.ts new file mode 100644 index 000000000..1be7cee08 --- /dev/null +++ b/__tests__/hooks/not-enforcing.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +/** + * `notEnforcingReason` — the one answer to "are the policies on this machine + * actually enforcing?", shared by the policies listing and the launch screen so + * the two can never disagree. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ARTIFACT = "export const hooks = [];\n"; +const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex"); + +let home: string; +let project: string; +let packRoot: string; +let saved: Record; + +function wireClaude(): void { + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "npx -y failproofai --hook PreToolUse", __failproofai_hook__: true }] }, + ], + }, + }), + ); +} + +function installPack(over: Record = {}): void { + writeFileSync( + join(packRoot, "installed.json"), + JSON.stringify({ + schemaVersion: 1, + packs: [ + { + id: "acme/finance", + version: "1.2.0", + source: "github:acme/finance@v1.2.0", + entry: `artifacts/${DIGEST}.mjs`, + sha256: DIGEST, + policies: [ + { name: "block-big-refund", description: "Block big refunds", category: "Finance", defaultEnabled: true, match: {} }, + { name: "require-note", description: "Require a note", category: "Finance", defaultEnabled: true, match: {} }, + ], + ...over, + }, + ], + }), + ); +} + +async function reason() { + const { notEnforcingReason } = await import("@/src/hooks/manager"); + return notEnforcingReason(project); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-enforcing-home-")); + project = mkdtempSync(join(tmpdir(), "fpai-enforcing-proj-")); + packRoot = mkdtempSync(join(tmpdir(), "fpai-enforcing-packs-")); + mkdirSync(join(packRoot, "artifacts"), { recursive: true }); + writeFileSync(join(packRoot, "artifacts", `${DIGEST}.mjs`), ARTIFACT); + saved = { FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR }; + process.env.FAILPROOFAI_HOME = home; + process.env.FAILPROOFAI_PACK_DIR = packRoot; + // Agent settings resolve from the OS home, not FAILPROOFAI_HOME. + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const dir of [home, project, packRoot]) rmSync(dir, { recursive: true, force: true }); +}); + +describe("notEnforcingReason", () => { + it("says no agent calls failproofai when nothing is wired, whatever is installed", async () => { + installPack(); + expect(await reason()).toBe("no-hooks"); + }); + + it("says no policies when an agent is wired but nothing is switched on", async () => { + wireClaude(); + expect(await reason()).toBe("no-policies"); + }); + + it("is null once a wired machine has a pack policy on", async () => { + wireClaude(); + installPack(); + expect(await reason()).toBeNull(); + }); + + it("calls an observe-only pack observe-only, never enforcing", async () => { + wireClaude(); + installPack({ effect: "observe" }); + expect(await reason()).toBe("observe-only"); + }); + + it("counts a policy the user switched off after install as off", async () => { + wireClaude(); + installPack({ enabled: ["block-big-refund"] }); + writeFileSync( + join(home, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], disabledCustomPolicies: ["pack:acme/finance@1.2.0:block-big-refund"] }), + ); + expect(await reason()).toBe("no-policies"); + }); + + it("counts convention policy files on disk without importing them", async () => { + wireClaude(); + mkdirSync(join(project, ".failproofai", "policies"), { recursive: true }); + writeFileSync(join(project, ".failproofai", "policies", "team-policies.mjs"), "throw new Error('must not be imported');\n"); + expect(await reason()).toBeNull(); + }); + + it("never calls a machine with a refused pack not enforcing: that pack denies", async () => { + wireClaude(); + installPack({ sha256: "0".repeat(64) }); + expect(await reason()).toBeNull(); + }); + + it("does not count a refused observe pack, which denies nothing", async () => { + wireClaude(); + installPack({ sha256: "0".repeat(64), effect: "observe" }); + expect(await reason()).toBe("no-policies"); + }); +}); + +describe("enforcingPolicyCount", () => { + async function count() { + const { enforcingPolicyCount } = await import("@/src/hooks/manager"); + return enforcingPolicyCount(project); + } + + it("counts the policies a pack enforces, leaving out what was switched off", async () => { + installPack(); + expect(await count()).toEqual({ count: 2, custom: false }); + writeFileSync( + join(home, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], disabledCustomPolicies: ["pack:acme/finance@1.2.0:require-note"] }), + ); + expect(await count()).toEqual({ count: 1, custom: false }); + }); + + it("counts nothing from an observe-only pack", async () => { + installPack({ effect: "observe" }); + expect((await count()).count).toBe(0); + }); + + it("reports custom policy files without importing them", async () => { + mkdirSync(join(project, ".failproofai", "policies"), { recursive: true }); + writeFileSync(join(project, ".failproofai", "policies", "team-policies.mjs"), "throw new Error('must not be imported');\n"); + expect(await count()).toEqual({ count: 0, custom: true }); + }); +}); diff --git a/__tests__/hooks/onboarding-attempt.test.ts b/__tests__/hooks/onboarding-attempt.test.ts index 995664011..8f0d26e74 100644 --- a/__tests__/hooks/onboarding-attempt.test.ts +++ b/__tests__/hooks/onboarding-attempt.test.ts @@ -262,9 +262,33 @@ describe("what the user is told", () => { it("does not tell an unsupported-platform machine to re-run the command that just hard-failed", () => { // blockerCleared only re-offers this reason on a CLI version change, so - // `failproofai config` would hit the exact same guard right now. + // `failproofai config` would hit the exact same guard right now. (The + // command is written plain now, with no backticks, so the check is for + // the command itself.) const text = attemptHintLines(attempt({ reason: "unsupported_platform" })).join("\n"); - expect(text).not.toContain("Run `failproofai config`"); + expect(text).not.toContain("failproofai config"); expect(text).toContain("update"); }); + + it("is one ▲ line and one fix line, the command plain without colour and pink with it", () => { + expect(attemptHintLines(attempt({ reason: "needs_root" }), { color: false })).toEqual([ + "", + "▲ Setup is not finished: it needs root to install the failproofaid service.", + " Run it when you are ready: failproofai config", + "", + ]); + const saved = { COLORTERM: process.env.COLORTERM, TERM: process.env.TERM }; + process.env.COLORTERM = "truecolor"; + process.env.TERM = "xterm-256color"; + try { + const [, state, fix] = attemptHintLines(attempt({ reason: "needs_root" }), { color: true }); + expect(state).toMatch(/^\x1B\[38;2;227;179;65m▲\x1B\[0m Setup is not finished/); + expect(fix).toBe(" Run it when you are ready: \x1B[38;2;228;88;125mfailproofai config\x1B[0m"); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); }); diff --git a/__tests__/hooks/pack-cli.test.ts b/__tests__/hooks/pack-cli.test.ts index b88637aa9..0d905bd98 100644 --- a/__tests__/hooks/pack-cli.test.ts +++ b/__tests__/hooks/pack-cli.test.ts @@ -148,23 +148,29 @@ describe("our own policies, named the way anyone else's are", () => { }, ); + // The add result is the redesign's (D14): one ✓ sentence with the count and + // the reason, then how many are on per category. It names categories, not + // policies, so these read the count and the category back. it("takes one policy by name, and does not read the flag's value as the source", async () => { const r = await runPackCommand(["add", "FailproofAI/policies", "--policy", "block-rm-rf"]); expect(r.exitCode).toBe(0); - expect(text(r)).toMatch(/enabled \(1\//); - expect(text(r)).toContain("block-rm-rf"); + expect(text(r)).toMatch(/✓ Turned on 1 policy from FailproofAI\/policies@\S+: your selection\./); + // block-rm-rf's own category, and nothing else. + expect(text(r)).toMatch(/^ {2}dangerous commands 1$/m); }); it("still takes --only, so anything scripted against it keeps working", async () => { const r = await runPackCommand(["add", "FailproofAI/policies", "--only", "block-rm-rf"]); expect(r.exitCode).toBe(0); - expect(text(r)).toMatch(/enabled \(1\//); + expect(text(r)).toMatch(/✓ Turned on 1 policy from FailproofAI\/policies@\S+: your selection\./); }); it("takes a whole category", async () => { const r = await runPackCommand(["add", "FailproofAI/policies", "--category", "dangerous-commands"]); expect(r.exitCode).toBe(0); - expect(text(r)).toContain("block-sudo"); + expect(text(r)).toMatch(/✓ Turned on \d+ policies from FailproofAI\/policies@\S+: your selection\./); + // Every one of them from that category, and only that category. + expect(text(r)).toMatch(/^ {2}dangerous commands \d+$/m); }); it("names the categories that exist when given one that does not", async () => { @@ -174,11 +180,25 @@ describe("our own policies, named the way anyone else's are", () => { expect(text(r)).toContain("dangerous-commands"); }); - it("suggests the selection flags when it did not install everything", async () => { + it("says how to take the rest when it did not install everything", async () => { + // Deliberately narrowed (D14, D6): the design closes on the one command + // that takes everything else; --policy and --category stay in the help. const r = await runPackCommand(["add", "FailproofAI/policies"]); - expect(text(r)).toContain("--policy"); - expect(text(r)).toContain("--category"); - expect(text(r)).toContain("--all"); + expect(text(r)).toMatch( + /^Turn on the other \d+ with failproofai policies add FailproofAI\/policies --all$/m, + ); + }); + + it("does not offer the rest once everything is on", async () => { + const r = await runPackCommand(["add", "FailproofAI/policies", "--all"]); + expect(text(r)).toMatch(/✓ Turned on \d+ policies from FailproofAI\/policies@\S+: everything in the pack\./); + expect(text(r)).not.toContain("Turn on the other"); + }); + + it("opens with the header and makes the pin visible on a tagless add", async () => { + const r = await runPackCommand(["add", "FailproofAI/policies"]); + expect(r.lines[0]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Policies$/); + expect(text(r)).toMatch(/^ {2}Pinned to github:FailproofAI\/policies@\S+, the newest release\.$/m); }); }); diff --git a/__tests__/hooks/pack-jev-checks.test.ts b/__tests__/hooks/pack-jev-checks.test.ts index 9d3b3663d..7c2cc4177 100644 --- a/__tests__/hooks/pack-jev-checks.test.ts +++ b/__tests__/hooks/pack-jev-checks.test.ts @@ -78,8 +78,10 @@ describe("the section's shape", () => { }); it("heads the section with the count and what these rows are not", () => { + // An UPPERCASE heading with the count as its meta, like every section on + // the redesigned `policies show` (D13, D14). Same words as the old rule. const lines = section([policy("block-rm-rf", ["destructive-deletion"])], [check("destructive-deletion")]); - expect(lines[0]).toContain("Jev checks — 1 · not selectable · only where Jev is configured"); + expect(lines[0]).toBe("JEV CHECKS 1 · not selectable · only where Jev is configured"); }); it("keeps saying that nothing toggles them, which no row can say", () => { @@ -325,7 +327,7 @@ describe("failproofai policies show ", () => { // The paragraph that used to carry this, gone: it named sixteen checks // comma-separated above a screen where everything else was a row. expect(text).not.toContain("It also carries"); - expect(text).toContain("Jev checks — 2 · not selectable · only where Jev is configured"); + expect(text).toContain("JEV CHECKS 2 · not selectable · only where Jev is configured"); expect(rowFor(lines, "destructive-deletion").slice(2)).toEqual(["reviews", "block-rm-rf"]); expect(rowFor(lines, "external-data-egress").slice(2)).toEqual([ "—", @@ -333,15 +335,18 @@ describe("failproofai policies show ", () => { ]); }); - it("keeps the header count and the minCliVersion line as they were", async () => { + it("states the counts and the minimum CLI as rows at the top", async () => { + // Deliberately moved (D14): the counts were the title's meta and the + // minimum a sentence under it; the redesigned screen opens on kv rows. release({ minCliVersion: "1.0.7-beta.0", policies: [policy("block-rm-rf", ["destructive-deletion"])], semantic: [check("destructive-deletion")], }); const text = (await show()).join("\n"); - expect(text).toContain("1 policies · 1 categories · 1 Jev check"); - expect(text).toContain("Requires failproofai 1.0.7-beta.0 or newer."); + expect(text).toMatch(/^ {2}policies\s+1 in 1 category {2}■ on by default$/m); + expect(text).toMatch(/^ {2}jev checks\s+1$/m); + expect(text).toMatch(/^ {2}requires\s+failproofai 1\.0\.7-beta\.0 or newer$/m); }); it("says a minimum it cannot compare was ignored, on show and on add", async () => { @@ -360,6 +365,10 @@ describe("failproofai policies show ", () => { const text = r.lines.join("\n"); expect(text).toContain("1 Jev check, asked by Jev on every tool call they apply to."); expect(text).not.toContain("for Jev"); + // A pack of checks alone has no policies to turn on, so it is installed — + // never "Turned on 0 policies" (D14). + expect(text).toContain("✓ Installed acme/guards@1.2.0."); + expect(text).not.toMatch(/Turned on/); }); it("says an observe pack's checks are not asked, rather than added", async () => { @@ -408,8 +417,9 @@ describe("failproofai policies show ", () => { it("sits under the policy rows, since a check is read against what it can clear", async () => { const lines = await show(); - const policyRow = lines.findIndex((l) => l.includes("block-rm-rf") && l.includes("default")); - const heading = lines.findIndex((l) => l.includes("Jev checks —")); + // ■ marks a policy that is on by default (D14). + const policyRow = lines.findIndex((l) => l.includes("■ block-rm-rf")); + const heading = lines.findIndex((l) => l.startsWith("JEV CHECKS")); expect(policyRow).toBeGreaterThan(-1); expect(heading).toBeGreaterThan(policyRow); }); @@ -418,6 +428,109 @@ describe("failproofai policies show ", () => { release({ policies: [policy("block-rm-rf")] }); const text = (await show()).join("\n"); expect(text).not.toContain("Jev check"); + expect(text).not.toContain("JEV CHECKS"); expect(text).toContain("block-rm-rf"); }); }); + +/** + * The rest of the redesigned `policies show` (D14's policiesShow): the header, + * the kv rows, the categories as ■/□ rows and the install command. + */ +describe("failproofai policies show — the screen", () => { + const ENTRY = "export const hooks = [];\n"; + const sha = (s: string) => createHash("sha256").update(s).digest("hex"); + + let server: Server; + let root: string; + let assets: Record; + const saved: Record = {}; + + function release(manifest: Record): void { + const json = JSON.stringify({ id: "acme/guards", version: "1.2.0", ...manifest }); + assets = { + "failproofai-pack.json": json, + "failproofai-pack.mjs": ENTRY, + SHA256SUMS: `${sha(json)} failproofai-pack.json\n${sha(ENTRY)} failproofai-pack.mjs\n`, + }; + } + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), "fpai-show-screen-")); + for (const k of ["FAILPROOFAI_PACK_DIR", "FAILPROOFAI_PACK_BASE_URL", "FAILPROOFAI_NO_DOWNLOAD", "NO_COLOR"]) { + saved[k] = process.env[k]; + } + delete process.env.FAILPROOFAI_NO_DOWNLOAD; + process.env.FAILPROOFAI_PACK_DIR = root; + process.env.NO_COLOR = "1"; + release({ + policies: [ + policy("block-prod-deploy", undefined, { category: "Deploy", description: "Block deploys to production outside CI" }), + policy("warn-migration", undefined, { category: "Deploy", defaultEnabled: false, description: "Warn before running a database migration" }), + policy("block-vault-read", undefined, { category: "Secrets", defaultEnabled: false, description: "Block reading secrets from Vault" }), + ], + }); + server = createServer((req, res) => { + const m = (req.url ?? "").match(/^\/acme\/guards\/releases\/download\/[^/]+\/([^/]+)$/); + const body = m ? assets[m[1]] : undefined; + if (body === undefined) { + res.writeHead(404).end("no such asset"); + return; + } + res.writeHead(200).end(body); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + process.env.FAILPROOFAI_PACK_BASE_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + await new Promise((r) => server.close(() => r())); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(root, { recursive: true, force: true }); + }); + + const show = async (): Promise => { + const r = await runPackCommand(["list", "acme/guards@v1.2.0"]); + expect(r.exitCode).toBe(0); + return r.lines.join("\n"); + }; + + it("draws the design's screen, the defaults ■ and the rest □", async () => { + const text = await show(); + expect(text).toMatch(/^failproof ai {2}v\S+ {2}· {2}acme\/guards@1\.2\.0$/m); + expect(text).toMatch(/^ {2}policies\s+3 in 2 categories {2}■ on by default$/m); + expect(text).toMatch(/^DEPLOY\n {2}■ block-prod-deploy {5}Block deploys to production outside CI\n {2}□ warn-migration {8}Warn before/m); + expect(text).toMatch(/^SECRETS\n {2}□ block-vault-read\s+Block reading secrets from Vault$/m); + expect(text).toMatch(/^Install with failproofai policies add acme\/guards@v1\.2\.0$/m); + // No release date: knowing one takes a GitHub API call this path does not + // make (D16). + expect(text).not.toMatch(/released/); + }); + + it("names the publisher from the source it was fetched from, never from the id", async () => { + // The id is whatever the manifest claims; the account the release came + // from is the provenance. + release({ id: "bob/tools", policies: [policy("block-prod-deploy")] }); + const text = await show(); + expect(text).toMatch(/^failproof ai {2}v\S+ {2}· {2}bob\/tools@1\.2\.0$/m); + expect(text).toMatch(/^ {2}publisher\s+acme$/m); + expect(text).not.toMatch(/publisher\s+bob/); + }); + + it("shows the commit when the publisher had one, and no commit row when not", async () => { + release({ commit: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", policies: [policy("block-prod-deploy")] }); + expect(await show()).toMatch(/^ {2}commit\s+a1b2c3d4e5f60718293a4b5c6d7e8f9012345678$/m); + release({ policies: [policy("block-prod-deploy")] }); + const without = await show(); + expect(without).toMatch(/^ {2}publisher\s+acme$/m); + expect(without).not.toMatch(/^ {2}commit/m); + }); + + it("says an observe pack blocks nothing", async () => { + release({ effect: "observe", policies: [policy("block-prod-deploy")] }); + expect(await show()).toMatch(/^ {2}effect\s+observe, blocks nothing$/m); + }); +}); diff --git a/__tests__/hooks/pack-loading.test.ts b/__tests__/hooks/pack-loading.test.ts index 794ea2d4b..d2f635249 100644 --- a/__tests__/hooks/pack-loading.test.ts +++ b/__tests__/hooks/pack-loading.test.ts @@ -60,8 +60,14 @@ async function loadWith(packs: ResolvedPack[], paths: string[] = [artifact]) { return result.hooks as unknown as Tagged[]; } +// Loading a pack writes its ESM shim under /state/shims. Left +// on the default, every run of this file dropped one into the developer's real +// ~/.failproofai. +const priorFpHome = process.env.FAILPROOFAI_HOME; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "fpai-pack-load-")); + process.env.FAILPROOFAI_HOME = join(root, "fp-home"); const artifacts = join(root, "artifacts"); mkdirSync(artifacts, { recursive: true }); artifact = join(artifacts, `${SHA}.mjs`); @@ -71,6 +77,8 @@ beforeEach(() => { afterEach(() => { clearCustomHooks(); + if (priorFpHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = priorFpHome; rmSync(root, { recursive: true, force: true }); }); diff --git a/__tests__/hooks/pack-releases-listing.test.ts b/__tests__/hooks/pack-releases-listing.test.ts index d23a82635..83a4d2eb7 100644 --- a/__tests__/hooks/pack-releases-listing.test.ts +++ b/__tests__/hooks/pack-releases-listing.test.ts @@ -73,11 +73,16 @@ interface Release { } /** A release the way GitHub returns one, with the fields this listing reads. */ +// One instant for every fixture release. Computed per call, four releases +// could straddle a millisecond on a loaded machine, and the newest-first sort +// would then reverse them and pick a different "newest" tag for the hint. +const PUBLISHED = daysAgo(3); + function release(tag: string, over: Partial = {}): Release { return { tag_name: tag, body: body(tag, true, COMMIT), - published_at: daysAgo(3), + published_at: PUBLISHED, draft: false, prerelease: false, assets: THREE_ASSETS, diff --git a/__tests__/hooks/pack-selection-merge.test.ts b/__tests__/hooks/pack-selection-merge.test.ts index 0892fb9e9..c7ebdbf73 100644 --- a/__tests__/hooks/pack-selection-merge.test.ts +++ b/__tests__/hooks/pack-selection-merge.test.ts @@ -544,14 +544,19 @@ describe("the reason the CLI prints", () => { // policies when you asked for one category and you take the printed set for // your whole answer. The first add is the contrast — same flag shape, no // prior record, so it really is only your selection. + // + // Since the redesign (D14) the result is one ✓ sentence, and a line of + // per-category counts instead of names. const first = await addViaCli("--category", "audit-trail"); - expect(first).toContain("enabled (1/3, your selection): audit-log-writes"); + expect(first).toContain("✓ Turned on 1 policy from acme/finance@1.2.0: your selection."); + expect(first).toMatch(/^ {2}audit trail 1$/m); const second = await addViaCli("--category", "finance"); - expect(second).toContain("enabled (3/3, what you added, plus what was already on)"); - // The name that came from the PRIOR record, not from this flag — the half - // the sentence exists to account for. - expect(second).toContain("audit-log-writes"); + // Not "Turned on 3": one of the three was already on. + expect(second).toContain("✓ acme/finance@1.2.0 has 3 policies on: what you added, plus what was already on."); + // The category that came from the PRIOR record, not from this flag — the + // half the sentence exists to account for. + expect(second).toMatch(/^ {2}finance 2, audit trail 1$/m); expect(second).not.toContain("your selection"); }); @@ -575,9 +580,23 @@ describe("the reason the CLI prints", () => { // publisher's opinion, "your existing selection" is yours from last time, // and "everything in the pack" is neither — and a reader who cannot tell // them apart cannot tell whose decision put these policies on their machine. - expect(await addViaCli()).toContain("enabled (1/3, the pack's defaults): block-big-refund"); - expect(await addViaCli()).toContain("enabled (1/3, your existing selection): block-big-refund"); - expect(await addViaCli("--all")).toContain("enabled (3/3, everything in the pack)"); + // + // A bare re-add turned nothing on, so it does not say "Turned on" (D14). + expect(await addViaCli()).toContain("✓ Turned on 1 policy from acme/finance@1.2.0: the pack's defaults."); + expect(await addViaCli()).toContain("✓ Kept 1 policy on from acme/finance@1.2.0: your existing selection."); + expect(await addViaCli("--all")).toContain("✓ Turned on 3 policies from acme/finance@1.2.0: everything in the pack."); + }); + + it("never says it turned on none, and says when the pack enforces nothing", async () => { + // A pack whose publisher marks nothing on by default installs with nothing + // on. "Turned on 0 policies" would read as a failure; it is an outcome. + release({ policies: [POLICY_2, POLICY_3] }); + const text = await addViaCli(); + expect(text).toContain( + "✓ Installed acme/finance@1.2.0 with no policies on (the pack's defaults), so it enforces nothing.", + ); + expect(text).not.toMatch(/Turned on 0/); + expect(text).toMatch(/^Turn on the other 2 with failproofai policies add github:acme\/finance@v1\.2\.0 --all$/m); }); }); diff --git a/__tests__/hooks/policies-listing.test.ts b/__tests__/hooks/policies-listing.test.ts index 8f41fee8b..e5dd1534e 100644 --- a/__tests__/hooks/policies-listing.test.ts +++ b/__tests__/hooks/policies-listing.test.ts @@ -5,6 +5,13 @@ * It had no test at all, which is how it came to answer that question with a * subset: builtins in a table, convention files and cloud policies as footer * sections in two other shapes, and installed PACKS not at all. + * + * Since the 2026-10 redesign (decision D14: "the policies list exactly as + * designed") every policy is ● on or ○ off, packs are named once as `pack` rows + * at the top, off policies fold into one line per category, and `--all` lists + * them by name. The assertions below pin the same facts the old chips did, in + * that vocabulary — positively, so none of them can pass by the old label + * simply no longer being printed. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; @@ -13,6 +20,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { visibleWidth } from "@/src/hooks/tui"; +import type { PoliciesScreen } from "@/src/hooks/manager"; const ARTIFACT = "export const hooks = [];\n"; const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex"); @@ -23,47 +31,72 @@ let packRoot: string; let saved: Record; let out: string[]; +const FINANCE = { + id: "acme/finance", + version: "1.2.0", + source: "github:acme/finance@v1.2.0", + entry: `artifacts/${DIGEST}.mjs`, + sha256: DIGEST, + policies: [ + { + name: "block-big-refund", + description: "Block big refunds", + category: "Finance", + defaultEnabled: true, + match: {}, + }, + { + name: "require-note", + description: "Require a note", + category: "Finance", + defaultEnabled: true, + match: {}, + }, + ], +}; + +function installPacks(packs: Array>): void { + writeFileSync(join(packRoot, "installed.json"), JSON.stringify({ schemaVersion: 1, packs })); +} + function installPack(over: Record = {}): void { + installPacks([{ ...FINANCE, ...over }]); +} + +/** Wire Claude Code at user scope, so "not enforcing" is decided by the policies alone. */ +function wireClaude(dir: string = home): void { + mkdirSync(join(dir, ".claude"), { recursive: true }); writeFileSync( - join(packRoot, "installed.json"), + join(dir, ".claude", "settings.json"), JSON.stringify({ - schemaVersion: 1, - packs: [ - { - id: "acme/finance", - version: "1.2.0", - source: "github:acme/finance@v1.2.0", - entry: `artifacts/${DIGEST}.mjs`, - sha256: DIGEST, - policies: [ - { - name: "block-big-refund", - description: "Block big refunds", - category: "Finance", - defaultEnabled: true, - match: {}, - }, - { - name: "require-note", - description: "Require a note", - category: "Finance", - defaultEnabled: true, - match: {}, - }, - ], - ...over, - }, - ], + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "npx -y failproofai --hook PreToolUse", __failproofai_hook__: true }], + }, + ], + }, }), ); } -async function run(): Promise { +async function run(opts: { all?: boolean } = {}): Promise { const { listHooks } = await import("@/src/hooks/manager"); - await listHooks(project); + await listHooks(project, opts); return out.join("\n"); } +const JEV_CHECK = { + name: "acme-check", + title: "Did the thing", + appliesTo: ["shell"], + mode: "deny", + userCanOverride: true, + probes: [{ id: "does", instructions: "It does the thing." }], + guidance: "Ask first.", +}; + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "fpai-listing-home-")); project = mkdtempSync(join(tmpdir(), "fpai-listing-proj-")); @@ -102,94 +135,112 @@ describe("failproofai policies", () => { it("lists no policy from this build — enforcement comes from packs", async () => { // The builtin table is gone. Nothing is compiled in except the always-on // guard, and that has no row precisely because no listing can switch it off. + // The empty-machine line proves the listing rendered, so the absence below + // is a finding rather than a blank screen. const text = await run(); - expect(text).not.toMatch(/✓ LOCK/); + expect(text).toContain("Turn on ours: failproofai policies add FailproofAI/policies"); expect(text).not.toMatch(/block-failproofai-commands/); + expect(text).not.toContain("●"); }); it("lists an installed pack's policies, which no listing did before", async () => { installPack(); const text = await run(); - expect(text).toContain("━━ Pack — acme/finance@1.2.0"); - expect(text).toMatch(/block-big-refund/); - expect(text).toMatch(/require-note/); + expect(text).toMatch(/^ {2}pack\s+acme\/finance@1\.2\.0$/m); + expect(text).toMatch(/^ {2}enabled\s+2 of 2$/m); + expect(text).toMatch(/^FINANCE {2}2 of 2$/m); + expect(text).toMatch(/● block-big-refund\s+Block big refunds/); + expect(text).toMatch(/● require-note\s+Require a note/); }); it("shows a pack policy the user did not take as off", async () => { + // Folded by default — one "○ 1 more off" line in its category, and counted + // in the heading — and listed by name, ○, under --all. installPack({ enabled: ["block-big-refund"] }); const text = await run(); - expect(text).toMatch(/✓ PACK\s+block-big-refund/); - expect(text).toMatch(/· OFF\s+require-note/); + expect(text).toMatch(/● block-big-refund/); + expect(text).toMatch(/^FINANCE {2}1 of 2$/m); + expect(text).toMatch(/^ {2}○ 1 more off$/m); + expect(text).not.toMatch(/● require-note/); + out = []; + expect(await run({ all: true })).toMatch(/○ require-note\s+Require a note/); }); it("shows an observe pack as observing, never as enforcing", async () => { - // observe evaluates and discards its verdict; an ON row would claim - // enforcement the pack deliberately is not doing. + // observe evaluates and discards its verdict, so its pack row says so — and + // a wired machine whose only policies observe is told it is not enforcing. + wireClaude(); installPack({ effect: "observe" }); const text = await run(); - expect(text).toMatch(/◉ OBS\s+block-big-refund/); - expect(text).not.toMatch(/✓ PACK\s+block-big-refund/); + expect(text).toMatch(/^ {2}pack\s+acme\/finance@1\.2\.0 {2}· {2}observe$/m); + expect(text).toContain("▲ Policies are not enforcing yet. · failproofai config"); + }); + + it("does not tag a pack that enforces", async () => { + // The control for the one above: the tag is about the pack, not decoration. + wireClaude(); + installPack(); + const text = await run(); + expect(text).toMatch(/^ {2}pack\s+acme\/finance@1\.2\.0$/m); + expect(text).not.toContain("Policies are not enforcing yet."); }); it("names a pack that will not load instead of quietly listing less", async () => { installPack({ sha256: "0".repeat(64) }); const text = await run(); - expect(text).toMatch(/will not load/); - expect(text).toContain("acme/finance"); + expect(text).toMatch(/^ {2}pack\s+acme\/finance {2}· {2}failed to load: /m); + expect(text).toContain("✕ acme/finance failed to load, so the calls it covers are denied."); }); it("does not call a pack of Jev checks alone switched off", async () => { // Its checks are live wherever Jev is configured; "everything switched off" - // sent people to enable policies the pack does not have. - installPack({ - policies: [], - semantic: [ - { - name: "acme-check", - title: "Did the thing", - appliesTo: ["shell"], - mode: "deny", - userCanOverride: true, - probes: [{ id: "does", instructions: "It does the thing." }], - guidance: "Ask first.", - }, - ], - }); - const text = await run(); - expect(text).not.toMatch(/Nothing is enforcing/); - expect(text).toMatch(/1 Jev check from the pack above/); + // sent people to enable policies the pack does not have. It has no regex + // policies, so it gets no categories, no fold line and no `enabled` row — + // its row counts the checks instead. + installPack({ policies: [], semantic: [JEV_CHECK] }); + const text = await run(); + expect(text).toMatch(/^ {2}pack\s+acme\/finance@1\.2\.0 {2}· {2}1 Jev check$/m); + expect(text).not.toMatch(/more off/); + expect(text).not.toMatch(/enabled\s+0 of/); }); it("shows where a pack came from, since its id is only what it says it is", async () => { installPack({ id: "FailproofAI/policies", source: "github:acme/evil@v9.9.9" }); const text = await run(); - expect(text).toContain("Pack — FailproofAI/policies@1.2.0 · github:acme/evil@v9.9.9"); + expect(text).toContain("pack FailproofAI/policies@1.2.0 · from github:acme/evil@v9.9.9"); }); it("does not say nothing is enforcing while a refused pack is denying", async () => { + // The refusal fails CLOSED, so the generic "not enforcing" line would say + // the opposite of what is happening. The pack is named, and so is the deny. installPack({ minCliVersion: "99.0.0" }); const text = await run(); - expect(text).toMatch(/will not load/); - expect(text).toMatch(/DENIED/); - expect(text).not.toMatch(/Nothing is enforcing/); + expect(text).toContain("✕ acme/finance failed to load, so the calls it covers are denied."); + expect(text).toContain("failproofai policies remove acme/finance"); + expect(text).not.toContain("Policies are not enforcing yet."); }); - it("keeps the config footer and any warning at the very end", async () => { - // A footer printed between two sections reads as the end of the output, and - // a warning above three more sections is one nobody scrolls back to. - // A pack has to be installed for an unknown key to BE unknown: the names a - // `policyParams` key may use are the policies a pack carries, and with none - // installed there is nothing to check a typo against. + it("puts the one attention line under the pack rows, and prints no Config footer", async () => { + // Deliberately changed (decisions D14 and D6). This test used to pin a + // `Config: ` footer with every warning printed after it. The design + // has no footer and at most one ▲, which sits between the kv rows and the + // first category. A pack has to be installed for an unknown key to BE + // unknown — the names a `policyParams` key may use are the policies a pack + // carries — and hooks wired, or "not enforcing" outranks it. + wireClaude(); installPack(); writeFileSync( join(home, "policies-config.json"), JSON.stringify({ enabledPolicies: [], policyParams: { "not-a-policy": { x: 1 } } }), ); const text = await run(); - const config = text.indexOf("Config:"); - const warn = text.indexOf("unknown policyParams key"); - expect(config).toBeGreaterThan(0); - expect(warn).toBeGreaterThan(config); + const enabled = text.indexOf("enabled 2 of 2"); + const warn = text.indexOf('▲ Unknown policyParams key "not-a-policy", possibly a typo.'); + const heading = text.indexOf("FINANCE 2 of 2"); + expect(enabled).toBeGreaterThan(0); + expect(warn).toBeGreaterThan(enabled); + expect(heading).toBeGreaterThan(warn); + expect(text).not.toContain("Config:"); }); it("does not call a parameter saved through the dashboard a typo", async () => { @@ -199,7 +250,9 @@ describe("failproofai policies", () => { // keys against BARE policy names only, so every parameter a user saved in // the UI was reported as a "possible typo" and shipped as a // `policy_params_validation_warning` event, while the key was in fact the - // only spelling that takes effect. + // only spelling that takes effect. Same setup as the test below, which + // proves the line would show here if the key were unknown. + wireClaude(); installPack(); writeFileSync( join(home, "policies-config.json"), @@ -209,13 +262,16 @@ describe("failproofai policies", () => { }), ); const text = await run(); - expect(text).not.toMatch(/unknown policyParams key/); + expect(text).not.toMatch(/Unknown policyParams key/); + // Nothing at all between the kv rows and the first category. + expect(text).toContain("enabled 2 of 2\n\nFINANCE 2 of 2"); }); it("still flags a key that names no installed policy", async () => { // The other half: widening the known set to both spellings must not turn // the typo warning off. A key qualified with a pack that is not installed // configures nothing, exactly like a misspelled bare name. + wireClaude(); installPack(); writeFileSync( join(home, "policies-config.json"), @@ -225,17 +281,22 @@ describe("failproofai policies", () => { }), ); const text = await run(); - expect(text).toMatch(/unknown policyParams key/); - expect(text).toContain("no-such-policy"); + expect(text).toContain('▲ Unknown policyParams key "pack/acme/finance/no-such-policy", possibly a typo.'); }); - it("says nothing is installed, and what to run", async () => { + it("says nothing is enforcing on an empty machine, and what to run", async () => { + // Deliberately changed (D14, D18): "nothing installed" and its paragraph + // became the one generic line plus the design's next step. `config` is the + // fix because setup is the guided path that wires the hooks. const text = await run(); - expect(text).toContain("nothing installed"); - // `config` rather than `policies --install`: setup is the guided path that - // wires the hooks, and with the Recommended/Customize fork gone it is one - // linear flow — daemon, harnesses, cloud. - expect(text).toContain("failproofai config"); + expect(text).toContain( + [ + "▲ Policies are not enforcing yet. · failproofai config", + " Turn on ours: failproofai policies add FailproofAI/policies", + // Ours first is a convenience, not a channel. + " Or anyone's: failproofai policies add /", + ].join("\n"), + ); }); it("never runs past the terminal edge", async () => { @@ -246,3 +307,281 @@ describe("failproofai policies", () => { } }); }); + +describe("folding and --all", () => { + it("folds off policies into one line per category, and points at --all", async () => { + installPack({ enabled: ["block-big-refund"] }); + const text = await run(); + expect(text).toMatch(/^ {2}○ 1 more off$/m); + expect(text).toContain("See all 2 with failproofai policies --all"); + }); + + it("lists every policy under --all, the off ones by name, and folds nothing", async () => { + installPack({ enabled: ["block-big-refund"] }); + const text = await run({ all: true }); + expect(text).toMatch(/● block-big-refund\s+Block big refunds/); + expect(text).toMatch(/○ require-note\s+Require a note/); + expect(text).not.toMatch(/more off/); + expect(text).not.toContain("See all"); + }); + + it("says nothing about --all when nothing is folded", async () => { + installPack(); + const text = await run(); + expect(text).toMatch(/● require-note/); + expect(text).not.toContain("See all"); + }); + + it("folds a category with nothing on into its line alone", async () => { + installPack({ enabled: [] }); + const text = await run(); + expect(text).toMatch(/^FINANCE {2}0 of 2\n {2}○ 2 more off$/m); + }); +}); + +describe("more than one pack", () => { + const OTHER = { + ...FINANCE, + id: "acme/other", + version: "0.1.0", + source: "github:acme/other@v0.1.0", + policies: [ + { name: "other-guard", description: "Guard something else", category: "Finance", defaultEnabled: true, match: {} }, + ], + }; + + it("names each pack once at the top", async () => { + installPacks([FINANCE, OTHER]); + const text = await run(); + expect(text).toMatch(/^ {2}pack\s+acme\/finance@1\.2\.0\n {2}pack\s+acme\/other@0\.1\.0\n {2}enabled\s+3 of 3$/m); + }); + + it("prefixes category headings with the pack id, so same-named categories stay apart", async () => { + installPacks([FINANCE, OTHER]); + const text = await run(); + expect(text).toMatch(/^ACME\/FINANCE · FINANCE {2}2 of 2$/m); + expect(text).toMatch(/^ACME\/OTHER · FINANCE {2}1 of 1$/m); + }); + + it("keeps headings as designed beside a pack of Jev checks alone, which has no categories", async () => { + installPacks([FINANCE, { ...OTHER, policies: [], semantic: [JEV_CHECK] }]); + const text = await run(); + expect(text).toMatch(/^FINANCE {2}2 of 2$/m); + expect(text).toMatch(/^ {2}pack\s+acme\/other@0\.1\.0 {2}· {2}1 Jev check$/m); + }); +}); + +describe("the other sources", () => { + it("lists a custom policy file that is missing as a failed row, and says it is not running", async () => { + wireClaude(); + installPack(); + const missing = join(project, "gone-policies.mjs"); + writeFileSync( + join(home, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], customPoliciesPaths: [missing] }), + ); + const text = await run(); + expect(text).toMatch(/^CUSTOM POLICIES$/m); + expect(text).toMatch(new RegExp(`✕ ${missing.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}\\s+not found`)); + expect(text).toContain("▲ A custom policy file was not found, so its policies are not running."); + }); + + it("warns about hooks in more than one scope when nothing is more severe", async () => { + wireClaude(); + wireClaude(project); + installPack(); + const text = await run(); + expect(text).toContain( + "▲ Hooks are installed in multiple scopes: user, project. · failproofai policies --uninstall --scope ", + ); + }); + + it("lets the more severe line win, one attention line at most", async () => { + // Unwired AND an unknown key: "not enforcing" is the line, never both. + installPack(); + writeFileSync( + join(home, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], policyParams: { "not-a-policy": { x: 1 } } }), + ); + const text = await run(); + expect(text).toContain("▲ Policies are not enforcing yet."); + expect(text).not.toContain("Unknown policyParams key"); + expect(text.match(/^[▲✕] /gm)).toHaveLength(1); + }); +}); + +describe("renderPoliciesScreen", () => { + const strip = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + const SCREEN: PoliciesScreen = { + packs: [ + { + id: "FailproofAI/policies", + version: "06b802b", + source: "github:FailproofAI/policies@06b802b", + observe: false, + jevChecks: 0, + policies: [ + { name: "sanitize-jwt", description: "Redact JWTs in tool output", category: "Sanitize", on: true }, + { name: "block-sudo", description: "Block sudo", category: "Dangerous Commands", on: true }, + { name: "block-rm-rf", description: "Block recursive force deletes", category: "Dangerous Commands", on: false }, + { name: "block-force-push", description: "Block force-pushing to any branch", category: "Git", on: false }, + ], + }, + ], + refused: [], + attention: { failed: false, text: "Policies are not enforcing yet.", fix: "failproofai config" }, + custom: [], + convention: [], + cloud: null, + }; + + it("counts the builtins this build enforces itself when no pack is installed, naming none", async () => { + // The migration shim: handler.ts registers enabledPolicies when no regex + // pack is installed. Without this section the screen showed nothing for a + // machine enforcing all of them, while config --status counted them. + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + const lines = renderPoliciesScreen( + { + ...SCREEN, + packs: [], + attention: null, + legacy: [ + { name: "block-sudo", description: "Block sudo" }, + { name: "block-rm-rf", description: "Block recursive force deletes" }, + ], + }, + { version: "1.0.11", cols: 104, color: false }, + ); + expect(lines).toEqual([ + "failproof ai v1.0.11 · Policies", + "", + "BUILT IN 2 on, from before packs", + " Move them into a pack: failproofai policies add FailproofAI/policies", + ]); + }); + + function withTruecolor(fn: () => T): T { + const before = { COLORTERM: process.env.COLORTERM, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }; + process.env.COLORTERM = "truecolor"; + process.env.TERM = "xterm-256color"; + delete process.env.NO_COLOR; + try { + return fn(); + } finally { + for (const [key, value] of Object.entries(before)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + it("draws the design's screen, line for line, with colour off", async () => { + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + expect(renderPoliciesScreen(SCREEN, { version: "1.0.11", cols: 104, color: false })).toEqual([ + "failproof ai v1.0.11 · Policies", + "", + " pack FailproofAI/policies@06b802b", + " enabled 2 of 4", + "", + "▲ Policies are not enforcing yet. · failproofai config", + "", + "SANITIZE 1 of 1", + " ● sanitize-jwt Redact JWTs in tool output", + "", + "DANGEROUS COMMANDS 1 of 2", + " ● block-sudo Block sudo", + " ○ 1 more off", + "", + "GIT 0 of 1", + " ○ 1 more off", + "", + "See all 4 with failproofai policies --all", + ]); + }); + + it("prints no escape at all with colour off", async () => { + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + const lines = renderPoliciesScreen( + { ...SCREEN, packs: [{ ...SCREEN.packs[0], observe: true }] }, + { version: "1.0.11", color: false }, + ); + expect(lines.join("\n")).not.toContain("\x1B"); + }); + + it("paints by role with colour on: mint on, grey off and tags, amber caution, pink commands", async () => { + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + const lines = withTruecolor(() => + renderPoliciesScreen( + { ...SCREEN, packs: [{ ...SCREEN.packs[0], observe: true }] }, + { version: "1.0.11", color: true }, + ), + ); + const text = lines.join("\n"); + expect(text).toContain("\x1B[38;2;102;209;181m●\x1B[0m sanitize-jwt"); + expect(text).toContain("\x1B[38;2;118;127;139m○\x1B[0m 1 more off"); + expect(text).toContain("\x1B[38;2;118;127;139mobserve\x1B[0m"); + expect(text).toContain("\x1B[38;2;227;179;65m▲\x1B[0m Policies are not enforcing yet."); + expect(text).toContain("\x1B[38;2;228;88;125mfailproofai policies --all\x1B[0m"); + // The same words underneath. + expect(strip(text)).toContain("pack FailproofAI/policies@06b802b · observe"); + }); + + it("sizes the name column from the names shown, at least the design's 32 and always two past the longest", async () => { + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + const long = "require-no-conflicts-before-stop"; // the longest real name: 32 + const lines = renderPoliciesScreen( + { + ...SCREEN, + attention: null, + packs: [ + { + ...SCREEN.packs[0], + policies: [ + { name: long, description: "Require no merge conflicts before stopping", category: "Workflow", on: true }, + { name: "block-sudo", description: "Block sudo", category: "Dangerous Commands", on: true }, + ], + }, + ], + }, + { version: "1.0.11", color: false }, + ); + const row = lines.find((l) => l.includes(long))!; + expect(row).toBe(` ● ${long} Require no merge conflicts before stopping`); + // Every pack section shares the column, so descriptions line up down the screen. + const sudo = lines.find((l) => l.includes("block-sudo"))!; + expect(sudo.indexOf("Block sudo")).toBe(row.indexOf("Require no")); + }); + + it("shortens descriptions on a terminal and never a name", async () => { + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + const fitted = renderPoliciesScreen(SCREEN, { version: "1.0.11", cols: 44, color: false, fit: true }); + const row = fitted.find((l) => l.includes("sanitize-jwt"))!; + expect(row.startsWith(" ● sanitize-jwt")).toBe(true); + expect(row.endsWith("…")).toBe(true); + expect(visibleWidth(row)).toBeLessThanOrEqual(44); + // A pipe keeps every word. + const piped = renderPoliciesScreen(SCREEN, { version: "1.0.11", cols: 44, color: false }); + expect(piped.find((l) => l.includes("sanitize-jwt"))).toContain("Redact JWTs in tool output"); + }); + + it("draws custom, convention and cloud sections in the same style", async () => { + const { renderPoliciesScreen } = await import("@/src/hooks/manager"); + const text = renderPoliciesScreen( + { + ...SCREEN, + packs: [], + attention: null, + custom: [ + { state: "on", name: "team-guard", description: "Guard the team" }, + { state: "failed", name: "/repo/broken.mjs", description: "failed to load" }, + ], + convention: [{ scope: "project", rows: [{ state: "on", name: "team-policies.mjs", description: "2 hooks (1 off)" }] }], + cloud: { deployment: 7, rows: [{ state: "on", name: "watch-only", description: "v1", tag: "observe" }] }, + }, + { version: "1.0.11", color: false }, + ).join("\n"); + expect(text).toMatch(/^CUSTOM POLICIES\n {2}● team-guard\s+Guard the team\n {2}✕ \/repo\/broken\.mjs\s+failed to load$/m); + expect(text).toMatch(/^CONVENTION POLICIES {2}project\n {2}● team-policies\.mjs\s+2 hooks \(1 off\)$/m); + expect(text).toMatch(/^CLOUD-MANAGED {2}deployment 7\n {2}● watch-only\s+v1 {2}· {2}observe$/m); + }); +}); diff --git a/__tests__/hooks/policy-attribution.test.ts b/__tests__/hooks/policy-attribution.test.ts index 4ee78c0d1..d758315ee 100644 --- a/__tests__/hooks/policy-attribution.test.ts +++ b/__tests__/hooks/policy-attribution.test.ts @@ -30,6 +30,13 @@ vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-id") vi.mock("../../src/hooks/hook-logger", () => ({ hookLogInfo: vi.fn(), hookLogWarn: vi.fn(), hookLogError: vi.fn(), })); +vi.mock("../../src/hooks/untraced-note", () => ({ + // Unmocked, the handler's once-per-session note reads the REAL + // ~/.failproofai/config.json and, when it leaves this agent untraced, writes + // state/untraced-notes.json there. The note decides nothing, so mocking it + // changes no outcome here. + noteUntracedAgent: vi.fn(() => false), +})); vi.mock("../../src/hooks/pack-manifest", () => ({ // Isolation, not convenience: unmocked, `readInstalledPacks` reads the REAL // ~/.failproofai/policies/packs of whoever runs the suite, so these tests would diff --git a/__tests__/hooks/print-block-colour.test.ts b/__tests__/hooks/print-block-colour.test.ts new file mode 100644 index 000000000..2968be40b --- /dev/null +++ b/__tests__/hooks/print-block-colour.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +/** + * printBlock decides colour from the stream it writes to. + * + * backfill, uninstall, harness and jev render their lines for stdout before + * they know whether the run succeeds, then print a failure to stderr. With + * stdout a terminal and stderr redirected, `2>err.log` captured + * `^[[38;2;240;113;120m✕^[[0m Unknown harness: bogus.`. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { paint, printBlock, type TTYOut } from "../../src/hooks/tui"; + +const coloured = `${paint(true).err("✕")} Unknown harness: bogus.`; + +function capture(isTTY: boolean): { out: TTYOut; text: () => string } { + const write = vi.fn((_chunk: unknown) => true); + return { out: { isTTY, columns: 80, write } as unknown as TTYOut, text: () => String(write.mock.calls[0]?.[0]) }; +} + +describe("printBlock colour", () => { + const savedNoColor = process.env.NO_COLOR; + afterEach(() => { + if (savedNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = savedNoColor; + }); + + it("writes no escape codes to a stream that is not a terminal, whatever the lines were drawn for", () => { + delete process.env.NO_COLOR; + expect(coloured).toContain("\x1b["); + const { out, text } = capture(false); + printBlock(out, [coloured, ` Known: claude, ${paint(true).pink("codex")}`]); + expect(text()).not.toContain("\x1b"); + expect(text()).toBe("\n✕ Unknown harness: bogus.\n Known: claude, codex\n\n"); + }); + + it("drops colour under NO_COLOR even on a terminal", () => { + process.env.NO_COLOR = "1"; + const { out, text } = capture(true); + printBlock(out, [coloured]); + expect(text()).not.toContain("\x1b"); + }); + + it("keeps colour on a terminal that shows it", () => { + delete process.env.NO_COLOR; + const { out, text } = capture(true); + printBlock(out, [coloured]); + expect(text()).toContain(coloured); + }); +}); diff --git a/__tests__/hooks/publish-command.test.ts b/__tests__/hooks/publish-command.test.ts index fb1094189..95b8fe660 100644 --- a/__tests__/hooks/publish-command.test.ts +++ b/__tests__/hooks/publish-command.test.ts @@ -352,12 +352,16 @@ describe("publish to a release", () => { expect(requests.every((q) => q.authorization === `Bearer ${TOKEN}`)).toBe(true); const text = r.lines.join("\n"); - expect(text).toMatch(/Published acme\/support@1\.0\.0 to acme\/support at tag 1\.0\.0\./); - expect(text).toMatch(/3 assets attached/); + // The design's done screen: the header, one `✓` sentence with the policy + // count, then how to install it. "3 assets attached" was the same line on + // every publish and went with the rest of the fine print (D6); the three + // uploads are asserted above, where they are a fact rather than a sentence. + expect(r.lines[0]).toMatch(/^failproof ai {2}v\S+ {2}· {2}Publish$/); + expect(r.lines).toContain("✓ Published acme/support@1.0.0 with 1 policy."); // The line the publisher hands to someone else. - expect(r.lines).toContain(" failproofai policies add acme/support"); + expect(r.lines).toContain(" Anyone can install it: failproofai policies add acme/support"); // A public repository is not warned about. - expect(text).not.toMatch(/PRIVATE/); + expect(text).not.toMatch(/is private/); }); it("tells the author to pass on the rollback reminder for a pack of Jev checks alone", async () => { @@ -545,7 +549,9 @@ describe("an existing private repository", () => { const r = await publishTo(); const text = r.lines.join("\n"); - expect(text).toMatch(/acme\/support is PRIVATE/); + // One `✕` sentence, then each way out on its own line. Said in sentence + // case now: the old "is PRIVATE" shouted the part that is not the reason. + expect(r.lines[0]).toMatch(/^✕ acme\/support is private, so nothing was published/); // "It is private" is not the reason — plenty of private things work. The // reason is that the install carries no credential, so it 404s. expect(text).toMatch(/anonymous HTTPS/); @@ -570,13 +576,13 @@ describe("an existing private repository", () => { ]); const text = r.lines.join("\n"); expect(text).toMatch(/Published acme\/support@1\.0\.0/); - // The flag buys a publish, never a working install, so the warning stays. - expect(text).toMatch(/acme\/support is PRIVATE/); - expect(text).toMatch(/anonymous HTTPS/); - // Printed INSTEAD of the install lines, not underneath them: a reader who + // The flag buys a publish, never a working install, so the warning stays: + // a `▲` line right under the `✓`, in the install line's place. + expect(text).toMatch(/▲ acme\/support is private, and installs fetch over anonymous HTTPS/); + // Printed INSTEAD of the install line, not underneath it: a reader who // copies the first command they see must not be copying one that 404s. - expect(text).not.toMatch(/Anyone can now install it/); - expect(r.lines).not.toContain(" failproofai policies add acme/support"); + expect(text).not.toMatch(/Anyone can install it/); + expect(r.lines.some((l) => l.includes("failproofai policies add"))).toBe(false); }); it("leaves a public repository alone, flag or no flag", async () => { @@ -591,8 +597,8 @@ describe("an existing private repository", () => { expect(uploadsOf()).toHaveLength(3); const text = r.lines.join("\n"); expect(text).toMatch(/Published acme\/support@1\.0\.0/); - expect(text).not.toMatch(/PRIVATE/); - expect(r.lines).toContain(" failproofai policies add acme/support"); + expect(text).not.toMatch(/is private/); + expect(r.lines).toContain(" Anyone can install it: failproofai policies add acme/support"); }); }); @@ -716,10 +722,12 @@ describe("the version, when nobody says what it is", () => { const version = packCli.versionFromCommit(sha); // The whole line, not just the `@version` fragment: a version carrying an // extra suffix still CONTAINS the fragment, so the fragment alone would - // read a counted `-2` as a pass. The tag is pinned in the same breath - // because a release whose tag and version disagree installs as neither. + // read a counted `-2` as a pass. The tag is pinned in the same breath: + // the done screen names the repository and tag only when they differ from + // the id and version, so this short form IS the claim that the release's + // tag agrees with its version — one that disagreed would install as neither. expect(r.lines.join("\n")).toContain( - `Published acme/guards@${version} to acme/guards at tag ${version}.`, + `Published acme/guards@${version} with 1 policy.`, ); expect(manifestVersion().version).toBe(version); // The ABBREVIATION is what reaches the artifact — the version is a prefix @@ -744,7 +752,7 @@ describe("the version, when nobody says what it is", () => { const first = await publish([entry, "--repo", "acme/guards", "--out", OUT()]); expect(first.exitCode).toBe(0); expect(first.lines.join("\n")).toContain( - `Published acme/guards@${version} to acme/guards at tag ${version}.`, + `Published acme/guards@${version} with 1 policy.`, ); // The release the first publish made is now sitting on that tag. @@ -754,7 +762,7 @@ describe("the version, when nobody says what it is", () => { const second = await publish([entry, "--repo", "acme/guards", "--out", OUT()]); expect(second.exitCode).toBe(0); expect(second.lines.join("\n")).toContain( - `Published acme/guards@${version} to acme/guards at tag ${version}.`, + `Published acme/guards@${version} with 1 policy.`, ); expect(manifestVersion().version).toBe(version); // Asserted at the wire too, because the printed line is downstream of the @@ -855,7 +863,7 @@ describe("the version, when nobody says what it is", () => { const second = await publish(["--repo", "acme/guards"]); expect(second.exitCode).toBe(0); expect(second.lines.join("\n")).toContain( - `Published acme/guards@${version} to acme/guards at tag ${version}.`, + `Published acme/guards@${version} with 1 policy.`, ); } finally { process.chdir(before); @@ -896,7 +904,7 @@ describe("the version, when nobody says what it is", () => { const second = await publish([entry, "--repo", "acme/guards", "--out", out]); expect(second.exitCode).toBe(0); expect(second.lines.join("\n")).toContain( - `Published acme/guards@${version} to acme/guards at tag ${version}.`, + `Published acme/guards@${version} with 1 policy.`, ); // Dirt two levels up from the file being published, with the same skip in @@ -930,13 +938,14 @@ describe("the version, when nobody says what it is", () => { // // The whole line each time, not the `@version` fragment: the fragment is // still contained in a version carrying a suffix, and the tag has to agree - // with the version or the release installs as neither. + // with the version or the release installs as neither — the short form is + // printed only when it does (see "publishes a clean checkout"). const bare = writeEntry(); const bareOut = join(work, "out-bare"); const r1 = await publish([bare, "--repo", "acme/guards", "--version", "1.0.0", "--out", bareOut]); expect(r1.exitCode).toBe(0); expect(r1.lines.join("\n")).toContain( - "Published acme/guards@1.0.0 to acme/guards at tag 1.0.0.", + "Published acme/guards@1.0.0 with 1 policy.", ); // No checkout, so there is no commit to record — and an invented one would // be worse than none, because `commit` is what a reader resolves back to @@ -949,7 +958,7 @@ describe("the version, when nobody says what it is", () => { const r2 = await publish([entry, "--repo", "acme/guards", "--version", "2.0.0", "--out", dirtyOut]); expect(r2.exitCode).toBe(0); expect(r2.lines.join("\n")).toContain( - "Published acme/guards@2.0.0 to acme/guards at tag 2.0.0.", + "Published acme/guards@2.0.0 with 1 policy.", ); // NO commit is recorded on a dirty tree, even though --version let the // publish through. `commit` claims these bytes came from that commit, and @@ -1029,9 +1038,9 @@ describe("a repository that is not there yet", () => { const create = requests.find((q) => q.method === "POST" && /repos$/.test(q.path)); expect(create, "the repository has to have been created for this to mean anything").toBeDefined(); expect(JSON.parse(create!.body.toString()).private).toBe(false); - // And because it came back public, the install lines stand — the flag - // suppresses them only for a destination that really is private. - expect(r.lines).toContain(" failproofai policies add acme/guards"); + // And because it came back public, the install line stands — the flag + // suppresses it only for a destination that really is private. + expect(r.lines).toContain(" Anyone can install it: failproofai policies add acme/guards"); }); it("uses the personal endpoint when the credential owns the name", async () => { @@ -1093,3 +1102,44 @@ describe("a repository that is not there yet", () => { expect(r.lines.join("\n")).toMatch(/gh repo create/); }); }); + +describe("the done screen", () => { + it("keeps the side effects as ✓ lines above the publish, and names a differing tag", async () => { + // A side effect on somebody's GitHub account is never silent, and a tag + // that is not the version — `--tag v1.0.0` — is said where it went. + github.repo = { status: 404, body: { message: "Not Found" } }; + const r = await publish([writeEntry(), "--repo", "acme/guards", "--version", "1.0.0", "--tag", "v1.0.0", "--out", join(work, "out")]); + + expect(r.exitCode, r.lines.join("\n")).toBe(0); + const created = r.lines.indexOf("✓ Created acme/guards (public)."); + const published = r.lines.indexOf("✓ Published acme/guards@1.0.0 with 1 policy to acme/guards at tag v1.0.0."); + expect(created).toBeGreaterThan(0); + expect(published).toBe(created + 1); + expect(r.lines[published + 1]).toBe(" Anyone can install it: failproofai policies add acme/guards"); + }); + + it("paints by role on a terminal — mint ✓, pink command — and writes no escape in a pipe", async () => { + const plain = await publish([writeEntry(), "--repo", "acme/support", "--version", "1.0.0", "--out", join(work, "plain")]); + expect(plain.lines.join("\n")).not.toMatch(/\x1B\[/); + + const saved = { COLORTERM: process.env.COLORTERM, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }; + const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + process.env.COLORTERM = "truecolor"; + process.env.TERM = "xterm-256color"; + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + try { + const r = await publish([writeEntry(), "--repo", "acme/support", "--version", "1.0.0", "--out", join(work, "tty")]); + const out = r.lines.join("\n"); + expect(out).toContain("\x1B[38;2;102;209;181m✓\x1B[0m Published acme/support@1.0.0 with 1 policy."); + expect(out).toContain(" Anyone can install it: \x1B[38;2;228;88;125mfailproofai policies add acme/support\x1B[0m"); + } finally { + if (tty) Object.defineProperty(process.stdout, "isTTY", tty); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); +}); diff --git a/__tests__/hooks/report-body.test.ts b/__tests__/hooks/report-body.test.ts new file mode 100644 index 000000000..6d2b841af --- /dev/null +++ b/__tests__/hooks/report-body.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment node +/** + * Which line of a failed action report carries the ✕. + * + * printReport used to put it on the FIRST top-level line whatever that line + * said, and indent everything after it as "the fix". `update` opens on "no + * migration was needed", `migrate` on its backup, `flush --wait` on "Requested + * delivery" — so the ✕ landed on a success and the real error read as its + * remedy. + */ +import { describe, it, expect } from "vitest"; +import { reportBody } from "../../src/hooks/report-body"; + +describe("reportBody", () => { + it("leads an unmarked failure with ✕ and indents the fix under it", () => { + expect( + reportBody(["failproofaid is stopped, and it is what delivers batches.", "Start it with `failproofai config`."], { + ok: false, + width: 80, + }), + ).toEqual(["✕ failproofaid is stopped, and it is what delivers batches.", " Start it with `failproofai config`."]); + }); + + it("adds no glyph to a run that succeeded", () => { + expect(reportBody(["2 batches spooled. Requested delivery.", "Spool drained."], { ok: true, width: 80 })).toEqual([ + "2 batches spooled. Requested delivery.", + "Spool drained.", + ]); + }); + + it("never puts the ✕ on update's success line when the daemon marks its own failure", () => { + const body = reportBody( + [ + "Home is at layout 3; no migration was needed.", + "", + "✕ failproofaid 1.0.0 was not installed: root privileges are required.", + " Run `sudo -v` and then `failproofai update` again.", + ], + { ok: false, width: 120 }, + ); + expect(body[0]).toBe("Home is at layout 3; no migration was needed."); + expect(body.filter((l) => l.includes("✕"))).toEqual([ + "✕ failproofaid 1.0.0 was not installed: root privileges are required.", + ]); + }); + + it("keeps migrate's failed step as the failure, not as a fix under the backup line", () => { + const body = reportBody( + [ + " migrated layout 2 → 3", + " FAILED layout 3 → 4", + "", + "Saved first: config.json, credentials.json", + "", + "✕ Step 3 → 4 did not finish: EACCES", + " The home is still marked layout 2 and will be retried.", + ], + { ok: false, width: 120 }, + ); + expect(body).toContain("Saved first: config.json, credentials.json"); + expect(body).toContain("✕ Step 3 → 4 did not finish: EACCES"); + expect(body.join("\n")).not.toMatch(/✕ Saved first/); + }); + + it("treats a ▲ line as marked too: flush --wait's timeout gets no ✕ on its first line", () => { + const body = reportBody( + ["3 batches spooled. Requested delivery.", "▲ Still 1 batch spooled after 3s.", " Delivery continues in the background."], + { ok: false, width: 80 }, + ); + expect(body[0]).toBe("3 batches spooled. Requested delivery."); + expect(body.join("\n")).not.toContain("✕"); + }); + + it("continues a wrapped glyph line under its words, not at column 0", () => { + const body = reportBody(["✕ " + "word ".repeat(12).trim()], { ok: false, width: 30 }); + expect(body.length).toBeGreaterThan(1); + for (const continuation of body.slice(1)) expect(continuation.startsWith(" ")).toBe(true); + }); + + it("continues a wrapped auto-led line under its words, within the width", () => { + const body = reportBody(["word ".repeat(12).trim()], { ok: false, width: 30 }); + expect(body[0].startsWith("✕ ")).toBe(true); + for (const line of body) expect(line.length).toBeLessThanOrEqual(30); + for (const continuation of body.slice(1)) expect(continuation.startsWith(" ")).toBe(true); + }); +}); diff --git a/__tests__/hooks/session-pause-cli.test.ts b/__tests__/hooks/session-pause-cli.test.ts index 357fcc8c8..f84fd012b 100644 --- a/__tests__/hooks/session-pause-cli.test.ts +++ b/__tests__/hooks/session-pause-cli.test.ts @@ -6,13 +6,40 @@ import { resolve } from "node:path"; vi.mock("../../src/hooks/hooks-config", () => ({ readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })) })); vi.mock("../../src/hooks/hook-activity-store", () => ({ getAllHookActivityEntries: vi.fn(() => []) })); -import { runPauseCommand, effectiveCeilingMs } from "../../src/hooks/session-pause-cli"; +import { + runPauseCommand, + effectiveCeilingMs, + pauseClockTime, + pauseDurationWords, +} from "../../src/hooks/session-pause-cli"; import { readActivePause, writePause, PAUSE_CEILING_MS } from "../../src/hooks/session-pause"; import { readMergedHooksConfig } from "../../src/hooks/hooks-config"; import { getAllHookActivityEntries } from "../../src/hooks/hook-activity-store"; let stateDir: string; -const NOW = 1_000_000_000; +const NOW = 1_000_000_000; // 1970-01-12T13:46:40Z +// Pinned so the clock reads the same wherever the suite runs: today's locale +// behaviour is kept, but a test cannot depend on the machine's zone. +const CLOCK = { locale: "en-GB", timeZone: "UTC" }; +const PLAIN = { color: false, cols: 104 }; +const strip = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + +/** Run `fn` as a truecolor terminal would, then put the environment back. */ +function withTrueColor(fn: () => T): T { + const keys = ["COLORTERM", "TERM", "NO_COLOR"] as const; + const saved = keys.map((k) => [k, process.env[k]] as const); + process.env.COLORTERM = "truecolor"; + process.env.TERM = "xterm-256color"; + delete process.env.NO_COLOR; + try { + return fn(); + } finally { + for (const [k, v] of saved) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} beforeEach(() => { vi.clearAllMocks(); @@ -87,10 +114,97 @@ describe("--pause", () => { expect(readActivePause("s1", NOW)).toBeNull(); }); - it("always says the pause expires on its own, and that cloud keeps enforcing", () => { - const out = runPauseCommand({ action: "pause", sessionId: "s1", cwd: "/tmp/p", now: NOW }).lines.join("\n"); - expect(out).toMatch(/resumes at/); - expect(out).toMatch(/Cloud-managed policies keep enforcing/); + it("always says when the pause ends, that cloud keeps enforcing, and how to end it early", () => { + vi.mocked(getAllHookActivityEntries).mockReturnValue([ + { timestamp: NOW - 1_000, sessionId: "here", cwd: "/tmp/p" }, + ] as never); + const r = runPauseCommand({ action: "pause", cwd: "/tmp/p", now: NOW, render: PLAIN, clock: CLOCK }); + expect(r.lines).toEqual([ + "✓ Paused enforcement for this session until 14:16 (30 minutes).", + " Cloud-managed policies keep enforcing.", + " Resume early: failproofai config --resume", + ]); + }); + + it("says it extended a live pause, with the new end", () => { + runPauseCommand({ action: "pause", duration: "10m", sessionId: "s1", cwd: "/tmp/p", now: NOW, render: PLAIN }); + const out = runPauseCommand({ + action: "pause", + duration: "1h", + sessionId: "s1", + cwd: "/tmp/p", + now: NOW + 60_000, + render: PLAIN, + clock: CLOCK, + }).lines; + expect(out[0]).toBe("✓ Extended the pause for session s1 until 14:47 (1 hour)."); + expect(out.join("\n")).not.toMatch(/Capped/); + }); + + it("shows the duration actually granted when the 8h ceiling cuts an extension short", () => { + // The ceiling is measured from the FIRST pause in the run, so a renewal + // seven hours in gets one hour however much it asked for. Repeating the + // request would misstate when enforcement comes back. + runPauseCommand({ action: "pause", duration: "8h", sessionId: "s1", cwd: "/tmp/p", now: NOW, render: PLAIN }); + const later = NOW + 7 * 3_600_000; + const r = runPauseCommand({ + action: "pause", + duration: "4h", + sessionId: "s1", + cwd: "/tmp/p", + now: later, + render: PLAIN, + clock: CLOCK, + }); + expect(readActivePause("s1", later)!.expiresAt).toBe(NOW + PAUSE_CEILING_MS); + expect(r.lines[0]).toBe("✓ Extended the pause for session s1 until 21:46 (1 hour)."); + expect(r.lines[1]).toBe(" Capped at 8 hours from when this pause began."); + expect(r.lines.join("\n")).toMatch(/Cloud-managed policies keep enforcing\./); + }); + + it("names an explicit session back, and resumes it by id", () => { + const r = runPauseCommand({ action: "pause", sessionId: "abc-123", cwd: "/tmp/p", now: NOW, render: PLAIN, clock: CLOCK }); + expect(r.lines[0]).toBe("✓ Paused enforcement for session abc-123 until 14:16 (30 minutes)."); + expect(r.lines).toContain(" Resume early: failproofai config --resume --session abc-123"); + }); + + it("says why nothing was paused, with the command that names a session, in the screen language", () => { + const r = runPauseCommand({ action: "pause", duration: "10m", cwd: "/tmp/project", now: NOW, render: PLAIN }); + expect(r.exitCode).toBe(1); + expect(r.lines).toEqual([ + "✕ No recent agent session found for this directory.", + " Name one from the dashboard's activity view: failproofai config --pause 10m --session ", + ]); + }); + + it("paints the result for a terminal and leaves a pipe plain", () => { + const painted = withTrueColor(() => + runPauseCommand({ action: "pause", sessionId: "s1", cwd: "/tmp/p", now: NOW, render: { color: true, cols: 104 }, clock: CLOCK }), + ).lines; + expect(painted[0]).toContain("\x1B[38;2;102;209;181m✓"); // mint: done + expect(painted[2]).toContain("\x1B[38;2;228;88;125mfailproofai config --resume"); // pink: typeable + expect(painted.map(strip)).toEqual( + runPauseCommand({ action: "pause", sessionId: "s2", cwd: "/tmp/p", now: NOW, render: PLAIN, clock: CLOCK }) + .lines.map((l) => l.replace("s2", "s1")), + ); + const plain = runPauseCommand({ action: "pause", sessionId: "s3", cwd: "/tmp/p", now: NOW, render: PLAIN }).lines.join("\n"); + expect(plain).not.toContain("\x1B"); + }); +}); + +describe("pause wording helpers", () => { + it("spells durations out", () => { + expect(pauseDurationWords(30 * 60_000)).toBe("30 minutes"); + expect(pauseDurationWords(60_000)).toBe("1 minute"); + expect(pauseDurationWords(90 * 60_000)).toBe("1 hour 30 minutes"); + expect(pauseDurationWords(8 * 3_600_000)).toBe("8 hours"); + expect(pauseDurationWords(45_000)).toBe("45 seconds"); + expect(pauseDurationWords(1_000)).toBe("1 second"); + }); + + it("writes the clock to the minute, in the locale it is given", () => { + expect(pauseClockTime(NOW, CLOCK)).toBe("13:46"); + expect(pauseClockTime(NOW, { locale: "en-US", timeZone: "UTC" }).replace(/\s/g, " ")).toBe("1:46 PM"); }); }); @@ -102,6 +216,32 @@ describe("--resume", () => { expect(readActivePause("s1", NOW)).toBeNull(); }); + it("confirms in the same shape as a pause", () => { + vi.mocked(getAllHookActivityEntries).mockReturnValue([ + { timestamp: NOW - 1_000, sessionId: "here", cwd: "/tmp/p" }, + ] as never); + writePause({ sessionId: "here", durationMs: 600_000, now: NOW }); + expect(runPauseCommand({ action: "resume", cwd: "/tmp/p", now: NOW, render: PLAIN }).lines).toEqual([ + "✓ Resumed enforcement for this session.", + ]); + writePause({ sessionId: "named", durationMs: 600_000, now: NOW }); + expect(runPauseCommand({ action: "resume", sessionId: "named", cwd: "/tmp/p", now: NOW, render: PLAIN }).lines).toEqual([ + "✓ Resumed enforcement for session named.", + ]); + }); + + it("says plainly, without a tick, when there was nothing to resume", () => { + expect(runPauseCommand({ action: "resume", cwd: "/tmp/p", now: NOW, render: PLAIN }).lines).toEqual([ + "Nothing was paused for this directory.", + ]); + expect(runPauseCommand({ action: "resume", sessionId: "gone", cwd: "/tmp/p", now: NOW, render: PLAIN }).lines).toEqual([ + "Nothing was paused for session gone.", + ]); + expect(runPauseCommand({ action: "resume", all: true, cwd: "/tmp/p", now: NOW, render: PLAIN }).lines).toEqual([ + "Nothing was paused.", + ]); + }); + it("is a no-op, not an error, when nothing is paused", () => { const r = runPauseCommand({ action: "resume", sessionId: "s1", cwd: "/tmp/p", now: NOW }); expect(r.exitCode).toBe(0); @@ -111,25 +251,15 @@ describe("--resume", () => { it("--all clears every active pause", () => { writePause({ sessionId: "s1", durationMs: 600_000, now: NOW }); writePause({ sessionId: "s2", durationMs: 600_000, now: NOW }); - const r = runPauseCommand({ action: "resume", all: true, cwd: "/tmp/p", now: NOW }); + const r = runPauseCommand({ action: "resume", all: true, cwd: "/tmp/p", now: NOW, render: PLAIN }); expect(r.affected).toBe(2); + expect(r.lines).toEqual(["✓ Resumed enforcement for 2 sessions."]); expect(readActivePause("s1", NOW)).toBeNull(); expect(readActivePause("s2", NOW)).toBeNull(); }); }); -describe("--status", () => { - it("says so plainly when nothing is paused", () => { - const r = runPauseCommand({ action: "status", cwd: "/tmp/p", now: NOW }); - expect(r.lines.join("\n")).toMatch(/enforcement\s+active — nothing is paused/); - }); - - it("lists active pauses with time remaining, and omits expired ones", () => { - writePause({ sessionId: "live", durationMs: 600_000, now: NOW }); - writePause({ sessionId: "dead", durationMs: 1_000, now: NOW - 60_000 }); - const out = runPauseCommand({ action: "status", cwd: "/tmp/p", now: NOW }).lines.join("\n"); - expect(out).toMatch(/live/); - expect(out).not.toMatch(/dead/); - expect(out).toMatch(/10m left/); - }); -}); +// `--status` is a whole-machine screen now (config-status.ts). Its two tests +// moved with it, intent unchanged: config-status.test.ts → "says so plainly when +// nothing is paused" and "lists active pauses with time left, and omits expired +// ones". diff --git a/__tests__/hooks/session-pause-enforcement.test.ts b/__tests__/hooks/session-pause-enforcement.test.ts index 9d4a8929a..3be0c9506 100644 --- a/__tests__/hooks/session-pause-enforcement.test.ts +++ b/__tests__/hooks/session-pause-enforcement.test.ts @@ -34,6 +34,13 @@ vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-inst vi.mock("../../src/hooks/hook-logger", () => ({ hookLogInfo: vi.fn(), hookLogWarn: vi.fn(), hookLogError: vi.fn(), })); +vi.mock("../../src/hooks/untraced-note", () => ({ + // Unmocked, the handler's once-per-session note reads the REAL + // ~/.failproofai/config.json and, when it leaves this agent untraced, writes + // state/untraced-notes.json there. The note decides nothing, so mocking it + // changes no outcome here. + noteUntracedAgent: vi.fn(() => false), +})); vi.mock("../../src/hooks/pack-manifest", () => ({ // Isolation, not convenience: unmocked, `readInstalledPacks` reads the REAL // ~/.failproofai/policies/packs of whoever runs the suite, so these tests would diff --git a/__tests__/hooks/tui-final-frames.test.ts b/__tests__/hooks/tui-final-frames.test.ts new file mode 100644 index 000000000..831860e17 --- /dev/null +++ b/__tests__/hooks/tui-final-frames.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node +/** + * The last frame a prompt draws stays in scrollback, so it is never cut. + * + * Every live frame is cut to the terminal width so the next cursor-up lands + * where it should, and the collapse used the same default. On an 80-column + * terminal an unticked agent's summary (≈88 columns) was left behind as + * `…no longer hooked or coll`, and a long org or host in the connect summary + * lost the end of the hostname. + * + * Also: Enter pressed while the saved key is still being checked, when that + * check comes back refused, says what to do — as an empty Enter with no saved + * key does — instead of redrawing as if nothing was pressed. + */ +import { describe, it, expect, vi } from "vitest"; +import { PassThrough } from "node:stream"; +import { multiSelect, promptCloudKey, OPEN_SOURCE, type KeyVerdict, type TTYIn, type TTYOut } from "../../src/hooks/tui"; + +function terminal(columns = 80) { + const written: string[] = []; + const stdout = { + isTTY: true, + columns, + write: (chunk: string) => { + written.push(String(chunk)); + return true; + }, + } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (on: boolean) => void }).setRawMode = () => {}; + const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); + const lastFrame = () => strip(written.filter((w) => w.includes("\u001b[?2026h")).pop() ?? ""); + const plain = () => strip(written.join("")); + return { stdin, stdout, lastFrame, plain, type: (s: string) => stdin.write(s) }; +} + +const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms)); + +const REMOVED = + " Removed Claude Code, OpenAI Codex and GitHub Copilot: no longer hooked or collected. Sessions already collected are kept."; +const ACCEPTED = "✓ Key accepted for acme-corp-production-org on cloud.befailproof.example.internal.company.com"; + +describe("a prompt's last frame is never cut", () => { + it("multiSelect leaves a summary wider than the terminal whole", async () => { + const t = terminal(80); + const pending = multiSelect({ + message: "Which agents should failproofai trace?", + choices: [ + { label: "Claude Code", value: "claude", checked: true }, + { label: "OpenAI Codex", value: "codex" }, + ], + collapsed: () => ["AGENTS", REMOVED, ""], + stdin: t.stdin, + stdout: t.stdout, + }); + await tick(); + t.type("\r"); + await pending; + expect(REMOVED.length).toBeGreaterThan(80); + expect(t.lastFrame()).toContain(REMOVED); + }); + + it("promptCloudKey leaves the org and host whole", async () => { + const t = terminal(80); + const pending = promptCloudKey({ + message: "Connect to cloud", + meta: "cloud.befailproof.example.internal.company.com", + tabHint: "use open source instead", + emptyError: "Paste a key, or press tab to use open source.", + check: vi.fn(async (): Promise => ({ ok: true })), + collapsed: (answer) => ["CONNECT TO CLOUD", answer === OPEN_SOURCE ? "open source" : ACCEPTED], + stdin: t.stdin, + stdout: t.stdout, + }); + t.type("fpk_live_0123456789abcdef\r"); + await pending; + expect(t.lastFrame()).toContain(ACCEPTED); + }); + + it("still cuts the live frames, which are repainted", async () => { + const t = terminal(40); + const pending = promptCloudKey({ + message: "Connect to cloud", + meta: "cloud.befailproof.example.internal.company.com", + tabHint: "use open source instead", + emptyError: "Paste a key, or press tab to use open source.", + check: vi.fn(async (): Promise => ({ ok: true })), + collapsed: () => ["CONNECT TO CLOUD"], + stdin: t.stdin, + stdout: t.stdout, + }); + await tick(); + const live = t.lastFrame(); + for (const line of live.split("\n")) expect(line.length).toBeLessThanOrEqual(40); + t.type("\u001b"); + await pending; + }); +}); + +describe("Enter waiting on a saved key that comes back refused", () => { + it("says to paste a key or press tab, as an empty Enter does", async () => { + const t = terminal(); + let release: (v: KeyVerdict) => void = () => {}; + const pending = promptCloudKey({ + message: "Connect to cloud", + meta: "app.befailproof.ai", + tabHint: "skip", + emptyError: "Paste a key, or press tab to skip.", + saved: { masked: "****3f2a", check: () => new Promise((r) => (release = r)) }, + check: vi.fn(async (): Promise => ({ ok: true })), + collapsed: () => ["CONNECT TO CLOUD"], + stdin: t.stdin, + stdout: t.stdout, + }); + await tick(10); + t.type("\r"); + await tick(10); + release({ ok: false, line: "Your saved key no longer works: the server rejected that key (401)" }); + await tick(20); + expect(t.lastFrame()).toContain("▲ Your saved key no longer works"); + expect(t.lastFrame()).toContain("✕ Paste a key, or press tab to skip."); + // Not the "enter try again" hints a refused TYPED key gets. + expect(t.lastFrame()).not.toContain("enter try again"); + t.type("\u001b"); + expect(await pending).toBeNull(); + }); +}); diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index 1d16893bc..bba3f6506 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -3,27 +3,20 @@ import { describe, it, expect, vi } from "vitest"; import { type TTYIn, multiSelect, + selectOne, INDENT, CHIP_WIDTH, - brandAnsi, - bullets, chip, colorsEnabled, - danger, emptyState, - helpBlock, - helpColumn, - helpHeading, - helpOptsFor, - helpScreen, note, nextStep, optsFor, paint, printBlock, - renderBrandLogo, rows, rule, + screenKit, stack, table, title, @@ -139,13 +132,6 @@ describe("colour tiers", () => { ); }); - it("carries the tiers into brandAnsi, so `audit` and `config` stay one product", () => { - expect(withEnv(TRUECOLOR, () => brandAnsi("pink"))).toBe(`\x1B[${PINK_24}m`); - expect(withEnv(ANSI256, () => brandAnsi("pink"))).toBe(`\x1B[${PINK_256}m`); - expect(withEnv(BASIC, () => brandAnsi("pink"))).toBe(PINK_BASIC); - expect(withEnv(ANSI256, () => brandAnsi("guide"))).toBe(`\x1B[${MINT_256}m`); - }); - it("emits ZERO escapes under NO_COLOR, however deep the terminal is", () => { const out = { isTTY: true, columns: 80, write: vi.fn(() => true) } as unknown as TTYOut; const painted = withEnv({ ...TRUECOLOR, NO_COLOR: "1" }, () => { @@ -157,27 +143,62 @@ describe("colour tiers", () => { expect(painted).not.toContain("\x1B"); }); - it("emits ZERO escapes off a TTY, however deep the terminal is", () => { - const out = { isTTY: false, columns: 80, write: vi.fn(() => true) } as unknown as TTYOut; - const lines = withEnv(TRUECOLOR, () => renderBrandLogo(out)); - expect(lines.join("")).not.toContain("\x1B"); +}); + +describe("the 2026-10 roles", () => { + const INK3_24 = "38;2;118;127;139"; // #767f8b + const TRACK_24 = "38;2;62;67;76"; // #3e434c + const ERR_24 = "38;2;240;113;120"; // #f07178 + + it("paints ink3 and track in their designed greys at 24-bit", () => { + expect(withEnv(TRUECOLOR, () => paint(true).ink3("x"))).toBe(`\x1B[${INK3_24}mx\x1B[0m`); + expect(withEnv(TRUECOLOR, () => paint(true).track("x"))).toBe(`\x1B[${TRACK_24}mx\x1B[0m`); + }); + + it("keeps both greys as the dim ATTRIBUTE below 24-bit, never a fixed colour", () => { + // A cube grey fights every theme that is not ours, and the basic tier's + // bright black (90) IS the background in Solarized Dark. + for (const env of [ANSI256, BASIC]) { + expect(withEnv(env, () => paint(true).ink3("x"))).toBe("\x1B[2mx\x1B[0m"); + expect(withEnv(env, () => paint(true).track("x"))).toBe("\x1B[2mx\x1B[0m"); + } + }); + + it("leaves ink2 in the terminal's own foreground at every tier", () => { + // The designed #a7adb6 is about 2.2:1 on a light ground and nothing detects + // which ground we are on, so descriptions are never painted. + for (const env of [TRUECOLOR, ANSI256, BASIC]) { + expect(withEnv(env, () => paint(true).ink2("Block sudo"))).toBe("Block sudo"); + } + }); + + it("paints err as a red at every tier, and not the brand pink's neighbour at 256", () => { + expect(withEnv(TRUECOLOR, () => paint(true).err("x"))).toBe(`\x1B[${ERR_24}mx\x1B[0m`); + // 204 is nearest by distance but differs from pink's 168 only in red. + expect(withEnv(ANSI256, () => paint(true).err("x"))).toBe("\x1B[38;5;203mx\x1B[0m"); + expect(withEnv(ANSI256, () => paint(true).err("x"))).not.toContain("38;5;204"); + expect(withEnv(BASIC, () => paint(true).err("x"))).toBe("\x1B[31mx\x1B[0m"); + }); + + it("emits ZERO escapes for the new roles when colour is off", () => { + const c = withEnv(TRUECOLOR, () => paint(false)); + const painted = [c.ink2("a"), c.ink3("b"), c.track("c"), c.err("d")].join(""); + expect(painted).toBe("abcd"); }); }); describe("the logomark follows the tier", () => { - const tty = { isTTY: true, columns: 80, write: vi.fn(() => true) } as unknown as TTYOut; - it("paints from the cube when the terminal is 256-colour, not monochrome", () => { // It used to test truecolor-or-nothing, so a 256-colour terminal got the // mark in the foreground colour while the wordmark under it was coloured. - const art = withEnv(ANSI256, () => renderBrandLogo(tty)).join("\n"); + const art = withEnv(ANSI256, () => screenKit({ cols: 80, color: true }).logo()).join("\n"); expect(art).toContain(PINK_256); expect(art).toContain(MINT_256); expect(art).not.toContain("38;2;"); }); it("paints 24-bit from the same two accents as the prompts", () => { - const art = withEnv(TRUECOLOR, () => renderBrandLogo(tty)).join("\n"); + const art = withEnv(TRUECOLOR, () => screenKit({ cols: 80, color: true }).logo()).join("\n"); expect(art).toContain(PINK_24); expect(art).toContain(MINT_24); // The mark's own softer pink is gone; it is the brand pink now. @@ -185,7 +206,7 @@ describe("the logomark follows the tier", () => { }); it("draws monochrome on a 16-colour terminal rather than approximate the hues", () => { - const art = withEnv(BASIC, () => renderBrandLogo(tty)).join("\n"); + const art = withEnv(BASIC, () => screenKit({ cols: 80, color: true }).logo()).join("\n"); // The block glyphs still print — shape carries the mark, colour never has // to. No 38;/48; anywhere: basic pink is `[95m` and dim is `[2m`. expect(art).toContain("█"); @@ -423,18 +444,6 @@ describe("table", () => { }); }); -describe("bullets — the uninstall overrun", () => { - it("wraps long items and aligns continuation under the text", () => { - const long = - "remove failproofai hook entries from 10 agent CLIs: Claude Code, OpenAI Codex, GitHub Copilot, Cursor Agent, OpenCode, Pi, Factory Droid, Devin CLI, Antigravity CLI, Goose"; - const out = bullets([long], { cols: 80, color: false }); - expect(out.length).toBeGreaterThan(1); - expect(out[0].startsWith(`${INDENT}•`)).toBe(true); - for (const line of out.slice(1)) expect(line.startsWith(`${INDENT} `)).toBe(true); - for (const line of out) expect(visibleWidth(line)).toBeLessThanOrEqual(80); - }); -}); - describe("warning / danger", () => { it("hangs continuation lines under the text, not under the symbol", () => { const out = warning( @@ -450,9 +459,6 @@ describe("warning / danger", () => { for (const line of out.slice(1)) expect(line.startsWith(`${INDENT} `)).toBe(true); }); - it("danger uses its own symbol", () => { - expect(danger(["deletes ~/.failproofai"], PLAIN)[0]).toContain("!"); - }); }); describe("emptyState", () => { @@ -466,42 +472,6 @@ describe("emptyState", () => { }); }); -describe("helpBlock", () => { - it("puts every description in one column", () => { - const out = helpBlock( - { - usage: [ - ["failproofai policy add ", "Enable one policy"], - ["failproofai policy remove ", "Disable one policy"], - ], - options: [["--scope user|project|local", "Config scope (default: user)"]], - examples: ["failproofai policy add block-sudo"], - }, - PLAIN, - ); - const described = out.filter((l) => /Enable one policy|Disable one policy|Config scope/.test(l)); - const starts = described.map((l) => l.search(/(Enable|Disable|Config)/)); - expect(new Set(starts).size).toBe(1); - }); - - it("gives an over-long name its own line instead of pushing the column out", () => { - const out = helpBlock( - { - usage: [["failproofai policies --install --cli claude codex copilot cursor", "Install for many CLIs"]], - }, - PLAIN, - ); - expect(out.some((l) => l.trim() === "failproofai policies --install --cli claude codex copilot cursor")).toBe(true); - expect(out.some((l) => l.includes("Install for many CLIs"))).toBe(true); - }); - - it("omits sections that have no entries", () => { - const out = helpBlock({ usage: [["failproofai flush", "Deliver now"]] }, PLAIN); - expect(out.join("\n")).not.toContain("OPTIONS"); - expect(out.join("\n")).not.toContain("EXAMPLES"); - }); -}); - describe("every builder, at every width", () => { const build = (opts: RenderOpts): string[] => stack( @@ -509,7 +479,6 @@ describe("every builder, at every width", () => { rule("Convention Policies", opts), rows([["daemon", "running"], ["scheduled audit", "off"]], opts), table({ head: ["Name", "Description"], rows: [["block-sudo", "Block sudo commands"]] }, opts), - bullets(["remove hook entries from 10 agent CLIs"], opts), warning(["Hooks in multiple scopes (user, project)."], opts), note("Config: ~/.failproofai/policies-config.json", opts), nextStep("failproofai pack add owner/repo", "Install a pack with:", opts), @@ -706,146 +675,574 @@ describe("a repaint is one atomic frame", () => { const written = await drawTwice(); // The cursor-up-and-clear must never arrive on its own — that lone write is // precisely the blank frame. + // Case-insensitive: the redesign prints the question as an UPPERCASE heading. const clearOnly = written.filter( - (c) => /\u001b\[\d+A\u001b\[J/.test(c) && !c.includes("pick"), + (c) => /\u001b\[\d+A\u001b\[J/.test(c) && !/pick/i.test(c), ); expect(clearOnly).toEqual([]); }); }); -describe("helpScreen — the one shape all twelve --help screens take", () => { - const SPEC = { - command: "policies", - version: "9.9.9", - tagline: "manage the policies your agents run under", - sections: [ - { - label: "usage", - entries: [ - ["add ", "Turn one policy on"], - ["show /", "What a pack holds, before you take it"], - ] as Array<[string, string?]>, - }, - { - label: "options", - entries: [["--beta", "Include beta policies"]] as Array<[string, string?]>, - after: ["A pause always expires on its own."], - }, - { label: "examples", lines: ["failproofai policies add block-sudo"] }, - ], - footer: ["policy, pack and p are all spellings of policies."], - }; +describe("rule — the one accent every sectioned surface carries", () => { + it("paints the lead but occupies the same columns either way", () => { + const plain = rule("Convention Policies", PLAIN)[0]; + const painted = rule("Convention Policies", COLOR)[0]; + expect(painted).not.toBe(plain); + expect(visibleWidth(painted)).toBe(visibleWidth(plain)); + expect(visibleWidth(plain)).toBe(80 - INDENT.length); + }); +}); + +describe("screenKit — the 2026-10 building blocks", () => { + const strip = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + const plain = screenKit({ version: "1.0.11", cols: 104, color: false }); - it("opens with the command, the version and one line of what it is", () => { - const out = helpScreen(SPEC, PLAIN); - expect(out[0]).toContain("failproofai policies"); - expect(out[0]).toContain("v9.9.9"); - expect(out[1]).toContain("manage the policies your agents run under"); + it("opens every screen with the wordmark, version and context", () => { + expect(plain.header("Policies")).toBe("failproof ai v1.0.11 · Policies"); + expect(plain.header()).toBe("failproof ai v1.0.11"); }); - it("gives every section the same rule heading", () => { - const out = helpScreen(SPEC, PLAIN).filter((l) => l.includes("━")); - expect(out).toHaveLength(3); - for (const label of ["usage", "options", "examples"]) { - expect(out.some((l) => l.includes(label))).toBe(true); - } + it("paints the wordmark's `il` pink and bold, and the version grey", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ version: "1.0.11", color: true })); + const h = k.header("Status"); + expect(h).toContain(`\x1B[1;${PINK_24}mil\x1B[0m`); + expect(h).toContain("\x1B[38;2;118;127;139mv1.0.11\x1B[0m"); + expect(strip(h)).toBe("failproof ai v1.0.11 · Status"); }); - it("computes the description column PER SECTION, not per screen", () => { - // One column across the page is what a screen of like-shaped entries wants - // and exactly wrong on a screen that has both: `show /` is 19 - // columns and `--beta` is 6, and a shared column left every flag on the - // page hanging with nothing under it. - const out = helpScreen(SPEC, PLAIN); - const usageRow = out.find((l) => l.includes("Turn one policy on"))!; - const optionRow = out.find((l) => l.includes("Include beta policies"))!; - expect(usageRow.indexOf("Turn one")).toBeGreaterThan(optionRow.indexOf("Include beta")); + it("uppercases headings and keeps their meta grey and as written", () => { + expect(plain.head("Dashboard")).toBe("DASHBOARD"); + expect(plain.head("Which agents should failproofai trace?", "8 of 9 selected")).toBe( + "WHICH AGENTS SHOULD FAILPROOFAI TRACE? 8 of 9 selected", + ); }); - it("puts a section's `after` note under its table, in the same section", () => { - const out = helpScreen(SPEC, PLAIN); - const note = out.findIndex((l) => l.includes("always expires on its own")); - const examples = out.findIndex((l) => l.includes("examples")); - expect(note).toBeGreaterThan(-1); - // Under options, above the next heading — not orphaned after the screen. - expect(note).toBeLessThan(examples); + it("lines up kv values where the reference does", () => { + // Copied from the reference's launch screen, plain. + expect( + plain.kv([ + ["url", "http://127.0.0.1:8020 ● live"], + ["policies", "10 on from FailproofAI/policies@06b802b"], + ["agents", "9 traced: Claude Code, Codex, Copilot, Cursor and 5 more"], + ["cloud", "Connected as chetanraghuvanshi85"], + ]), + ).toEqual([ + " url http://127.0.0.1:8020 ● live", + " policies 10 on from FailproofAI/policies@06b802b", + " agents 9 traced: Claude Code, Codex, Copilot, Cursor and 5 more", + " cloud Connected as chetanraghuvanshi85", + ]); }); - it("emits no ANSI at all when colour is off, at every width", () => { - for (const cols of WIDTHS) { - expect(helpScreen(SPEC, { cols, color: false }).join("")).not.toContain("\x1B"); - } + it("widens the kv column for a long label instead of running it into its value", () => { + const [line] = plain.kv([["would block", "7: block-env-files 4"]]); + expect(line).toBe(" would block 7: block-env-files 4"); }); - it("fits the width it was given, coloured or not", () => { - for (const cols of WIDTHS) { - for (const color of [true, false]) { - for (const line of helpScreen(SPEC, { cols, color })) { - expect(visibleWidth(line)).toBeLessThanOrEqual(cols); - } - } - } + it("continues a kv row under the value column when the label is empty", () => { + expect(plain.kv([["paused", "1 session"], ["", "s-1 28m left"]])).toEqual([ + " paused 1 session", + " s-1 28m left", + ]); + // An empty label paints nothing: no escape pair with no text between. + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + const [, continued] = k.kv([["paused", "1 session"], ["", "s-1"]]); + expect(continued).toBe(" s-1"); }); - it("skips a section with nothing in it rather than printing a bare heading", () => { - const out = helpScreen( - { ...SPEC, sections: [...SPEC.sections, { label: "empty", lines: [] }] }, - PLAIN, + it("never cuts a kv value, however narrow the terminal", () => { + const url = "https://app.befailproof.ai/settings/machines/0b1c2d3e-0000-4000-8000-000000000001"; + const k = screenKit({ cols: 40, color: false, fit: true }); + expect(k.kv([["dashboard", url]])[0]).toContain(url); + }); + + it("renders a `command description` row at the reference's column", () => { + expect(plain.rows([["command", "What it does, in a short sentence"]], 14)).toEqual([ + " command What it does, in a short sentence", + ]); + }); + + it("always leaves two spaces after the widest name — the longest real policy name is 32", () => { + const name = "require-no-conflicts-before-stop"; + expect(name).toHaveLength(32); + const [line] = plain.rows([[name, "Require no merge conflicts before stopping"]], 32); + expect(line).toBe(` ${name} Require no merge conflicts before stopping`); + }); + + it("shortens a description to fit the terminal, but never the name", () => { + const k = screenKit({ cols: 40, color: false, fit: true }); + const [line] = k.rows([["failproofai policies show ", "See what a pack holds before installing it"]]); + expect(line.startsWith(" failproofai policies show ")).toBe(true); + const [short] = k.rows([["--wait", "Wait until everything is delivered to the cloud dashboard"]]); + expect(visibleWidth(short)).toBeLessThanOrEqual(40); + expect(short.endsWith("…")).toBe(true); + }); + + it("never shortens anything when fitting is off — piped output is never cut", () => { + const k = screenKit({ cols: 40, color: false }); + const [line] = k.rows([["--wait", "Wait until everything is delivered to the cloud dashboard"]]); + expect(line).toBe(" --wait Wait until everything is delivered to the cloud dashboard"); + }); + + it("states results, cautions and failures with one glyph and an inline fix", () => { + expect(plain.ok("Done, said in the past tense")).toBe("✓ Done, said in the past tense"); + expect(plain.ok("Published acme/guards", "in 2.1s")).toBe("✓ Published acme/guards in 2.1s"); + expect(plain.caution("Needs attention", "the fix")).toBe("▲ Needs attention · the fix"); + expect(plain.fail("Failed, said plainly", "the fix")).toBe("✕ Failed, said plainly · the fix"); + }); + + it("paints the state glyphs by role and every fix as a command", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + expect(k.ok("x")).toContain(`\x1B[${MINT_24}m✓`); + expect(k.caution("x", "failproofai config")).toContain(`\x1B[${PINK_24}mfailproofai config`); + expect(k.fail("x")).toContain("\x1B[38;2;240;113;120m✕"); + }); + + it("draws the row glyphs a listing needs: failed, selected and unselected", () => { + expect([plain.failed, plain.selected, plain.unselected]).toEqual(["✕", "■", "□"]); + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + // A state colour for a failure, the brand pink for picked, grey for not. + expect(k.failed).toBe("\x1B[38;2;240;113;120m✕\x1B[0m"); + expect(k.selected).toBe(`\x1B[${PINK_24}m■\x1B[0m`); + expect(k.unselected).toBe("\x1B[38;2;118;127;139m□\x1B[0m"); + }); + + it("paints a value's grey qualifier, and never an empty span", () => { + expect(plain.meta("observe")).toBe("observe"); + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + expect(k.meta("observe")).toBe("\x1B[38;2;118;127;139mobserve\x1B[0m"); + expect(k.meta("")).toBe(""); + }); + + it("joins key hints with a spaced dot", () => { + expect(plain.keys(["↑↓ move", "space toggle", "enter confirm"])).toBe( + "↑↓ move · space toggle · enter confirm", ); - expect(out.some((l) => l.includes("empty"))).toBe(false); + }); + + it("draws a progress bar: pink fill and a grey track in colour, a blank track without it", () => { + expect(plain.bar(10, 0.6)).toBe("━━━━━━ "); + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + const painted = k.bar(10, 0.6); + expect(painted).toContain(`\x1B[${PINK_24}m━━━━━━\x1B[0m`); + expect(painted).toContain("\x1B[38;2;62;67;76m━━━━\x1B[0m"); + // No empty colour span at either end. + expect(k.bar(10, 0)).not.toContain(`\x1B[${PINK_24}m\x1B[0m`); + expect(k.bar(10, 1)).not.toContain("38;2;62;67;76"); + }); + + it("shows the logomark only where it fits, and never the retired ▮▮ one-liner", () => { + const art = screenKit({ cols: 80, color: false }).logo(); + expect(art).toHaveLength(10); + for (const line of art) expect(line.startsWith(" ")).toBe(true); + expect(art.join("")).not.toContain("\x1B"); + expect(screenKit({ cols: 21, color: false }).logo()).toEqual([]); + expect(art.join("")).not.toContain("▮"); + }); + + it("builds a command's --help from usage, options and examples, and nothing else", () => { + const page = plain.helpPage({ + name: "flush", + usage: [["failproofai flush [options]", "Send queued events to cloud now"]], + options: [ + ["--wait", "Wait until everything is delivered"], + ["--timeout ", "How long to wait (default 60)"], + ], + optionsCol: 20, + }); + expect(page).toEqual([ + "failproof ai v1.0.11 · flush", + "", + "USAGE", + " failproofai flush [options] Send queued events to cloud now", + "", + "OPTIONS", + // The reference's option column, exactly. + " --wait Wait until everything is delivered", + " --timeout How long to wait (default 60)", + ]); + }); + + it("emits no escape at all when colour is off", () => { + const all = [ + plain.header("x"), + plain.head("x", "m"), + ...plain.kv([["a", "b"]]), + ...plain.rows([["a", "b"]]), + plain.ok("x", "d"), + plain.caution("x", "f"), + plain.fail("x", "f"), + plain.keys(["a"]), + plain.bar(5, 0.5), + plain.on, + plain.off, + plain.failed, + plain.selected, + plain.unselected, + plain.meta("observe"), + plain.cmd("failproofai config"), + ].join("\n"); + expect(all).not.toContain("\x1B"); + }); + + it("defaults the header's version to this build's", async () => { + const { version } = await import("../../package.json"); + expect(screenKit().header()).toBe(`failproof ai v${version}`); }); }); -describe("helpColumn", () => { - it("ignores entries with no description", () => { - // A bare usage line describes itself and has nothing in the second column. - // Letting it vote pushed every real description out to its own width. - const wide: Array<[string, string?]> = [ - ["failproofai flush [--wait] [--timeout ]"], - ["--wait", "Block until the spool drains"], - ]; - expect(helpColumn(wide)).toBe(helpColumn([["--wait", "Block until the spool drains"]])); +describe("pickers in the 2026-10 language", () => { + const plainText = (s: string) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ""); + + /** Drive a prompt through a real PassThrough, sending `keys` after the first frame. */ + const drive = async (start: (io: { stdin: TTYIn; stdout: TTYOut }) => Promise, keys: string[], columns = 100) => { + const written: string[] = []; + const stdout = { isTTY: true, columns, write: (chunk: string) => { written.push(chunk); return true; } } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (on: boolean) => void }).setRawMode = () => {}; + const pending = start({ stdin: stdin as unknown as TTYIn, stdout }); + for (const k of keys) stdin.write(k); + const result = await pending; + // Cursor show/hide go out as writes of their own, which strip to nothing. + const frames = written.map(plainText).filter((f) => f.trim() !== ""); + return { result, frames, last: frames[frames.length - 1] ?? "", all: frames.join("") }; + }; + + const POLICIES = ["block-sudo", "block-rm-rf", "block-env-files"].map((name, i) => ({ + label: name, + value: name, + hint: `Description ${i}`, + checked: i === 0, + })); + + it("draws the heading in UPPERCASE with a live count, a › cursor and ■ □ boxes", async () => { + const { frames } = await drive( + (io) => multiSelect({ message: "Choose policies", choices: POLICIES, ...io }), + ["\u001b"], + ); + const first = frames[0]; + expect(first).toContain("CHOOSE POLICIES 1 of 3 selected"); + expect(first).toContain(" › ■ block-sudo"); + expect(first).toContain(" □ block-rm-rf"); + expect(first).toContain("↑↓ move · space toggle · a all · enter confirm · esc cancel"); + // No spine glyphs from the old flow. + expect(first).not.toMatch(/[│◆◇❯◼◻]/); + }); + + it("toggles everything with a plain `a`, and keeps ctrl+a as an alias", async () => { + const plainA = await drive((io) => multiSelect({ message: "pick", choices: POLICIES, ...io }), ["a", "\r"]); + expect(plainA.result).toEqual(["block-sudo", "block-rm-rf", "block-env-files"]); + const ctrlA = await drive((io) => multiSelect({ message: "pick", choices: POLICIES, ...io }), ["\u0001", "\r"]); + expect(ctrlA.result).toEqual(["block-sudo", "block-rm-rf", "block-env-files"]); + }); + + it("says the caller's minimum with ✕ and stays put", async () => { + const none = POLICIES.map((p) => ({ ...p, checked: false })); + const { all } = await drive( + (io) => multiSelect({ message: "Which agents should failproofai trace?", choices: none, minSelected: 1, minMessage: "pick at least one agent", ...io }), + ["\r", "\u001b"], + ); + expect(all).toContain("✕ pick at least one agent"); + }); + + it("recomputes a row's tag on every toggle", async () => { + const { frames } = await drive( + (io) => + multiSelect({ + message: "pick", + choices: POLICIES, + tag: (value, on) => (value === "block-rm-rf" && on ? "will be added" : undefined), + ...io, + }), + ["\u001b[B", " ", "\u001b"], + ); + expect(frames[0]).not.toContain("will be added"); + expect(frames.some((f) => f.includes("block-rm-rf") && f.includes("will be added"))).toBe(true); + }); + + it("collapses to `✓ ` under the heading, or to the caller's own lines", async () => { + const plain = await drive((io) => multiSelect({ message: "Choose policies", choices: POLICIES, ...io }), ["\r"]); + expect(plain.last).toContain("CHOOSE POLICIES"); + expect(plain.last).toContain("✓ block-sudo"); + const custom = await drive( + (io) => multiSelect({ message: "Choose policies", choices: POLICIES, collapsed: (v) => ["AGENTS", `✓ Tracing ${v.length} agents`], ...io }), + ["\r"], + ); + expect(custom.last).toContain("✓ Tracing 1 agents"); + }); + + it("shows twelve rows before it scrolls, and says how many more are below", async () => { + const many = Array.from({ length: 15 }, (_, i) => ({ label: `policy-${i}`, value: `p${i}` })); + const { frames } = await drive((io) => multiSelect({ message: "pick", choices: many, ...io }), ["\u001b"]); + expect(frames[0]).toContain("policy-11"); + expect(frames[0]).not.toContain("policy-12"); + expect(frames[0]).toContain("↓ 3 more"); + }); + + it("selectOne: grey meta after the heading, a bold cursor row, and the redesign's key hints", async () => { + const { frames, result } = await drive( + (io) => + selectOne({ + message: "Choose agents", + meta: "9 found on this machine", + choices: [ + { label: "All 9 agents", value: "all" }, + { label: "Claude Code", value: "claude" }, + ], + ...io, + }), + ["\u001b[B", "\r"], + ); + expect(frames[0]).toContain("CHOOSE AGENTS 9 found on this machine"); + expect(frames[0]).toContain(" › All 9 agents"); + expect(frames[0]).toContain("↑↓ move · enter select · esc cancel"); + expect(result).toBe("claude"); }); - it("caps the column so one long flag cannot push every description off", () => { - expect(helpColumn([["-".repeat(60), "x"]])).toBeLessThanOrEqual(34); + it("selectOne picks on space only when the caller asks for it", async () => { + const choices = [{ label: "A", value: "a" }, { label: "B", value: "b" }]; + const asked = await drive((io) => selectOne({ message: "pick", choices, spaceSelects: true, ...io }), [" "]); + expect(asked.result).toBe("a"); + const notAsked = await drive((io) => selectOne({ message: "pick", choices, ...io }), [" ", "\u001b"]); + expect(notAsked.result).toBeNull(); }); }); -describe("helpHeading", () => { - it("drops the version onto its own line rather than wrapping it into the name", () => { - const out = helpHeading( - { command: "policies add|remove|show", version: "1.0.0-beta.6", tagline: "t" }, - { cols: 30, color: false }, - ); - expect(out[0]).toContain("policies add|remove|show"); - expect(out[1].trim()).toBe("v1.0.0-beta.6"); +describe("screenKit().live — a block that redraws in place", () => { + const fakeTty = (columns = 40) => { + const writes: string[] = []; + const out = { + isTTY: true, + columns, + write: vi.fn((chunk: string) => { + writes.push(chunk); + return true; + }), + } as unknown as TTYOut; + return { out, writes }; + }; + /** The listeners `live` added for an event, by diffing against what was there. */ + const added = (event: "exit" | "SIGINT" | "SIGTERM", before: Function[]) => + process.listeners(event).filter((l) => !before.includes(l)) as Array<(...args: unknown[]) => void>; + + it("draws every frame as ONE write, held in synchronized output, with the cursor hidden from the first", () => { + const { out, writes } = fakeTty(); + const region = screenKit().live(out); + region.draw(() => ["one", "two"]); + expect(writes).toEqual(["\x1B[?2026h\x1B[?25lone\ntwo\n\x1B[?2026l"]); + region.done(["done"]); + }); + + it("draws at most every 100 ms, and the newest frame is the one that lands", () => { + vi.useFakeTimers(); + try { + const { out, writes } = fakeTty(); + const region = screenKit().live(out); + region.draw(() => ["frame 1"]); + region.draw(() => ["frame 2"]); + region.draw(() => ["frame 3"]); + expect(writes).toHaveLength(1); + vi.advanceTimersByTime(99); + expect(writes).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(writes).toHaveLength(2); + // Cleared the one row the first frame drew, then the newest frame. + expect(writes[1]).toBe("\x1B[?2026h\x1B[1A\x1B[Jframe 3\n\x1B[?2026l"); + region.done(["done"]); + } finally { + vi.useRealTimers(); + } + }); + + it("builds only the frames it draws", () => { + vi.useFakeTimers(); + try { + const { out } = fakeTty(); + const region = screenKit().live(out); + const build = vi.fn(() => ["x"]); + region.draw(build); + for (let i = 0; i < 50; i += 1) region.draw(build); + vi.advanceTimersByTime(100); + expect(build).toHaveBeenCalledTimes(2); + region.done(["done"]); + } finally { + vi.useRealTimers(); + } + }); + + it("cuts a live frame to the terminal so its rows stay countable, and never the last one", () => { + const { out, writes } = fakeTty(10); + const region = screenKit().live(out); + region.draw(() => ["0123456789abcdef"]); + expect(writes[0]).toContain("0123456789\n"); + expect(writes[0]).not.toContain("abcdef"); + region.done(["0123456789abcdef"]); + // The last frame stays in scrollback, so it is written whole. + expect(writes[1]).toContain("0123456789abcdef\n"); + }); + + it("ends in ONE write that clears the live frame, draws the last one and shows the cursor", () => { + const { out, writes } = fakeTty(); + const region = screenKit().live(out); + region.draw(() => ["a", "b", "c"]); + region.done(["final"]); + expect(writes[1]).toBe("\x1B[?2026h\x1B[3A\x1B[Jfinal\n\x1B[?25h\x1B[?2026l"); + // Nothing draws after the end. + region.draw(() => ["late"]); + region.done(["later"]); + expect(writes).toHaveLength(2); + }); + + it("gives the cursor back on exit and on SIGINT/SIGTERM, and lets the signal through", () => { + const before = { + exit: process.listeners("exit"), + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + }; + const kill = vi.spyOn(process, "kill").mockImplementation(() => true); + try { + const { out, writes } = fakeTty(); + const region = screenKit().live(out); + // Nothing is registered until something is drawn. + expect(added("SIGINT", before.SIGINT)).toHaveLength(0); + region.draw(() => ["scanning"]); + + const [onExit] = added("exit", before.exit); + expect(onExit).toBeTypeOf("function"); + onExit(); + expect(writes[writes.length - 1]).toBe("\x1B[?25h"); + + const [onSigint] = added("SIGINT", before.SIGINT); + expect(added("SIGTERM", before.SIGTERM)).toHaveLength(1); + onSigint("SIGINT"); + expect(writes[writes.length - 1]).toBe("\x1B[?25h"); + // Re-raised once the cursor is back, with every hook of ours removed, so + // the default disposition stops the command as it always did. + expect(kill).toHaveBeenCalledWith(process.pid, "SIGINT"); + expect(added("SIGINT", before.SIGINT)).toHaveLength(0); + expect(added("SIGTERM", before.SIGTERM)).toHaveLength(0); + expect(added("exit", before.exit)).toHaveLength(0); + // And the block is over: a frame after the signal draws nothing. + const count = writes.length; + region.draw(() => ["after"]); + expect(writes).toHaveLength(count); + } finally { + kill.mockRestore(); + } }); - it("paints the wordmark without changing what it occupies", () => { - const plain = helpHeading({ version: "1.0.0", tagline: "t" }, PLAIN); - const painted = helpHeading({ version: "1.0.0", tagline: "t" }, COLOR); - expect(painted[0]).toContain("\x1B"); - expect(visibleWidth(painted[0])).toBe(visibleWidth(plain[0])); + it("removes every hook when it ends normally", () => { + const before = { + exit: process.listeners("exit"), + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + }; + const { out } = fakeTty(); + const region = screenKit().live(out); + region.draw(() => ["scanning"]); + region.done(["done"]); + expect(added("exit", before.exit)).toHaveLength(0); + expect(added("SIGINT", before.SIGINT)).toHaveLength(0); + expect(added("SIGTERM", before.SIGTERM)).toHaveLength(0); }); }); -describe("helpOptsFor", () => { - it("never renders help wider than 80, however wide the terminal is", () => { - expect(helpOptsFor({ isTTY: true, columns: 220, write: () => true } as unknown as TTYOut).cols).toBe(80); +describe("screenKit().notice — every error and warning in one shape", () => { + const plainKit = screenKit({ color: false }); + + it("puts the glyph on the first line and indents every fix line two", () => { + expect(plainKit.notice("fail", "There is no command called 'status'.\nDid you mean `failproofai config --status`?")).toEqual([ + "✕ There is no command called 'status'.", + " Did you mean failproofai config --status?", + ]); + expect(plainKit.notice("caution", "Needs attention.")).toEqual(["▲ Needs attention."]); + expect(plainKit.notice("ok", "Done.\nNext: `failproofai policies`")).toEqual(["✓ Done.", " Next: failproofai policies"]); + }); + + it("drops the backticks from a command in plain text — nothing is left to show they were there", () => { + const [, fix] = plainKit.notice("fail", "Unknown flag: --typo.\nRun `failproofai policies --help` for usage."); + expect(fix).toBe(" Run failproofai policies --help for usage."); + expect(fix).not.toMatch(/`|\x1B/); + }); + + it("keeps a fix line's own indentation under the two it adds", () => { + expect(plainKit.notice("fail", "Two packs declare it.\nName the one you mean:\n `acme/a:x`\n `acme/b:x`")).toEqual([ + "✕ Two packs declare it.", + " Name the one you mean:", + " acme/a:x", + " acme/b:x", + ]); }); - it("still narrows to a terminal smaller than that", () => { - expect(helpOptsFor({ isTTY: true, columns: 60, write: () => true } as unknown as TTYOut).cols).toBe(60); + it("leaves unbalanced backticks as written rather than guessing", () => { + expect(plainKit.notice("fail", "A stray ` here.")).toEqual(["✕ A stray ` here."]); + }); + + it("paints the glyph by role and every command pink with colour on", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + const [failure, fix] = k.notice("fail", "Gone.\nRun `failproofai config` again."); + expect(failure).toBe("\x1B[38;2;240;113;120m✕\x1B[0m Gone."); + expect(fix).toBe(` Run \x1B[${PINK_24}mfailproofai config\x1B[0m again.`); + expect(k.notice("caution", "x")[0]).toBe("\x1B[38;2;227;179;65m▲\x1B[0m x"); + expect(k.notice("ok", "x")[0]).toBe(`\x1B[${MINT_24}m✓\x1B[0m x`); }); }); -describe("rule — the one accent every sectioned surface carries", () => { - it("paints the lead but occupies the same columns either way", () => { - const plain = rule("Convention Policies", PLAIN)[0]; - const painted = rule("Convention Policies", COLOR)[0]; - expect(painted).toContain(brandAnsi("pink")); - expect(visibleWidth(painted)).toBe(visibleWidth(plain)); - expect(visibleWidth(plain)).toBe(80 - INDENT.length); +describe("screenKit().confirm — `Continue? y/N`", () => { + /** A terminal on both ends, with the keys typed after the question is drawn. */ + const ask = async (keys: string[], color = false) => { + const written: string[] = []; + const stdout = { isTTY: true, columns: 80, write: (chunk: string) => (written.push(chunk), true) } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + let raw = false; + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (on: boolean) => void }).setRawMode = (on: boolean) => { + raw = on; + }; + const kit = color ? withEnv(TRUECOLOR, () => screenKit({ color: true })) : screenKit({ color: false }); + const pending = kit.confirm({ stdin: stdin as unknown as TTYIn, stdout }); + for (const k of keys) stdin.write(k); + const answer = await pending; + return { answer, out: written.join(""), rawAfter: raw }; + }; + + it("draws the design's question, the y/N grey with colour on and plain without", async () => { + expect((await ask(["\r"])).out.startsWith("Continue? y/N ")).toBe(true); + const { out } = await ask(["\r"], true); + expect(out.startsWith("Continue? \x1B[38;2;118;127;139my/N\x1B[0m ")).toBe(true); + }); + + it.each([["y\r"], ["Y\r"], ["yes\r"], ["YES\r"]])("takes %j as yes", async (keys) => { + expect((await ask([keys])).answer).toBe(true); + }); + + it.each([ + ["Enter alone", ["\r"]], + ["n", ["n\r"]], + ["anything else", ["sure\r"]], + ["Esc", ["\u001b"]], + ["Ctrl+C", ["\u0003"]], + ["Ctrl+D", ["\u0004"]], + ])("takes %s as no", async (_label, keys) => { + expect((await ask(keys)).answer).toBe(false); + }); + + it("says no when the input closes before an answer", async () => { + const stdout = { isTTY: true, columns: 80, write: () => true } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + const pending = screenKit().confirm({ stdin: stdin as unknown as TTYIn, stdout }); + stdin.end(); + expect(await pending).toBe(false); + }); + + it("echoes what is typed, honours backspace, and ends the line", async () => { + const { answer, out } = await ask(["n", "\u007f", "y", "\r"]); + expect(answer).toBe(true); + expect(out).toBe("Continue? y/N n\b \by\n"); + }); + + it("gives the terminal back out of raw mode", async () => { + expect((await ask(["y\r"])).rawAfter).toBe(false); }); }); diff --git a/__tests__/hooks/tui-logo.test.ts b/__tests__/hooks/tui-logo.test.ts new file mode 100644 index 000000000..316ba939b --- /dev/null +++ b/__tests__/hooks/tui-logo.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +/** + * The logomark's shape. + * + * Ported from main's tui.test.ts, where these ran against renderBrandLogo. That + * helper was retired, but LOGO_GRID was not: screenKit().logo() draws it on the + * `failproofai` and `config` screens, and rule 1 above the grid in tui.ts (the + * two uprights are the SAME width) still holds. tui-kit.test.ts checks only the + * line count, the indent and the absence of escapes, which a grid edit that + * narrows one upright would pass. + */ +import { describe, it, expect } from "vitest"; +import { screenKit } from "../../src/hooks/tui"; + +// Colours are off, so each cell is a bare block glyph and a run of them +// measures the mark's width in columns. +const logoRows = (cols = 80): string[][] => + screenKit({ cols, color: false }) + .logo() + .map((l) => l.match(/[█▀▄]+/g) ?? []); + +describe("the logomark", () => { + // Regression: the right-hand bar once shipped a column narrower than the + // left, which read as a drawing mistake at every terminal size. In the source + // artwork both bars are the same width, so the narrowest left-hand run (the + // upright, excluding the wider cross) must equal the right-hand run. + it("draws both uprights the same width", () => { + const twoRun = logoRows().filter((runs) => runs.length === 2); + expect(twoRun.length).toBeGreaterThan(0); + + const rightWidths = new Set(twoRun.map((r) => r[1].length)); + expect(rightWidths.size).toBe(1); // the tall bar is a constant width + + const leftUpright = Math.min(...twoRun.map((r) => r[0].length)); + expect(leftUpright).toBe([...rightWidths][0]); + }); + + it("draws the cross wider than the upright it sits on", () => { + const twoRun = logoRows().filter((runs) => runs.length === 2); + const lefts = twoRun.map((r) => r[0].length); + expect(Math.max(...lefts)).toBeGreaterThan(Math.min(...lefts)); + }); +}); diff --git a/__tests__/hooks/tui.test.ts b/__tests__/hooks/tui.test.ts index 47479d730..3078a8818 100644 --- a/__tests__/hooks/tui.test.ts +++ b/__tests__/hooks/tui.test.ts @@ -4,7 +4,7 @@ import { multiSelect, ellipsize, summarize, - renderBrandLogo, + promptText, type TTYIn, type TTYOut, @@ -96,51 +96,6 @@ describe("tui text helpers", () => { }); }); -describe("brand logomark", () => { - // The logo lines are the ones before the blank separator that precedes the - // wordmark. Colours are off here (not a TTY), so each cell is a bare block - // glyph and a run of them measures the mark's width in columns. - const logoRows = (cols = 80): string[][] => { - const lines = renderBrandLogo({ - isTTY: false, - write: vi.fn(() => true), - columns: cols, - } as unknown as TTYOut); - const art = lines.slice(0, lines.indexOf("")); - return art.map((l) => l.match(/[█▀▄]+/g) ?? []); - }; - - // Regression: the right-hand bar once shipped a column narrower than the - // left, which read as a drawing mistake at every terminal size. In the source - // artwork both bars are the same width, so the narrowest left-hand run (the - // upright, excluding the wider cross) must equal the right-hand run. - it("draws both uprights the same width", () => { - const twoRun = logoRows().filter((runs) => runs.length === 2); - expect(twoRun.length).toBeGreaterThan(0); - - const rightWidths = new Set(twoRun.map((r) => r[1].length)); - expect(rightWidths.size).toBe(1); // the tall bar is a constant width - - const leftUpright = Math.min(...twoRun.map((r) => r[0].length)); - expect(leftUpright).toBe([...rightWidths][0]); - }); - - it("draws the cross wider than the upright it sits on", () => { - const twoRun = logoRows().filter((runs) => runs.length === 2); - const lefts = twoRun.map((r) => r[0].length); - expect(Math.max(...lefts)).toBeGreaterThan(Math.min(...lefts)); - }); - - it("collapses to a single line on a narrow terminal", () => { - const lines = renderBrandLogo({ - isTTY: false, - write: vi.fn(() => true), - columns: 21, - } as unknown as TTYOut); - 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 @@ -360,3 +315,91 @@ describe("promptText validation errors stay within the terminal", () => { expect(rows.length).toBeGreaterThan(1); }); }); + +// ── the 2026-10 field ──────────────────────────────────────────────────────── +// +// `field: true` draws `