Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
- Redirect the 13 AgentEye pages the upstream syncs deleted (`/agenteye/deployment`, `/agenteye/kubernetes-deployment`, `/agenteye/troubleshooting`, …) to a live page instead of hard-404ing. (#556)

### Fixes
- Stop shipping npm lifecycle scripts, removing the `npm warn` every install printed. npm 12 (`allowScripts`, released 2026-07-08 and now the `latest` dist-tag) blocks dependency install scripts by default, so `npm install failproofai` warned `1 package had install scripts blocked … failproofai@… (postinstall: node scripts/postinstall.mjs)` and silently skipped the script; npm 11.16+ prints the advisory `allow-scripts` variant and still runs it. `allowScripts` is purely consumer-side — a package cannot opt itself in (verified: a package declaring `allowScripts`/`trustedDependencies` for *itself* is ignored) — so the only fix is to ship no install scripts. The `postinstall` script's install telemetry (`first_install` / `version_changed` / `package_installed`, with identical event names and properties) now fires from the CLI's first non-hook invocation via `lib/install-check.ts`, reporting at most once per version and no-op'ing on the steady-state path; it is deliberately kept out of `bin/failproofai.mjs`'s `--hook` fast path, which runs on every tool call. This also *recovers* telemetry already being dropped for bun, pnpm, and Yarn ≥4.14 users, which have blocked install scripts for some time. The script's `server.js` check and shadowed-PATH diagnosis were redundant — `scripts/launch.ts` already performs both at launch, with a better error message. `package_installed` now measures install→activation rather than raw installs, and same-version reinstalls (`direction: "reinstall"`) are no longer reported, as the CLI has no signal to detect them. The install-time welcome message is dropped; the `config` wizard's first-run redirect already handles onboarding. (#560)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Remove `scripts/preuninstall.mjs`, which never ran. npm honours no uninstall lifecycle scripts (verified with a probe package: `postinstall` fired, `preuninstall` did not), so the hook cleanup it appeared to perform has never happened — uninstalling failproofai leaves `__failproofai_hook__` entries behind in settings files. Removing the dead code makes the gap visible; cleanup needs an explicit `failproofai policies --uninstall` before removing the package. (#560)
- Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook <Event>` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use <other>` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564)
- Report the onboarding auto-audit to PostHog. `runPostSetupAudit()` — the audit that runs automatically at the end of first-run setup, and therefore the **first audit every new user ever runs** — emitted no telemetry at all, while an explicit `failproofai audit` reported `cli_audit_started` / `cli_audit_completed` / `cli_audit_failed`. First-run audits were invisible, so the audit funnel silently undercounted exactly the activation moment it exists to measure (confirmed live: a fresh install's auto-audit wrote its dashboard cache and reached PostHog with nothing). It now emits the same three events, tagged `source: "onboarding"` against the existing `source: "cli"`, so the two paths stay distinguishable. `cli_audit_completed` fires before the empty-history return, matching `runAuditCli`, so a fresh user with no agent history is still counted; `cli_audit_failed` is awaited because the function returns straight into the dashboard boot, which would otherwise race a fire-and-forget send. The completed-event properties are now built by one shared helper so the two entry points cannot drift. Onboarding remains best-effort: it never throws and never exits. (#562)
- Report only our own dashboard failures, not browser extensions'. `GlobalErrorListeners` registers page-global `error` / `unhandledrejection` handlers, and browser extensions inject content scripts into the same page and share the same `window` — so their failures reached our listeners and went to PostHog stamped `$lib: failproofai-web`, as though the dashboard had thrown them. Observed live: MetaMask's "Failed to connect to MetaMask" (`error_name: "i"`, its minified class) arriving as a failproofai `unhandled_rejection` on `/policies`. Extensions are user-installed and open-ended, so the noise was unbounded, depended on which extensions a user happened to run rather than on our code, and would eventually have drowned the real signal these listeners exist to catch. Both handlers now attribute the error before reporting (new `lib/error-origin.ts`) and report it only when it traces back to our own origin. Attribution is positive rather than a denylist of known extensions, so an unrecognised extension is filtered by default; the match is on the shared `<vendor>-extension://` suffix, so a new browser's scheme needs no code change. Unattributable errors (cross-origin `"Script error."`, rejections of non-Error values) are dropped too — there is nothing in them to debug. React render errors are unaffected: the error boundaries report `client_error` directly, and React only invokes those for errors thrown inside our own tree. (#560)
- Delete translated pages whose English source no longer exists, and stop them coming back: `translate-docs` gained a `--prune` mode that runs by default on every translation pass (`--no-prune` opts out) and as an explicit step in the `consolidate` job — that job re-checks-out `main` and *overlays* the artifacts, so a prune done only in the per-language jobs would be undone. Translation only ever moved forward, so the 11 pages the AgentEye syncs removed upstream left 154 orphans (11 × 14 locales) that `--update-nav` dropped from the sidebar but Mintlify still served and indexed — non-English readers could land on docs for a deleted feature with no way out. A repo invariant test now fails if any translation outlives its English source. (#556)
- Move the docs auto-translation daily cron from 06:00 UTC to 11:05 AM IST (05:35 UTC, encoded as `35 5 * * *` since GitHub Actions cron is always UTC). (#553)

Expand Down
92 changes: 91 additions & 1 deletion __tests__/audit/audit-cli-telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ 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 { runAuditCli } from "../../src/audit/cli";
import { runAuditCli, runPostSetupAudit } from "../../src/audit/cli";

function result(over: Partial<AuditResult>): AuditResult {
return {
Expand Down Expand Up @@ -129,3 +129,93 @@ describe("failproofai audit telemetry", () => {
expect(exitInfo?.resolvedAtExit.has("cli_audit_failed")).toBe(true);
});
});

/**
* The onboarding auto-audit — the FIRST audit any new user runs — previously
* emitted nothing at all, so first-run audits were invisible in PostHog while
* explicit `failproofai audit` runs reported normally.
*/
describe("post-setup (onboarding) audit telemetry", () => {
afterEach(() => {
delete process.env.FAILPROOFAI_NO_AUTO_AUDIT;
});

it("emits cli_audit_started then cli_audit_completed, tagged source=onboarding", async () => {
h.runAudit.mockResolvedValue(result({ eventsScanned: 100, totals: { hits: 2, projectsWithHits: 1 } }));

await runPostSetupAudit();

expect(names()).toEqual(["cli_audit_started", "cli_audit_completed"]);
expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_started", {
source: "onboarding",
});
expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_completed", {
source: "onboarding",
events_scanned: 100,
sessions_scanned: 3,
total_hits: 2,
findings: 0,
});
});

// source is what separates the onboarding run from a deliberate one in PostHog.
it("distinguishes itself from an explicit `failproofai audit` run", async () => {
h.runAudit.mockResolvedValue(result({}));

await runPostSetupAudit();
const onboarding = h.trackHookEvent.mock.calls.map((c) => (c[2] as { source: string }).source);

expect(new Set(onboarding)).toEqual(new Set(["onboarding"]));
});

it("emits cli_audit_failed when the scan throws, and still does not throw", async () => {
h.runAudit.mockRejectedValue(new TypeError("disk exploded"));

await expect(runPostSetupAudit()).resolves.toBeUndefined();

expect(names()).toEqual(["cli_audit_started", "cli_audit_failed"]);
expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_failed", {
source: "onboarding",
error_type: "TypeError",
error_message: "disk exploded",
});
});

