Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.0.0-beta.13 — 2026-08-07

### Fixes
- Make `failproofai config` refuse setup on an unsupported platform (Windows, today) instead of completing it unenforced. The wizard used to skip the daemon requirement and finish anyway, leaving the machine reading as configured while enforcing in-process with no fail-closed guarantee — now it prints why and exits 1 before drawing a single prompt, writing nothing. (#PR)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

## 1.0.0-beta.12 — 2026-08-07

### Fixes
Expand Down
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -906,22 +906,22 @@ denies until `failproofai config` runs. `publish.yml` ships both from one commit
itself.

**In-process evaluation still exists**, reachable only when `daemonConfigured` is false —
which is exactly three situations, none of them a configured user machine:
which is exactly two situations, neither of them a configured user machine:

| Case | Why |
|------|-----|
| This repo's dogfood configs | Standing decision above: a flaky dev daemon must not block contributors' tool calls in the same loop where the daemon is being developed. |
| Unsupported platforms | `isDaemonSupportedPlatform()` is linux + darwin only. |
| Not yet set up | No hooks are installed either, so nothing evaluates anything. |

**Known gap — Windows.** The wizard *skips* the daemon requirement on an unsupported
platform rather than refusing setup, so a Windows user completes setup, reads as
configured, and enforces **in-process**: slower (~850ms vs ~57ms), and with no fail-closed
guarantee, because `daemonConfigured` is never set and there is nothing to fail closed
against. The policies themselves are identical and do enforce. This is a deliberate
trade — refusing setup would drop the platform entirely — and it is the one place
"all enforcement routes through the daemon" is not literally true. Revisit it if
failproofaid ever gains a Windows service target.
**Unsupported platforms refuse setup, not degrade into it.** `isDaemonSupportedPlatform()`
is linux + darwin only, and `failproofai config` checks it before drawing a single prompt:
on anything else (Windows, today) it prints why and exits 1, writing nothing — no hooks
installed, no `daemonConfigured` flag, no half-configured machine. This used to be a known
gap — the wizard *skipped* the daemon requirement instead, so a Windows user completed
setup, read as configured, and enforced in-process with no fail-closed guarantee at all.
Refusing is the more honest failure: it says plainly that the platform isn't supported yet,
rather than silently shipping a weaker guarantee than every other configured machine has.
Revisit when failproofaid gains a Windows service target.

### How the daemon is supervised

Expand Down
67 changes: 46 additions & 21 deletions __tests__/hooks/configure-wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,16 @@ beforeEach(() => {
vi.mocked(installHooks).mockClear();
vi.mocked(runPostSetupAudit).mockClear();
vi.mocked(outro).mockClear();
vi.mocked(isDaemonSupportedPlatform).mockReset().mockReturnValue(false);
vi.mocked(installDaemonService)
.mockReset()
.mockResolvedValue({ installed: false, reason: "mocked" });
// 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(isDaemonSupportedPlatform).mockReset().mockReturnValue(true);
vi.mocked(daemonServiceStatus).mockReset().mockReturnValue("running");
vi.mocked(daemonServiceNeedsUpgrade).mockReset().mockReturnValue(false);
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);
Expand Down Expand Up @@ -496,6 +502,20 @@ describe("first-run redirect", () => {
expect(handled).toBe(false);
expect(selectOne).not.toHaveBeenCalled();
});

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();

// 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();
});
});

describe("assistant selection summary", () => {
Expand Down Expand Up @@ -681,20 +701,37 @@ describe("configure-wizard daemon integration", () => {
expect(hasSeenLauncher()).toBe(false);
});

it("does not require a daemon, or sudo, on an unsupported platform", async () => {
// Requiring an impossible step would lock these users out of setup
// entirely rather than protecting anything.
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(true);
expect(result.applied).toBe(false);
expect(result.abort).toBe("unsupported_platform");
expect(selectOne).not.toHaveBeenCalled();
expect(multiSelect).not.toHaveBeenCalled();
expect(primeElevation).not.toHaveBeenCalled();
expect(installDaemonService).not.toHaveBeenCalled();
expect(installHooks).not.toHaveBeenCalled();
expect(readGlobalConfig().daemonConfigured).toBeUndefined();
});

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 });

