From 01663ed50970781b510c994f30158893b929231d Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Thu, 21 May 2026 17:13:23 -0700 Subject: [PATCH 1/2] chore: remove undocumented cloud auth + event relay subsystem Strips the device-flow login, token store, event queue, WebSocket relay daemon, and the login/logout/whoami/relay/sync CLI surface so the cloud side can be redesigned from scratch. None of this was documented in the README or docs/, no tests covered it, and nothing outside the subsystem depended on it (the only cross-imports were internal). The hook handler no longer enqueues events or lazy-spawns a daemon. Co-Authored-By: Claude Opus 4.7 --- bin/failproofai.mjs | 91 +--------- src/auth/login.ts | 104 ------------ src/auth/logout.ts | 50 ------ src/auth/token-store.ts | 64 ------- src/hooks/handler.ts | 19 --- src/relay/daemon.ts | 362 ---------------------------------------- src/relay/pid.ts | 76 --------- src/relay/queue.ts | 225 ------------------------- 8 files changed, 2 insertions(+), 989 deletions(-) delete mode 100644 src/auth/login.ts delete mode 100644 src/auth/logout.ts delete mode 100644 src/auth/token-store.ts delete mode 100644 src/relay/daemon.ts delete mode 100644 src/relay/pid.ts delete mode 100644 src/relay/queue.ts diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 4b1c9a89..019a3547 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -74,20 +74,6 @@ 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) @@ -95,7 +81,7 @@ if (args.includes("--relay-daemon")) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "login", "logout", "whoami", "relay", "sync"]; + const SUBCOMMANDS = ["policies"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -132,12 +118,6 @@ 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 @@ -429,73 +409,6 @@ 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, waitForRelayAlive } = await import("../src/relay/daemon"); - ensureRelayRunning(); - // Spawn is async — give the child a moment to write its PID file - const alive = await waitForRelayAlive(); - const s = relayStatus(); - if (alive && s.running) { - console.log(`Relay daemon started (pid ${s.pid})`); - process.exit(0); - } - console.log("Failed to start daemon"); - process.exit(1); - } - - throw new CliError( - `Usage: failproofai relay ` - ); - } - - // 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)); @@ -514,7 +427,7 @@ EXAMPLES return dp[m][n]; } - const primary = ["--version", "--help", "--hook", "policies", "login", "logout", "whoami", "relay", "sync"]; + const primary = ["--version", "--help", "--hook", "policies"]; const closest = primary.reduce((best, flag) => { const dist = levenshtein(unknownFlag, flag); return dist < best.dist ? { flag, dist } : best; diff --git a/src/auth/login.ts b/src/auth/login.ts deleted file mode 100644 index da75b7b1..00000000 --- a/src/auth/login.ts +++ /dev/null @@ -1,104 +0,0 @@ -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"; -const HTTP_TIMEOUT_MS = 10_000; - -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 os = platform(); - try { - if (os === "darwin") { - spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); - } else if (os === "win32") { - // On cmd's `start`, the first quoted token is treated as a window - // title. Pass an empty title so URLs containing "&" or spaces are - // interpreted as the target, not the title. - spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); - } else { - spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); - } - } catch { - // Fallback: the URL is already printed above. - } -} - -async function postJson(url: string, body: unknown, timeoutMs = HTTP_TIMEOUT_MS): Promise { - const resp = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs), - }); - if (!resp.ok) { - throw new Error(`${url} → ${resp.status} ${resp.statusText}`); - } - return (await resp.json()) as T; -} - -export async function login(): Promise { - const serverUrl = DEFAULT_SERVER_URL; - - console.log("Requesting device code..."); - const dc = await postJson(`${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( - `${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."); -} diff --git a/src/auth/logout.ts b/src/auth/logout.ts deleted file mode 100644 index c047f438..00000000 --- a/src/auth/logout.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readTokens, clearTokens } from "./token-store"; -import { stopRelay } from "../relay/pid"; - -const LOGOUT_TIMEOUT_MS = 3_000; - -export async function logout(): Promise { - const tokens = readTokens(); - if (!tokens) { - console.log("Not logged in."); - return; - } - - // Best-effort server revoke with a short timeout — the local logout - // must not block on a slow network. - 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 }), - signal: AbortSignal.timeout(LOGOUT_TIMEOUT_MS), - }); - } catch { - // Network or timeout — proceed to local clear anyway - } - - 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)`); - } -} diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts deleted file mode 100644 index 5acce9c1..00000000 --- a/src/auth/token-store.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - readFileSync, - writeFileSync, - existsSync, - mkdirSync, - unlinkSync, - renameSync, - openSync, - closeSync, -} 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"); - -function ensureAuthDir(): void { - if (!existsSync(AUTH_DIR)) mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 }); -} - -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; - } -} - -/** - * Write tokens atomically with 0600 permissions *from creation*. - * We open with O_WRONLY|O_CREAT|O_TRUNC and explicit mode 0600 so the - * file is never world-readable, not even briefly during the write. - * Then rename into place (atomic on POSIX). - */ -export function writeTokens(tokens: AuthTokens): void { - ensureAuthDir(); - const tmpPath = `${AUTH_FILE}.tmp`; - const fd = openSync(tmpPath, "w", 0o600); - try { - writeFileSync(fd, JSON.stringify(tokens, null, 2)); - } finally { - closeSync(fd); - } - renameSync(tmpPath, AUTH_FILE); -} - -export function clearTokens(): void { - if (existsSync(AUTH_FILE)) unlinkSync(AUTH_FILE); -} - -export function isLoggedIn(): boolean { - return existsSync(AUTH_FILE); -} diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 43d43ba9..06f62a18 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -340,25 +340,6 @@ export async function handleHookEvent( hookLogWarn("activity persistence failed"); } - // Enqueue for server relay — fire-and-forget, never blocks hook. - // queue.ts is a no-op if the user is not logged in (no auth.json), and - // sanitizes the entry before persisting (drops toolInput/transcriptPath, - // hashes cwd, redacts known secret patterns in `reason`). - try { - const { appendToServerQueue } = await import("../relay/queue"); - appendToServerQueue(activityEntry); - } 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 - } - // Fire PostHog telemetry for decisions that affect Claude's behavior if (result.decision === "deny" || result.decision === "instruct") { try { diff --git a/src/relay/daemon.ts b/src/relay/daemon.ts deleted file mode 100644 index 0f7e3278..00000000 --- a/src/relay/daemon.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { randomUUID } from "node:crypto"; -import { readTokens, writeTokens, isLoggedIn } from "../auth/token-store"; -import { readPid, writePid, clearPid, isProcessAlive } from "./pid"; -import { - claimPendingBatch, - readProcessingFile, - deleteProcessingFile, - findOrphanProcessingFiles, - type QueueEntry, -} from "./queue"; - -const QUEUE_DIR = join(homedir(), ".failproofai", "cache", "server-queue"); -const BATCH_SIZE = 100; -const FLUSH_INTERVAL_MS = 2000; -const RECONNECT_BASE_MS = 1000; -const RECONNECT_MAX_MS = 60_000; -const HTTP_TIMEOUT_MS = 10_000; -const WS_CONNECT_TIMEOUT_MS = 15_000; -const ACK_TIMEOUT_MS = 30_000; - -/** - * Lazy-start check: call on every hook invocation. Near-zero cost when daemon - * is already running (~1ms PID check); spawns daemon once after reboots. - */ -export function ensureRelayRunning(): void { - if (!isLoggedIn()) return; - - const pid = readPid(); - if (pid !== null && isProcessAlive(pid)) return; - - if (pid !== null) clearPid(); - spawnDaemon(); -} - -function spawnDaemon(): void { - const entrypoint = process.env.FAILPROOFAI_RELAY_ENTRYPOINT ?? process.argv[1]; - if (!entrypoint) return; - - const child = spawn(process.execPath, [entrypoint, "--relay-daemon"], { - detached: true, - stdio: "ignore", - env: { ...process.env, FAILPROOFAI_DAEMON: "1" }, - }); - child.unref(); - - if (typeof child.pid === "number") { - writePid(child.pid); - } -} - -/** - * Block until the spawned daemon has been observed running, or until the - * timeout elapses. Used by `relay start` so we don't falsely report - * "Failed to start daemon" in the split-second window before the child - * has finished exec-ing. - */ -export async function waitForRelayAlive(timeoutMs = 2_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const pid = readPid(); - if (pid !== null && isProcessAlive(pid)) return true; - await new Promise((r) => setTimeout(r, 50)); - } - return false; -} - -async function refreshTokenIfNeeded(): Promise { - const tokens = readTokens(); - if (!tokens) return null; - - const nowSec = Math.floor(Date.now() / 1000); - if (tokens.expires_at - nowSec > 300) { - return tokens.access_token; - } - - try { - const resp = await fetch(`${tokens.server_url}/api/v1/auth/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: tokens.refresh_token }), - signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), - }); - if (!resp.ok) return tokens.access_token; - const refreshed = (await resp.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - writeTokens({ - ...tokens, - access_token: refreshed.access_token, - refresh_token: refreshed.refresh_token, - expires_at: nowSec + refreshed.expires_in, - }); - return refreshed.access_token; - } catch { - return tokens.access_token; - } -} - -type WebSocketLike = { - send(data: string): void; - close(): void; - readyState: number; - onopen: (() => void) | null; - onmessage: ((ev: { data: string }) => void) | null; - onerror: ((ev: unknown) => void) | null; - onclose: (() => void) | null; -}; - -class Relay { - private readonly ws: WebSocketLike; - private readonly pendingAcks = new Map void>(); - private closed = false; - - constructor(ws: WebSocketLike) { - this.ws = ws; - ws.onmessage = (ev) => this.handleMessage(ev.data); - ws.onclose = () => this.handleClose(); - ws.onerror = () => this.handleClose(); - } - - private handleMessage(data: string): void { - try { - const msg = JSON.parse(data) as { ack?: string; error?: string }; - if (msg.ack && this.pendingAcks.has(msg.ack)) { - const resolve = this.pendingAcks.get(msg.ack)!; - this.pendingAcks.delete(msg.ack); - resolve(true); - } - } catch { - // Ignore unparseable server messages - } - } - - private handleClose(): void { - this.closed = true; - // Reject all outstanding acks so callers can retry - for (const [, resolve] of this.pendingAcks) { - resolve(false); - } - this.pendingAcks.clear(); - } - - isClosed(): boolean { - return this.closed; - } - - close(): void { - try { - this.ws.close(); - } catch { - // ignore - } - } - - /** - * Send a batch and wait for the server's ack (keyed on batch_id). - * Returns true only when the server confirms the insert. - */ - async sendBatchAndWaitAck(events: QueueEntry[]): Promise { - if (this.closed) return false; - const batchId = randomUUID(); - - const ackPromise = new Promise((resolve) => { - this.pendingAcks.set(batchId, resolve); - setTimeout(() => { - if (this.pendingAcks.delete(batchId)) resolve(false); - }, ACK_TIMEOUT_MS); - }); - - try { - this.ws.send(JSON.stringify({ batch_id: batchId, events })); - } catch { - this.pendingAcks.delete(batchId); - return false; - } - - return ackPromise; - } -} - -async function connect(wsUrl: string, token: string): Promise { - const WSCtor: any = (globalThis as any).WebSocket; - if (!WSCtor) { - throw new Error("WebSocket not available in this Node version. Requires Node 22+."); - } - const ws: WebSocketLike = new WSCtor(wsUrl); - - await new Promise((resolve, reject) => { - let settled = false; - const timeout = setTimeout(() => { - if (settled) return; - settled = true; - try { - ws.close(); - } catch { - // ignore - } - reject(new Error("WebSocket connect timeout")); - }, WS_CONNECT_TIMEOUT_MS); - - ws.onopen = () => { - if (settled) return; - settled = true; - clearTimeout(timeout); - try { - ws.send(token); - resolve(); - } catch (e) { - reject(e); - } - }; - ws.onerror = (e) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(e); - }; - ws.onclose = () => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(new Error("WebSocket closed before opening")); - }; - }); - - return ws; -} - -/** - * Send all events from a processing file and wait for server acks on every - * batch. Returns true only when every batch was acknowledged — in that - * case the caller may delete the processing file. - */ -async function sendProcessingFile(relay: Relay, path: string): Promise { - const events = readProcessingFile(path); - if (events.length === 0) return true; - - for (let i = 0; i < events.length; i += BATCH_SIZE) { - const batch = events.slice(i, i + BATCH_SIZE); - const ok = await relay.sendBatchAndWaitAck(batch); - if (!ok) return false; - } - return true; -} - -export async function runDaemon(): Promise { - let reconnectDelay = RECONNECT_BASE_MS; - - while (true) { - const token = await refreshTokenIfNeeded(); - const tokens = readTokens(); - if (!token || !tokens) { - await new Promise((r) => setTimeout(r, 30_000)); - continue; - } - - const wsUrl = `${tokens.server_url.replace(/^http/, "ws")}/ws/events/ingest`; - - try { - const ws = await connect(wsUrl, token); - const relay = new Relay(ws); - reconnectDelay = RECONNECT_BASE_MS; - - // Drain any orphaned processing files from a prior crash first - for (const orphan of findOrphanProcessingFiles()) { - if (relay.isClosed()) break; - const ok = await sendProcessingFile(relay, orphan); - if (ok) deleteProcessingFile(orphan); - } - - while (!relay.isClosed()) { - let processingFile: string | null = null; - try { - processingFile = claimPendingBatch(); - } catch { - // Transient FS error — retry on next tick - } - - if (processingFile) { - const ok = await sendProcessingFile(relay, processingFile); - if (ok) { - deleteProcessingFile(processingFile); - } else { - // Ack failed or connection dropped — leave file for retry - break; - } - } - await new Promise((r) => setTimeout(r, FLUSH_INTERVAL_MS)); - } - - relay.close(); - } catch { - // Connection failed — wait and retry with backoff - } - - if (existsSync(QUEUE_DIR)) { - // noop; QUEUE_DIR referenced to preserve import when tree-shaking - } - - await new Promise((r) => setTimeout(r, reconnectDelay)); - reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); - } -} - -/** - * One-shot: POST all pending events to the server via REST batch endpoint. - * Used by `failproofai sync` — same rotate-then-delete pattern, but with - * HTTP response status as the ack mechanism. - */ -export async function runOneShotSync(): Promise { - const token = await refreshTokenIfNeeded(); - const tokens = readTokens(); - if (!token || !tokens) { - throw new Error("Not logged in. Run `failproofai login` first."); - } - - let total = 0; - - async function postBatch(events: QueueEntry[]): Promise { - const resp = await fetch(`${tokens!.server_url}/api/v1/events/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ events }), - signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), - }); - if (!resp.ok) { - throw new Error(`Sync failed: ${resp.status} ${resp.statusText}`); - } - } - - // Drain orphans first - for (const orphan of findOrphanProcessingFiles()) { - const events = readProcessingFile(orphan); - if (events.length > 0) { - await postBatch(events); - total += events.length; - } - deleteProcessingFile(orphan); - } - - // Drain fresh pending batch - const processingFile = claimPendingBatch(); - if (processingFile) { - const events = readProcessingFile(processingFile); - if (events.length > 0) { - await postBatch(events); - total += events.length; - } - deleteProcessingFile(processingFile); - } - - return total; -} diff --git a/src/relay/pid.ts b/src/relay/pid.ts deleted file mode 100644 index b7cdcb3c..00000000 --- a/src/relay/pid.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { homedir } from "node:os"; - -const PID_FILE = join(homedir(), ".failproofai", "relay.pid"); - -export function readPid(): number | null { - if (!existsSync(PID_FILE)) return null; - try { - const raw = readFileSync(PID_FILE, "utf8").trim(); - const pid = parseInt(raw, 10); - if (Number.isNaN(pid) || pid <= 0) return null; - return pid; - } catch { - return null; - } -} - -export function writePid(pid: number): void { - const dir = dirname(PID_FILE); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(PID_FILE, String(pid)); -} - -export function clearPid(): void { - if (existsSync(PID_FILE)) unlinkSync(PID_FILE); -} - -interface ErrnoError extends Error { - code?: string; -} - -/** - * `process.kill(pid, 0)` sends signal 0 as an existence probe. - * - * no throw → PID exists and we can signal it - * ESRCH → PID doesn't exist (process is gone) - * EPERM → PID exists but belongs to a different user — still ALIVE, - * just unsignalable by us. Treating this as "dead" would cause - * us to clear the PID file and spawn a second daemon while - * the first keeps running. - */ -export function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const e = err as ErrnoError; - // EPERM means the process exists but we can't signal it — still alive - if (e?.code === "EPERM") return true; - // ESRCH or anything else → treat as dead - return false; - } -} - -export function stopRelay(): boolean { - const pid = readPid(); - if (pid === null) return false; - if (!isProcessAlive(pid)) { - clearPid(); - return false; - } - try { - process.kill(pid, "SIGTERM"); - clearPid(); - return true; - } catch { - return false; - } -} - -export function relayStatus(): { running: boolean; pid: number | null } { - const pid = readPid(); - if (pid === null) return { running: false, pid: null }; - return { running: isProcessAlive(pid), pid }; -} diff --git a/src/relay/queue.ts b/src/relay/queue.ts deleted file mode 100644 index 854099d5..00000000 --- a/src/relay/queue.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { - appendFileSync, - mkdirSync, - existsSync, - readFileSync, - statSync, - renameSync, - unlinkSync, - readdirSync, - chmodSync, -} from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { createHash, randomUUID } from "node:crypto"; -import { isLoggedIn } from "../auth/token-store"; - -const QUEUE_DIR = join(homedir(), ".failproofai", "cache", "server-queue"); -const PENDING_FILE = join(QUEUE_DIR, "pending.jsonl"); -const PROCESSING_PREFIX = "processing-"; - -// Cap — if the queue file exceeds this, `appendToServerQueue` is a no-op. -// Prevents unbounded growth when the daemon is down for a long time or the -// user installed the CLI but never logged in. -const MAX_QUEUE_BYTES = 50 * 1024 * 1024; // 50 MB - -export interface RawEntry { - timestamp: number; - eventType: string; - toolName?: string | null; - policyName?: string | null; - policyNames?: string[]; - decision: string; - reason?: string | null; - durationMs: number; - sessionId?: string | null; - transcriptPath?: string | null; - cwd?: string | null; - permissionMode?: string | null; - hookEventName?: string | null; - toolInput?: Record; -} - -/** - * What actually gets persisted and sent to the server. Intentionally a - * narrower shape than RawEntry — we drop / hash anything that could leak - * secrets or paths: - * - toolInput: dropped entirely (can contain credentials, file contents, commands) - * - cwd: replaced with cwd_hash (SHA-256) so the server can group by project - * - transcriptPath: dropped (local-only filesystem path) - * - reason: passed through a redactor for common credential patterns - */ -export interface QueueEntry { - client_event_id: string; - timestamp: number; - event_type: string; - tool_name: string | null; - policy_name: string | null; - policy_names: string[]; - decision: string; - reason: string | null; - duration_ms: number; - session_id: string | null; - cwd_hash: string | null; - permission_mode: string | null; - hook_event_name: string | null; -} - -function hashCwd(cwd: string | null | undefined): string | null { - if (!cwd) return null; - return createHash("sha256").update(cwd).digest("hex"); -} - -function redactReason(reason: string | null | undefined): string | null { - if (!reason) return reason ?? null; - return reason - .replace(/AKIA[0-9A-Z]{16}/g, "[REDACTED-AWS-KEY]") - .replace(/eyJ[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+/g, "[REDACTED-JWT]") - .replace(/ghp_[A-Za-z0-9]{36,}/g, "[REDACTED-GH-TOKEN]") - .replace(/sk-[A-Za-z0-9]{20,}/g, "[REDACTED-API-KEY]") - .replace(/Bearer\s+[A-Za-z0-9_.=+-]+/gi, "Bearer [REDACTED]"); -} - -function sanitize(entry: RawEntry): QueueEntry { - return { - client_event_id: randomUUID(), - timestamp: entry.timestamp, - event_type: entry.eventType, - tool_name: entry.toolName ?? null, - policy_name: entry.policyName ?? null, - policy_names: entry.policyNames ?? [], - decision: entry.decision, - reason: redactReason(entry.reason), - duration_ms: entry.durationMs, - session_id: entry.sessionId ?? null, - cwd_hash: hashCwd(entry.cwd), - permission_mode: entry.permissionMode ?? null, - hook_event_name: entry.hookEventName ?? null, - }; -} - -function ensureDir(): void { - if (!existsSync(QUEUE_DIR)) { - mkdirSync(QUEUE_DIR, { recursive: true, mode: 0o700 }); - } -} - -/** - * Hook-side API — append one event to the pending queue. - * - * Uses `appendFileSync` (O_APPEND) which is atomic for small writes, so - * concurrent hook processes interleave lines correctly without clobbering - * each other. Sanitizes sensitive fields before persisting. - * - * No-op cases (keeps hook path fast and safe): - * - User not logged in (no auth.json exists) - * - Queue file already exceeds MAX_QUEUE_BYTES (prevents unbounded growth) - */ -export function appendToServerQueue(entry: RawEntry): void { - if (!isLoggedIn()) return; - ensureDir(); - - try { - if (existsSync(PENDING_FILE) && statSync(PENDING_FILE).size > MAX_QUEUE_BYTES) { - return; - } - } catch { - // existsSync/statSync races are fine; proceed - } - - const sanitized = sanitize(entry); - appendFileSync(PENDING_FILE, JSON.stringify(sanitized) + "\n", { mode: 0o600 }); - - // Tighten perms on first create in case the umask allowed wider access - try { - chmodSync(PENDING_FILE, 0o600); - } catch { - // Windows or non-critical; skip - } -} - -export function queueSizeBytes(): number { - try { - return statSync(PENDING_FILE).size; - } catch { - return 0; - } -} - -interface ClaimError extends Error { - code?: string; -} - -/** - * Daemon-side API — atomically claim all pending events into a new - * processing file. Returns the processing file path, or null ONLY when - * there's nothing to claim (ENOENT). Other errors throw so we don't - * silently strand events. - */ -export function claimPendingBatch(): string | null { - if (!existsSync(PENDING_FILE)) return null; - try { - const size = statSync(PENDING_FILE).size; - if (size === 0) return null; - } catch { - return null; - } - - const seq = `${Date.now()}-${process.pid}`; - const processingFile = join(QUEUE_DIR, `${PROCESSING_PREFIX}${seq}.jsonl`); - try { - renameSync(PENDING_FILE, processingFile); - try { - chmodSync(processingFile, 0o600); - } catch { - // non-critical - } - return processingFile; - } catch (err) { - const e = err as ClaimError; - if (e?.code === "ENOENT") return null; - // Real failure (EACCES, EIO, etc.) — surface to caller; don't lose events - throw err; - } -} - -export function findOrphanProcessingFiles(): string[] { - ensureDir(); - try { - return readdirSync(QUEUE_DIR) - .filter((n) => n.startsWith(PROCESSING_PREFIX) && n.endsWith(".jsonl")) - .map((n) => join(QUEUE_DIR, n)) - .sort(); - } catch { - return []; - } -} - -/** - * Parse a processing file into structured events, skipping (and logging - * via stderr) any malformed JSON lines so one bad entry doesn't wedge - * the entire file forever. - */ -export function readProcessingFile(path: string): QueueEntry[] { - if (!existsSync(path)) return []; - const content = readFileSync(path, "utf8"); - const out: QueueEntry[] = []; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - out.push(JSON.parse(trimmed) as QueueEntry); - } catch { - // Skip malformed line — we can't recover it - } - } - return out; -} - -export function deleteProcessingFile(path: string): void { - try { - unlinkSync(path); - } catch { - // best-effort; stale processing files are cleaned up on next run - } -} From 4421e6dc2d9e10746787ae4136ad7c3be51196fe Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Thu, 21 May 2026 17:14:06 -0700 Subject: [PATCH 2/2] docs: changelog entry for the cloud auth/relay removal Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index daccaaad..5194af33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.11-beta.2 — 2026-05-21 + +### Breaking +- Remove the undocumented cloud auth + event relay subsystem ahead of a from-scratch redesign. Deletes `src/auth/` (OAuth 2.0 device-flow login against `api.befailproof.ai`, `~/.failproofai/auth.json` token store) and `src/relay/` (WebSocket event relay daemon, sanitized JSONL queue at `~/.failproofai/cache/server-queue/`, PID tracking). Strips the `failproofai login` / `logout` / `whoami` / `relay start|stop|status` / `sync` subcommands and the internal `--relay-daemon` mode from `bin/failproofai.mjs`, along with their `--help` entries and "did you mean" suggestions. Removes the fire-and-forget `appendToServerQueue` + `ensureRelayRunning` calls from `src/hooks/handler.ts` so hook evaluation no longer enqueues events or lazy-spawns a daemon. The whole subsystem had zero references in `README.md`, `docs/`, `examples/`, or `__tests__/`, and only had internal cross-imports — `tsc`, `eslint`, `vitest` (1623 tests), and the `bun run build` bundles all stay green. Users who ran `failproofai login` should also wipe `~/.failproofai/{auth.json,cache/server-queue,relay.pid}` and stop any running relay daemon by hand; new auth/cloud surface will land in a follow-up. + ## 0.0.11-beta.1 — 2026-05-20 ### Breaking