// Mirrors runAuditCli, which reports completed regardless of what it found —
// a fresh user with no agent history is exactly who we want counted.
it("still emits cli_audit_completed when there is no history to scan", async () => {
h.runAudit.mockResolvedValue(
result({ eventsScanned: 0, transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 } }),
);

await runPostSetupAudit();

expect(names()).toEqual(["cli_audit_started", "cli_audit_completed"]);
expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_completed", {
source: "onboarding",
events_scanned: 0,
sessions_scanned: 0,
total_hits: 0,
findings: 0,
});
// Nothing found — no point warming a cache or opening a dashboard.
expect(h.writeDashboardCache).not.toHaveBeenCalled();
});

it("emits nothing when the auto-audit is opted out", async () => {
process.env.FAILPROOFAI_NO_AUTO_AUDIT = "1";

await runPostSetupAudit();

expect(names()).toEqual([]);
expect(h.runAudit).not.toHaveBeenCalled();
});

it("never exits the process — onboarding must continue to the dashboard", async () => {
h.runAudit.mockResolvedValue(result({}));

await runPostSetupAudit();

expect(exitInfo).toBeNull();
});
});
112 changes: 112 additions & 0 deletions __tests__/components/global-error-listeners.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* End-to-end wiring for the dashboard's global error listeners: real window
* events in, capture decisions out.
*
* The bug this guards: these listeners are page-global, so browser extensions
* sharing the page had their failures reported as failproofai's. A live
* MetaMask rejection reached PostHog as an unhandled_rejection.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { GlobalErrorListeners } from "@/app/components/global-error-listeners";

