Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ package-lock.json
packages/*/bin/
packages/*/assets/
.gstack/

# closed-source platform (cloned separately)
/platform
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Features
- Add cloud platform client: `login`, `logout`, `whoami`, `relay start|stop|status`, and `sync` subcommands. Hook events are appended to a local queue and streamed to the failproofai cloud server via a background relay daemon that lazy-starts from the hook handler and survives reboots (#132)

## 0.0.6-beta.2 — 2026-04-21

### Features
Expand Down
89 changes: 85 additions & 4 deletions bin/failproofai.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,28 @@ if (hookIdx >= 0) {
}
}

// --relay-daemon — internal: long-running background process started by
// ensureRelayRunning(). Streams queued events to the server via WebSocket.
if (args.includes("--relay-daemon")) {
try {
const { runDaemon } = await import("../src/relay/daemon");
await runDaemon();
process.exit(0);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Relay daemon error: ${msg}`);
process.exit(1);
}
}

/**
* Centralised error handler for all CLI subcommands.
* CliError → clean message, no stack trace, exit exitCode (1 or 2)
* Error → unexpected; shows message only, exits 2
*/
async function runCli() {
// --help / -h (only when not inside a subcommand that handles its own --help)
const SUBCOMMANDS = ["policies"];
const SUBCOMMANDS = ["policies", "login", "logout", "whoami", "relay", "sync"];
if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) {
const extraArgs = args.filter((a) => a !== "--help" && a !== "-h");
if (extraArgs.length > 0) {
Expand Down Expand Up @@ -94,6 +108,12 @@ COMMANDS

policies --help, -h Show this help for the policies command

login Authenticate with the failproofai cloud (Google OAuth)
logout Clear local auth tokens and stop relay daemon
whoami Print current logged-in user
relay start|stop|status Manage the event relay daemon
sync One-shot flush of pending events to the server

--version, -v Print version and exit
--help, -h Show this help message

Expand Down Expand Up @@ -288,6 +308,67 @@ EXAMPLES
process.exit(0);
}

// login — authenticate with failproofai server via Google OAuth
if (args[0] === "login") {
const { login } = await import("../src/auth/login");
await login();
process.exit(0);
}

// logout — clear local tokens and stop relay daemon
if (args[0] === "logout") {
const { logout } = await import("../src/auth/logout");
await logout();
process.exit(0);
}

// whoami — print current user and auth status
if (args[0] === "whoami") {
const { whoami } = await import("../src/auth/logout");
whoami();
process.exit(0);
}

// relay start|stop|status — manage the event relay daemon
if (args[0] === "relay") {
const subcmd = args[1];
const { relayStatus, stopRelay } = await import("../src/relay/pid");

if (subcmd === "status") {
const s = relayStatus();
if (s.running) console.log(`Relay daemon running (pid ${s.pid})`);
else if (s.pid !== null) console.log(`Stale PID file (${s.pid}); daemon not running`);
else console.log("Relay daemon not running");
process.exit(0);
}

if (subcmd === "stop") {
const stopped = stopRelay();
console.log(stopped ? "Relay daemon stopped" : "Relay daemon was not running");
process.exit(0);
}

if (subcmd === "start") {
const { ensureRelayRunning } = await import("../src/relay/daemon");
ensureRelayRunning();
const s = relayStatus();
console.log(s.running ? `Relay daemon started (pid ${s.pid})` : "Failed to start daemon");
process.exit(s.running ? 0 : 1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

throw new CliError(
`Usage: failproofai relay <start|stop|status>`
);
}

// sync — one-shot flush of pending events to server (fallback for no daemon)
if (args[0] === "sync") {
const { runOneShotSync } = await import("../src/relay/daemon");
const count = await runOneShotSync();
console.log(`Synced ${count} event${count === 1 ? "" : "s"} to server`);
process.exit(0);
}

// Unknown flag guard — must appear after all known-flag branches
const knownFlags = ["--version", "-v", "--help", "-h", "--hook"];
const unknownFlag = args.find(a => a.startsWith("-") && !knownFlags.includes(a));
Expand All @@ -306,7 +387,7 @@ EXAMPLES
return dp[m][n];
}

const primary = ["--version", "--help", "--hook", "policies"];
const primary = ["--version", "--help", "--hook", "policies", "login", "logout", "whoami", "relay", "sync"];
const closest = primary.reduce((best, flag) => {
const dist = levenshtein(unknownFlag, flag);
return dist < best.dist ? { flag, dist } : best;
Expand All @@ -319,8 +400,8 @@ EXAMPLES
);
}

// Unknown subcommand guard (non-flag args that aren't "policies")
const unknownSubcommand = args.find(a => !a.startsWith("-") && a !== "policies");
// Unknown subcommand guard (non-flag args that aren't a known subcommand)
const unknownSubcommand = args.find(a => !a.startsWith("-") && !SUBCOMMANDS.includes(a));
if (unknownSubcommand) {
throw new CliError(
`Unknown command: ${unknownSubcommand}\n` +
Expand Down
97 changes: 97 additions & 0 deletions src/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawn } from "node:child_process";
import { platform } from "node:os";
import { writeTokens, type AuthTokens } from "./token-store";

const DEFAULT_SERVER_URL = process.env.FAILPROOFAI_SERVER_URL ?? "https://api.befailproof.ai";

interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_url: string;
expires_in: number;
interval: number;
}

interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; email: string; name?: string };
}

function openBrowser(url: string): void {
const cmd =
platform() === "darwin" ? "open" :
platform() === "win32" ? "cmd" :
"xdg-open";
const args = platform() === "win32" ? ["/c", "start", url] : [url];
try {
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
} catch {
// Fallback: just print the URL
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function postJson<T>(url: string, body: unknown): Promise<T> {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`${url} → ${resp.status} ${resp.statusText}`);
}
return (await resp.json()) as T;
}

export async function login(): Promise<void> {
const serverUrl = DEFAULT_SERVER_URL;

console.log("Requesting device code...");
const dc = await postJson<DeviceCodeResponse>(`${serverUrl}/api/v1/auth/device-code`, {});

console.log(`\n Open this URL in your browser (will be opened automatically):`);
console.log(` ${dc.verification_url}\n`);
console.log(` Your code: ${dc.user_code}\n`);

openBrowser(dc.verification_url);

const deadline = Date.now() + dc.expires_in * 1000;
const intervalMs = dc.interval * 1000;

while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, intervalMs));
try {
const result = await postJson<TokenResponse | { status: string }>(
`${serverUrl}/api/v1/auth/device-token`,
{ device_code: dc.device_code },
);
if ("access_token" in result) {
const tokens: AuthTokens = {
access_token: result.access_token,
refresh_token: result.refresh_token,
expires_at: Math.floor(Date.now() / 1000) + result.expires_in,
user_email: result.user.email,
user_id: result.user.id,
server_url: serverUrl,
};
writeTokens(tokens);
console.log(`Logged in as ${result.user.email}`);

// Auto-start relay daemon
try {
const { ensureRelayRunning } = await import("../relay/daemon");
ensureRelayRunning();
console.log("Relay daemon started.");
} catch (e) {
console.warn("Failed to auto-start relay daemon:", e);
}
return;
}
} catch {
// Pending or transient error — keep polling
}
}

throw new Error("Login timed out. Run `failproofai login` again.");
}
45 changes: 45 additions & 0 deletions src/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { readTokens, clearTokens } from "./token-store";
import { stopRelay } from "../relay/pid";

export async function logout(): Promise<void> {
const tokens = readTokens();
if (!tokens) {
console.log("Not logged in.");
return;
}

try {
await fetch(`${tokens.server_url}/api/v1/auth/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refresh_token }),
});
} catch {
// Best-effort server revoke
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try {
stopRelay();
} catch {
// Best-effort daemon stop
}

clearTokens();
console.log("Logged out.");
}