const written = vi.mocked(stdout.write).mock.calls.map((c) => String(c[0])).join("");
expect(written).toContain("Linux");
expect(written).toContain("macOS");
});

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.
Expand Down Expand Up @@ -927,19 +964,12 @@ describe("configure-wizard daemon integration", () => {
expect(props.reason).not.toContain("/home/");
});

it("mentions the daemon in the outro only when one is actually there", async () => {
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 on");

// Unsupported platform: no daemon, so no claim of one.
vi.mocked(outro).mockClear();
vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false);
drive(HAPPY);
await runConfigureWizard(ttyIO());
expect(vi.mocked(outro).mock.calls[0]![0]).not.toContain("daemon on");
});

it("shows the daemon row in the review only when one will be installed", async () => {
Expand Down Expand Up @@ -997,10 +1027,6 @@ describe("configure-wizard daemon integration", () => {
});
});
describe("scope targets", () => {
beforeEach(() => {
vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false);
});

it("installs once per scope when Both is chosen", async () => {
drive({ ...HAPPY, target: "both" });

Expand Down Expand Up @@ -1059,7 +1085,6 @@ describe("scope targets", () => {

describe("connect step", () => {
beforeEach(() => {
vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false);
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
Expand Down
23 changes: 23 additions & 0 deletions __tests__/hooks/onboarding-attempt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,28 @@ describe("cancelled — a deliberate stop", () => {
});
});

describe("unsupported_platform — a permanent property of the machine", () => {
it("stays blocked on the same CLI version", () => {
// Nagging every command would just repeat the hard-fail this reason
// records — the machine's platform has not changed.
expect(
blockerCleared(
attempt({ reason: "unsupported_platform", cliVersion: "1.0.0" }),
probe({ cliVersion: "1.0.0" }),
),
).toBe(false);
});

it("re-offers after an upgrade, in case the new version supports it", () => {
expect(
blockerCleared(
attempt({ reason: "unsupported_platform", cliVersion: "1.0.0" }),
probe({ cliVersion: "1.1.0" }),
),
).toBe(true);
});
});

describe("reasons that are properties of the invocation, not the machine", () => {
it("re-offers for not_a_tty and running_as_sudo", () => {
for (const reason of ["not_a_tty", "running_as_sudo"] as const) {
Expand Down Expand Up @@ -226,6 +248,7 @@ describe("what the user is told", () => {
"needs_root",
"daemon_failed",
"cancelled",
"unsupported_platform",
"not_a_tty",
"running_as_sudo",
] as const) {
Expand Down
34 changes: 29 additions & 5 deletions src/hooks/configure-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type WizardAbort =
| "cancelled"
| "needs_root"
| "daemon_failed"
| "unsupported_platform"
| "not_a_tty"
| "running_as_sudo";

Expand Down Expand Up @@ -712,6 +713,25 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul

// Fire-and-forget: never block the wizard's first paint on telemetry.
void emit("configure_started", {});

// failproofaid — the only evaluator on a configured machine — only runs on
// Linux and macOS. Checked before intro() draws anything and before a
// single prompt is asked: completing setup anyway used to leave e.g. a
// Windows machine reading as configured while enforcing in-process with no
// fail-closed guarantee, which is worse than not being set up at all.
if (!isDaemonSupportedPlatform()) {
stdout.write(
`failproofai requires failproofaid, its background policy daemon, which runs on\n` +
`Linux and macOS only — not ${process.platform}. Setup cannot continue here: an\n` +
"installation with no daemon behind it would read as configured while enforcing\n" +
"nothing, which is worse than not being set up at all.\n\n" +
"Nothing was changed. This platform will be supported once failproofaid gains a\n" +
`${process.platform} service target.\n\n`,
);
void emit("configure_aborted", { reason: "unsupported_platform" });
return { applied: false, abort: "unsupported_platform" };
}

intro("let's set up your safety net", stdout);

const cancel = (): WizardResult => {
Expand All @@ -733,9 +753,9 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
// Machine-level, so it is deliberately NOT gated on the scope chosen in the
// next step: one daemon serves every project on this machine.
//
// On a platform with no service manager there is nothing to install, so the
// requirement does not apply — requiring an impossible step would lock those
// users out of setup entirely rather than protecting anything.
// Always true here — the guard near the top of this function already
// refused setup on anything else. Kept as a real read (not a literal
// `true`) so this block still fails safe if that guard is ever moved.
const daemonSupported = isDaemonSupportedPlatform();
// An already-healthy daemon needs no install and no password. Re-running
// setup on a configured machine must not demand sudo for work that is
Expand Down Expand Up @@ -1415,12 +1435,16 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
// a hard cut and no ellipsis, so an over-long line doesn't just lose its tail
// — it reads as broken output. Naming all ten CLIs took it to 182 characters;
// the count alone carries the same information, and the user picked them two
// screens ago.
// screens ago. Every real completed setup is on a supported platform now (an
// unsupported one aborts before this point), so `daemonNote` below is no
// longer an occasional addition — the widest real case (every policy, every
// CLI, custom off) must fit with it included, which is why this note stays
// terse rather than spelling out "DISABLED".
const customNote =
customEnabled === true
? " + your custom policies"
: customEnabled === false
? " · custom policies DISABLED"
? " · custom off"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: "";
const daemonNote = daemonInstalled ? " · daemon on" : "";
const cloudNote = connected ? " · reporting on" : "";
Expand Down
18 changes: 13 additions & 5 deletions src/hooks/onboarding-attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
* elevate. So each reason carries a cheap local check for whether its blocker
* is gone, and the wizard offers itself again the moment one is:
*
* needs_root -> elevation is now possible without a prompt
* daemon_failed -> the service manager now reports something different
* cancelled -> the CLI version changed; an upgrade is a fair moment to
* ask again, and nothing else about a deliberate cancel
* should re-nag
* needs_root -> elevation is now possible without a prompt
* daemon_failed -> the service manager now reports something different
* cancelled -> the CLI version changed; an upgrade is a fair moment to
* ask again, and nothing else about a deliberate cancel
* should re-nag
* unsupported_platform -> the CLI version changed (a future release might
* support this platform)
*
* Every probe is local and takes milliseconds. None of them runs on the hook
* path — `--hook` never reaches the first-run gate at all.
Expand Down Expand Up @@ -150,6 +152,11 @@ export function blockerCleared(attempt: OnboardingAttempt, probe: RetryProbe): b
// this module removes — but an upgrade is a new thing to say, so it is
// allowed to ask once more.
return probe.cliVersion !== attempt.cliVersion;
case "unsupported_platform":
// A permanent property of the machine, not the invocation — nagging every
// command would just repeat the hard-fail. Only a new release is a reason
// to ask again (it might add support for this platform).
return probe.cliVersion !== attempt.cliVersion;
case "not_a_tty":
case "running_as_sudo":
// Both are properties of the invocation, not of the machine, so the next
Expand All @@ -166,6 +173,7 @@ export function attemptHintLines(attempt: OnboardingAttempt): string[] {
needs_root: "it needs root to install the failproofaid service",
daemon_failed: "the failproofaid service could not be started",
cancelled: "it was cancelled",
unsupported_platform: "failproofaid does not run on this platform",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
not_a_tty: "there was no terminal to ask in",
running_as_sudo: "it was run under sudo",
};
Expand Down