const h = vi.hoisted(() => ({ captureClientEvent: vi.fn() }));
vi.mock("@/lib/client-telemetry", () => ({ captureClientEvent: h.captureClientEvent }));

const APP = window.location.origin;

/** Dispatch a real unhandledrejection event with the given reason. */
function rejectWith(reason: unknown) {
const event = new Event("unhandledrejection") as Event & { reason: unknown };
event.reason = reason;
window.dispatchEvent(event);
}

/** Dispatch a real error event carrying `error` and `filename`. */
function throwWith(error: Error | undefined, filename: string) {
const event = new Event("error") as Event & {
error?: Error;
filename: string;
message: string;
lineno: number;
colno: number;
};
event.error = error;
event.filename = filename;
event.message = error?.message ?? "boom";
event.lineno = 1;
event.colno = 1;
window.dispatchEvent(event);
}

function errorFrom(origin: string, message: string): Error {
const err = new Error(message);
err.stack = `Error: ${message}\n at fn (${origin}/_next/static/chunks/main.js:1:1)`;
return err;
}

describe("GlobalErrorListeners", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("reports an unhandled rejection thrown by our own code", () => {
render(<GlobalErrorListeners />);

rejectWith(errorFrom(APP, "state is undefined"));

expect(h.captureClientEvent).toHaveBeenCalledWith(
"unhandled_rejection",
expect.objectContaining({ error_message: "state is undefined" }),
);
});

// The exact production case that prompted this filter.
it("ignores MetaMask's rejection instead of reporting it as ours", () => {
render(<GlobalErrorListeners />);

const metamask = new Error("Failed to connect to MetaMask");
metamask.name = "i";
metamask.stack =
"i: Failed to connect to MetaMask\n at chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/inpage.js:1:1";
rejectWith(metamask);

expect(h.captureClientEvent).not.toHaveBeenCalled();
});

it("ignores a rejection with no stack to attribute it by", () => {
render(<GlobalErrorListeners />);

rejectWith("just a string");

expect(h.captureClientEvent).not.toHaveBeenCalled();
});

it("reports an uncaught exception from our own script", () => {
render(<GlobalErrorListeners />);

throwWith(errorFrom(APP, "render failed"), `${APP}/_next/static/chunks/main.js`);

expect(h.captureClientEvent).toHaveBeenCalledWith(
"unhandled_exception",
expect.objectContaining({ error_message: "render failed" }),
);
});

it("ignores an uncaught exception from an extension's script", () => {
render(<GlobalErrorListeners />);

throwWith(undefined, "chrome-extension://abc/inpage.js");

expect(h.captureClientEvent).not.toHaveBeenCalled();
});

it("stops listening once unmounted", () => {
const { unmount } = render(<GlobalErrorListeners />);
unmount();

rejectWith(errorFrom(APP, "after unmount"));

expect(h.captureClientEvent).not.toHaveBeenCalled();
});
});
111 changes: 111 additions & 0 deletions __tests__/lib/error-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment node
/**
* Attribution for browser errors.
*
* The bug this guards: window's error/unhandledrejection listeners are
* page-global, so browser extensions injected into the dashboard reported their
* failures as ours. A live MetaMask rejection ("Failed to connect to MetaMask")
* arrived in PostHog as a failproofai unhandled_rejection.
*/
import { describe, it, expect } from "vitest";
import { classifyErrorOrigin, isAppError } from "../../lib/error-origin";