export function whoami(): void {
const tokens = readTokens();
if (!tokens) {
console.log("Not logged in. Run `failproofai login` to authenticate.");
process.exit(1);
}
console.log(`Logged in as ${tokens.user_email}`);
console.log(`Server: ${tokens.server_url}`);
const expiresIn = tokens.expires_at - Math.floor(Date.now() / 1000);
if (expiresIn > 0) {
console.log(`Access token expires in ${Math.floor(expiresIn / 60)} minutes`);
} else {
console.log(`Access token expired (will refresh on next use)`);
}
}
43 changes: 43 additions & 0 deletions src/auth/token-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

export interface AuthTokens {
access_token: string;
refresh_token: string;
expires_at: number;
user_email: string;
user_id: string;
server_url: string;
}

const AUTH_DIR = join(homedir(), ".failproofai");
const AUTH_FILE = join(AUTH_DIR, "auth.json");

export function readTokens(): AuthTokens | null {
if (!existsSync(AUTH_FILE)) return null;
try {
const raw = readFileSync(AUTH_FILE, "utf8");
return JSON.parse(raw) as AuthTokens;
} catch {
return null;
}
}

export function writeTokens(tokens: AuthTokens): void {
if (!existsSync(AUTH_DIR)) mkdirSync(AUTH_DIR, { recursive: true });
writeFileSync(AUTH_FILE, JSON.stringify(tokens, null, 2));
try {
chmodSync(AUTH_FILE, 0o600);
} catch {
// Windows doesn't support chmod; ignore
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function clearTokens(): void {
if (existsSync(AUTH_FILE)) unlinkSync(AUTH_FILE);
}

export function isLoggedIn(): boolean {
return readTokens() !== null;
}
50 changes: 35 additions & 15 deletions src/hooks/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,26 +148,46 @@ export async function handleHookEvent(eventType: string): Promise<number> {
}

// Persist activity to disk (visible in /policies activity tab)
const activityEntry = {
timestamp: Date.now(),
eventType,
toolName: (parsed.tool_name as string) ?? null,
policyName: result.policyName,
policyNames: result.policyNames,
decision: result.decision,
reason: result.reason,
durationMs,
sessionId: session.sessionId,
transcriptPath: session.transcriptPath,
cwd: session.cwd,
permissionMode: session.permissionMode,
hookEventName: session.hookEventName,
};
try {
persistHookActivity({
timestamp: Date.now(),
eventType,
toolName: (parsed.tool_name as string) ?? null,
policyName: result.policyName,
policyNames: result.policyNames,
decision: result.decision,
reason: result.reason,
durationMs,
sessionId: session.sessionId,
transcriptPath: session.transcriptPath,
cwd: session.cwd,
permissionMode: session.permissionMode,
hookEventName: session.hookEventName,
});
persistHookActivity(activityEntry);
} catch {
hookLogWarn("activity persistence failed");
}

// Enqueue for server relay — fire-and-forget, never blocks hook
try {
const { appendToServerQueue } = await import("../relay/queue");
appendToServerQueue({
...activityEntry,
toolInput: parsed.tool_input as Record<string, unknown> | undefined,
});
} catch {
// Server queue is best-effort; fail-open
}

// Lazy-start relay daemon if user is logged in — ~1ms when already running
try {
const { ensureRelayRunning } = await import("../relay/daemon");
ensureRelayRunning();
} catch {
// Relay is best-effort; hook must succeed regardless
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Fire PostHog telemetry for decisions that affect Claude's behavior
if (result.decision === "deny" || result.decision === "instruct") {
try {
Expand Down
Loading