const APP = "http://localhost:8020";

describe("classifyErrorOrigin", () => {
describe("browser extensions", () => {
// The exact shape observed in production.
it("classifies the real MetaMask rejection as an extension error", () => {
const stack =
"i: Failed to connect to MetaMask\n" +
" at chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/inpage.js:1:12345";

expect(classifyErrorOrigin({ stack, appOrigin: APP })).toBe("extension");
});

it.each([
["chrome", "chrome-extension://abc/inpage.js:1:1"],
["firefox", "moz-extension://abc/content.js:1:1"],
["safari", "safari-web-extension://abc/injected.js:1:1"],
["edge", "ms-browser-extension://abc/script.js:1:1"],
])("recognises %s extension frames", (_browser, frame) => {
expect(classifyErrorOrigin({ stack: `Error: x\n at ${frame}`, appOrigin: APP })).toBe(
"extension",
);
});

// Matching the shared `-extension://` suffix, not a vendor list, so a new
// browser's scheme is filtered without a code change.
it("recognises an unknown vendor's extension scheme", () => {
expect(
classifyErrorOrigin({ stack: "at future-extension://abc/x.js:1:1", appOrigin: APP }),
).toBe("extension");
});

it("uses the filename when there is no stack", () => {
expect(
classifyErrorOrigin({ filename: "chrome-extension://abc/inpage.js", appOrigin: APP }),
).toBe("extension");
});

// An error routed through an extension is the extension's problem, even
// though one of our frames appears further down the stack.
it("treats a mixed stack as an extension error", () => {
const stack =
"Error: boom\n" +
" at chrome-extension://abc/inpage.js:1:1\n" +
` at fn (${APP}/_next/static/chunks/main.js:2:2)`;

expect(classifyErrorOrigin({ stack, appOrigin: APP })).toBe("extension");
});
});

describe("our own dashboard", () => {
it("classifies a stack from our origin as an app error", () => {
const stack = `TypeError: x is not a function\n at Page (${APP}/_next/static/chunks/page.js:1:1)`;

expect(classifyErrorOrigin({ stack, appOrigin: APP })).toBe("app");
});

it("classifies a filename from our origin as an app error", () => {
expect(
classifyErrorOrigin({ filename: `${APP}/_next/static/chunks/main.js`, appOrigin: APP }),
).toBe("app");
});

it("attributes correctly on a non-default origin", () => {
const origin = "http://127.0.0.1:3999";

expect(classifyErrorOrigin({ stack: `at fn (${origin}/x.js:1:1)`, appOrigin: origin })).toBe(
"app",
);
});
});

describe("unattributable", () => {
it("returns unknown when there is no location information at all", () => {
expect(classifyErrorOrigin({ appOrigin: APP })).toBe("unknown");
expect(classifyErrorOrigin({ stack: "", filename: "", appOrigin: APP })).toBe("unknown");
expect(classifyErrorOrigin({ stack: " ", appOrigin: APP })).toBe("unknown");
});

// The opaque cross-origin error — nothing in it to debug.
it("returns unknown for a bare cross-origin 'Script error.'", () => {
expect(classifyErrorOrigin({ stack: "Script error.", appOrigin: APP })).toBe("unknown");
});

it("returns unknown for a third-party host that is neither ours nor an extension", () => {
expect(
classifyErrorOrigin({ stack: "at https://cdn.example.com/x.js:1:1", appOrigin: APP }),
).toBe("unknown");
});
});
});

describe("isAppError", () => {
it("reports only positively-attributed app errors", () => {
expect(isAppError({ stack: `at fn (${APP}/x.js:1:1)`, appOrigin: APP })).toBe(true);
expect(isAppError({ stack: "at chrome-extension://abc/x.js:1:1", appOrigin: APP })).toBe(false);
expect(isAppError({ appOrigin: APP })).toBe(false);
});
});
Loading