diff --git a/packages/browseros-agent/apps/claw-app/components/audit/CleanupButton.test.tsx b/packages/browseros-agent/apps/claw-app/components/audit/CleanupButton.test.tsx new file mode 100644 index 0000000000..4d20bf40e8 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/audit/CleanupButton.test.tsx @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Static-markup checks for the audit CleanupButton's visibility gate. + * Interactive two-stage dialog behaviour is covered by the pure-logic + * unit tests in `cleanup.helpers.test.ts` (phrase builder + comparator) + * plus the server-side audit-cleanup tests. This file only asserts the + * outer rule: the button hides itself when there is nothing to clean. + */ + +import { describe, expect, it, mock } from 'bun:test' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderToStaticMarkup } from 'react-dom/server' +import type { CleanupCandidatesResponse } from '@/modules/api/audit.hooks' +import * as auditHooks from '@/modules/api/audit.hooks' + +let candidatesOverride: CleanupCandidatesResponse | undefined + +// Re-export the real module surface with only the two cleanup hooks +// stubbed. Preserves useTasks, useTaskDetail, useDispatches etc. for +// any downstream consumer (Audit.test.tsx runs in the same process +// and would otherwise resolve to a mock that's missing those exports). +mock.module('@/modules/api/audit.hooks', () => ({ + ...auditHooks, + useAuditCleanupCandidates: () => ({ data: candidatesOverride }), + useAuditCleanup: () => ({ mutate: () => {}, isPending: false }), +})) + +const { CleanupButton } = await import('./CleanupButton') + +function render(): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return renderToStaticMarkup( + + + , + ) +} + +describe('CleanupButton visibility', () => { + it('renders nothing when candidates data is still loading', () => { + candidatesOverride = undefined + expect(render()).toBe('') + }) + + it('renders nothing when every range has zero sessions', () => { + candidatesOverride = { + ranges: [ + { + olderThanDays: 15, + sessionCount: 0, + dispatchCount: 0, + bytesOnDisk: 0, + }, + { + olderThanDays: 30, + sessionCount: 0, + dispatchCount: 0, + bytesOnDisk: 0, + }, + { + olderThanDays: 90, + sessionCount: 0, + dispatchCount: 0, + bytesOnDisk: 0, + }, + ], + } + expect(render()).toBe('') + }) + + it('renders the Storage button when at least one range has sessions', () => { + candidatesOverride = { + ranges: [ + { + olderThanDays: 15, + sessionCount: 3, + dispatchCount: 12, + bytesOnDisk: 1000, + }, + { + olderThanDays: 30, + sessionCount: 0, + dispatchCount: 0, + bytesOnDisk: 0, + }, + { + olderThanDays: 90, + sessionCount: 0, + dispatchCount: 0, + bytesOnDisk: 0, + }, + ], + } + const html = render() + expect(html).toContain('Storage') + }) +}) diff --git a/packages/browseros-agent/apps/claw-app/components/audit/CleanupButton.tsx b/packages/browseros-agent/apps/claw-app/components/audit/CleanupButton.tsx new file mode 100644 index 0000000000..427f6af823 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/audit/CleanupButton.tsx @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * The "Storage" button in the audit header. Only renders when the + * candidates query returns at least one non-empty range (i.e. there + * IS data older than the smallest threshold). Owns the dialog open + * state so the header component doesn't have to. + */ + +import { Trash2 } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import type { CleanupCandidateStats } from '@/modules/api/audit.hooks' +import { useAuditCleanupCandidates } from '@/modules/api/audit.hooks' +import { CleanupDialog } from './CleanupDialog' + +/** + * A range is worth showing to the user only when at least one session + * would be affected. Ranges with zero sessions are omitted entirely + * from the dialog (per Dani's spec). + */ +function nonEmptyRanges( + ranges: CleanupCandidateStats[] | undefined, +): CleanupCandidateStats[] { + return (ranges ?? []).filter((r) => r.sessionCount > 0) +} + +export function CleanupButton() { + const [open, setOpen] = useState(false) + const candidates = useAuditCleanupCandidates() + const ranges = nonEmptyRanges(candidates.data?.ranges) + + if (ranges.length === 0) return null + + return ( + <> + + + + ) +} diff --git a/packages/browseros-agent/apps/claw-app/components/audit/CleanupDialog.tsx b/packages/browseros-agent/apps/claw-app/components/audit/CleanupDialog.tsx new file mode 100644 index 0000000000..4cd2155818 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/audit/CleanupDialog.tsx @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Two-stage confirmation for the destructive /audit/cleanup call. + * + * Stage 1 Pick a range. Radios for whichever thresholds have data. + * Nothing dangerous happens here; Continue is neutral. + * + * Stage 2 Typed-confirmation gate. Read-back line + input where the + * user has to type "delete N sessions older than D days" + * verbatim before Delete unlocks. Case-sensitive exact + * match. Rebuilds when the range changes so muscle memory + * from a prior cleanup cannot unlock a new one. Same + * pattern as GitHub repo delete / Stripe account delete. + * + * Failure clears the typed input so hitting Enter without re-reading + * cannot retry silently. + */ + +import { AlertTriangle } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import type { + CleanupCandidateStats, + CleanupResult, +} from '@/modules/api/audit.hooks' +import { useAuditCleanup } from '@/modules/api/audit.hooks' +import { + buildConfirmationPhrase, + formatBytes, + matchesConfirmationPhrase, +} from './cleanup.helpers' + +interface CleanupDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Only the non-empty ranges. Empty ones are filtered by the caller. */ + ranges: CleanupCandidateStats[] + onSuccess?: (result: CleanupResult) => void +} + +type Stage = 'pick' | 'confirm' + +export function CleanupDialog({ + open, + onOpenChange, + ranges, + onSuccess, +}: CleanupDialogProps) { + const [stage, setStage] = useState('pick') + const [selectedDays, setSelectedDays] = useState( + ranges.length === 1 ? (ranges[0]?.olderThanDays ?? null) : null, + ) + const [typed, setTyped] = useState('') + const [errorText, setErrorText] = useState(null) + const cleanup = useAuditCleanup() + // Track the previous `open` value so the reset effect below fires + // ONLY on a false→true transition (the moment the user opens the + // dialog) and not on every unrelated re-render. Without this guard, + // the candidates poll (30s refetch) hands `CleanupButton` a fresh + // `ranges` array reference on every tick; that would ripple through + // the effect deps and wipe Stage 2 progress out from under a user + // who takes longer than the poll interval to read + type the + // confirmation phrase. + const prevOpenRef = useRef(open) + + useEffect(() => { + const justOpened = open && !prevOpenRef.current + prevOpenRef.current = open + if (!justOpened) return + setStage('pick') + setSelectedDays( + ranges.length === 1 ? (ranges[0]?.olderThanDays ?? null) : null, + ) + setTyped('') + setErrorText(null) + }, [open, ranges]) + + const selected = ranges.find((r) => r.olderThanDays === selectedDays) ?? null + const phrase = selected + ? buildConfirmationPhrase(selected.sessionCount, selected.olderThanDays) + : '' + const canDelete = + stage === 'confirm' && + !!selected && + !cleanup.isPending && + matchesConfirmationPhrase(typed, phrase) + + const handleContinue = () => { + if (!selected) return + setStage('confirm') + } + + const handleBack = () => { + setStage('pick') + setTyped('') + setErrorText(null) + } + + const handleDelete = () => { + if (!canDelete || !selected) return + setErrorText(null) + cleanup.mutate( + { olderThanDays: selected.olderThanDays }, + { + onSuccess: (res) => { + onSuccess?.(res) + onOpenChange(false) + }, + onError: () => { + setErrorText( + 'The cleanup failed. Read the confirmation again and try once more.', + ) + setTyped('') + }, + }, + ) + } + + return ( + + + {stage === 'pick' ? ( + <> + + Delete old audit data + + Deletes sessions, replays, and screenshots older than the + selected age. This cannot be undone. + + + setSelectedDays(Number(v))} + className="gap-2" + > + {ranges.map((r) => ( + + ))} + + + Cancel + + + + ) : ( + <> + + + + Type to confirm + + + You are about to delete{' '} + + {selected?.sessionCount}{' '} + {selected?.sessionCount === 1 ? 'session' : 'sessions'} older + than {selected?.olderThanDays} days + + . This frees up to {formatBytes(selected?.bytesOnDisk ?? 0)} and + cannot be undone. + + +
+

+ Type the phrase below exactly to enable the Delete button: +

+ + {phrase} + + setTyped(e.target.value)} + placeholder="" + className="font-mono text-xs" + data-testid="cleanup-confirm-input" + /> + {errorText && ( +

+ {errorText} +

+ )} +
+ + + + {cleanup.isPending ? 'Deleting…' : 'Delete'} + + + + )} +
+
+ ) +} diff --git a/packages/browseros-agent/apps/claw-app/components/audit/cleanup.helpers.test.ts b/packages/browseros-agent/apps/claw-app/components/audit/cleanup.helpers.test.ts new file mode 100644 index 0000000000..7b9dbf6536 --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/audit/cleanup.helpers.test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'bun:test' +import { + buildConfirmationPhrase, + formatBytes, + matchesConfirmationPhrase, +} from './cleanup.helpers' + +describe('buildConfirmationPhrase', () => { + it('produces the canonical phrase for a plural count', () => { + expect(buildConfirmationPhrase(42, 15)).toBe( + 'delete 42 sessions older than 15 days', + ) + }) + + it('produces a singular form when count is one', () => { + expect(buildConfirmationPhrase(1, 30)).toBe( + 'delete 1 session older than 30 days', + ) + }) + + it('rebuilds when the range changes so muscle memory cannot unlock', () => { + // Simulate the user picking 15-day, backing out, picking 30-day. + const a = buildConfirmationPhrase(42, 15) + const b = buildConfirmationPhrase(18, 30) + expect(a).not.toBe(b) + }) +}) + +describe('matchesConfirmationPhrase', () => { + const phrase = 'delete 42 sessions older than 15 days' + + it('accepts an exact match', () => { + expect(matchesConfirmationPhrase(phrase, phrase)).toBe(true) + }) + + it('rejects the empty string', () => { + expect(matchesConfirmationPhrase('', phrase)).toBe(false) + }) + + it('rejects a near-match with different count', () => { + expect( + matchesConfirmationPhrase( + 'delete 41 sessions older than 15 days', + phrase, + ), + ).toBe(false) + }) + + it('rejects a near-match with different threshold', () => { + expect( + matchesConfirmationPhrase( + 'delete 42 sessions older than 30 days', + phrase, + ), + ).toBe(false) + }) + + it('is case-sensitive', () => { + expect(matchesConfirmationPhrase(phrase.toUpperCase(), phrase)).toBe(false) + }) + + it('rejects missing words', () => { + expect( + matchesConfirmationPhrase('delete 42 sessions older 15 days', phrase), + ).toBe(false) + }) + + it('trims whitespace on both sides', () => { + expect(matchesConfirmationPhrase(` ${phrase}\n`, phrase)).toBe(true) + }) +}) + +describe('formatBytes', () => { + it('reports zero as "0 bytes"', () => { + expect(formatBytes(0)).toBe('0 bytes') + expect(formatBytes(-1)).toBe('0 bytes') + }) + + it('formats sub-1KB as bytes', () => { + expect(formatBytes(512)).toBe('512 bytes') + }) + + it('formats KB with one decimal', () => { + expect(formatBytes(2048)).toBe('2.0 KB') + }) + + it('formats MB with one decimal', () => { + // 3.2 MB + expect(formatBytes(3.2 * 1024 * 1024)).toBe('3.2 MB') + }) + + it('formats GB with one decimal', () => { + expect(formatBytes(2 * 1024 * 1024 * 1024)).toBe('2.0 GB') + }) +}) diff --git a/packages/browseros-agent/apps/claw-app/components/audit/cleanup.helpers.ts b/packages/browseros-agent/apps/claw-app/components/audit/cleanup.helpers.ts new file mode 100644 index 0000000000..d38fca630b --- /dev/null +++ b/packages/browseros-agent/apps/claw-app/components/audit/cleanup.helpers.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Helpers for the audit cleanup dialog. Two responsibilities: + * + * 1. Format the bytes+session sub-line the user sees on each radio. + * 2. Build the typed-confirmation phrase and check whether the user's + * input matches it. Case-sensitive, whitespace-trimmed exact match. + * Anything looser (case-insensitive, fuzzy) defeats the point of + * the gate: the phrase exists to force conscious reading, not to + * be another checkbox. + */ + +/** + * Builds the typed-confirmation phrase for a given cleanup range. The + * phrase mirrors the read-back line above the input so the user's + * fingers acknowledge exactly what will happen. It rebuilds when the + * radio selection changes, so muscle memory from a previous cleanup + * cannot unlock a new one. + */ +export function buildConfirmationPhrase( + sessionCount: number, + olderThanDays: number, +): string { + return `delete ${sessionCount} ${sessionCount === 1 ? 'session' : 'sessions'} older than ${olderThanDays} days` +} + +/** + * Exact, case-sensitive comparator with whitespace trimmed on both + * sides. Paste (Cmd+V) counts as a match because the friction we care + * about is READING the phrase, not typing each character. But everything + * about the phrase itself has to be right: character-for-character. + */ +export function matchesConfirmationPhrase( + input: string, + expected: string, +): boolean { + return input.trim() === expected.trim() +} + +/** + * Bytes -> short human string. Zero collapses to "0 bytes"; anything + * under 1024 shows as bytes; otherwise KB / MB / GB with one decimal. + * Deliberately imprecise on purpose: the value is a "up to" estimate, + * not a promise. + */ +export function formatBytes(bytes: number): string { + if (bytes <= 0) return '0 bytes' + if (bytes < 1024) return `${bytes} bytes` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb.toFixed(1)} MB` + const gb = mb / 1024 + return `${gb.toFixed(1)} GB` +} diff --git a/packages/browseros-agent/apps/claw-app/modules/api/audit.hooks.ts b/packages/browseros-agent/apps/claw-app/modules/api/audit.hooks.ts index 3d569f1bc1..3b7b0f2172 100644 --- a/packages/browseros-agent/apps/claw-app/modules/api/audit.hooks.ts +++ b/packages/browseros-agent/apps/claw-app/modules/api/audit.hooks.ts @@ -15,8 +15,13 @@ * through the rpc client (which is JSON-only). */ +import type { AuditCleanupThresholdDays } from '@browseros/shared/constants/audit' import { useEffect, useState } from 'react' -import { createInfiniteQuery, createQuery } from 'react-query-kit' +import { + createInfiniteQuery, + createMutation, + createQuery, +} from 'react-query-kit' import { api, apiBaseUrl, resolveApiBaseUrl } from './client' import { parseResponse } from './parseResponse' @@ -188,3 +193,69 @@ export function useTaskScreenshotBaseUrl(): string | null { return baseUrl } + +// ------------------------- Audit cleanup ------------------------- +// +// GET /audit/cleanup/candidates + POST /audit/cleanup. Candidates poll +// so the dialog options appear/disappear naturally as data ages past a +// threshold. The mutation invalidates every audit-facing query so the +// list, chip counts, and candidates all refresh in one refetch cycle. + +export interface CleanupCandidateStats { + olderThanDays: AuditCleanupThresholdDays + sessionCount: number + dispatchCount: number + bytesOnDisk: number +} + +export interface CleanupOrphans { + replayFilesDeleted: number + screenshotFilesDeleted: number + bytesFreed: number +} + +export interface CleanupResult { + olderThanDays: AuditCleanupThresholdDays + sessionsDeleted: number + dispatchesDeleted: number + replayFilesDeleted: number + screenshotFilesDeleted: number + bytesFreed: number + orphans: CleanupOrphans +} + +export interface CleanupCandidatesResponse { + ranges: CleanupCandidateStats[] +} + +export const useAuditCleanupCandidates = createQuery( + { + queryKey: ['audit', 'cleanup', 'candidates'], + fetcher: async () => { + const response = await api.audit.cleanup.candidates.$get() + return parseResponse(response) + }, + // 30s poll: cheap on the server (three GROUP BY + a couple of + // stat calls), keeps the Storage button visibility and the option + // list honest as sessions age past a threshold with no user + // action. + refetchInterval: 30_000, + }, +) + +export const useAuditCleanup = createMutation< + CleanupResult, + { olderThanDays: AuditCleanupThresholdDays } +>({ + mutationFn: async (vars) => { + const response = await api.audit.cleanup.$post({ json: vars }) + return parseResponse(response) + }, + onSuccess: (_result, _vars, _ctx, { client }) => { + // Sessions and their totals just changed. Invalidate every audit + // surface so the list, empty state, and the Storage button all + // refetch on the same tick. + client.invalidateQueries({ queryKey: useTasks.getKey() }) + client.invalidateQueries({ queryKey: useAuditCleanupCandidates.getKey() }) + }, +}) diff --git a/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx b/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx index 3978080ae5..f933f6a285 100644 --- a/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx +++ b/packages/browseros-agent/apps/claw-app/screens/audit/Audit.tsx @@ -10,6 +10,7 @@ import { Fragment, useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router' import { AuditEmpty } from '@/components/audit/AuditEmpty' import { AuditHoverPreview } from '@/components/audit/AuditHoverPreview' +import { CleanupButton } from '@/components/audit/CleanupButton' import { FilterBar } from '@/components/audit/FilterBar' import { Table, @@ -107,10 +108,11 @@ export function Audit() { return (
-
+

Audit

+
{!isError && (tasks.length > 0 || hasActiveFilters) && ( diff --git a/packages/browseros-agent/apps/claw-server/src/main.ts b/packages/browseros-agent/apps/claw-server/src/main.ts index 8e32d47528..0b295dab76 100644 --- a/packages/browseros-agent/apps/claw-server/src/main.ts +++ b/packages/browseros-agent/apps/claw-server/src/main.ts @@ -30,6 +30,7 @@ import { writeRuntimeFile } from './lib/runtime-file' import { setLocalServerUrl } from './local-server-url' import { createServer } from './server' import { captureEvent, shutdownAnalytics } from './services/analytics' +import { scheduleStartupOrphanSweep } from './services/audit-cleanup-startup' import { runIntegrityScan } from './services/integrity-scan' import { startScreencastPoller } from './services/screencast-poller' import { publicMcpUrl } from './shared/mcp-url' @@ -85,6 +86,12 @@ async function start(): Promise { }) } + // Self-heal loop 2: sweep orphan replay + screenshot files whose DB + // row disappeared (partial cleanup failure, crashed writer). Deferred + // 30s so it never delays the socket going live; the sweep is + // best-effort and its own errors are swallowed. + scheduleStartupOrphanSweep() + // Attach to the BrowserOS Chromium so MCP `tools/call` dispatches // hit a real browser. The bootstrap soft-fails when BrowserOS is // not reachable: the cockpit keeps serving the UI, connection diff --git a/packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts b/packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts new file mode 100644 index 0000000000..bf83ee7591 --- /dev/null +++ b/packages/browseros-agent/apps/claw-server/src/routes/audit/cleanup.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Audit cleanup routes. Two endpoints: + * GET /audit/cleanup/candidates cheap counts per threshold; UI polls + * this to know which options to offer + * POST /audit/cleanup destructive delete for one threshold + * + * The `olderThanDays` payload is restricted to the three shared + * thresholds (15/30/90) at the schema layer. Server never trusts a + * client-supplied cutoff; the UI's typed-confirmation gate is the + * final human-facing safety, not the API. + */ + +import { AUDIT_CLEANUP_THRESHOLD_DAYS } from '@browseros/shared/constants/audit' +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { cleanupOlderThan, listCandidates } from '../../services/audit-cleanup' + +// z.union of literals from the shared constant. Adding a new threshold +// to the shared array picks up here without touching this file, but the +// tuple annotation is required so TS keeps the union tight instead of +// widening to `z.ZodLiteral`. +const thresholdSchema = z.union( + AUDIT_CLEANUP_THRESHOLD_DAYS.map((d: number) => z.literal(d)) as [ + z.ZodLiteral<15>, + z.ZodLiteral<30>, + z.ZodLiteral<90>, + ], +) + +const cleanupBodySchema = z.object({ + olderThanDays: thresholdSchema, +}) + +export const auditCleanupRoute = new Hono() + .get('/audit/cleanup/candidates', (c) => c.json({ ranges: listCandidates() })) + .post('/audit/cleanup', zValidator('json', cleanupBodySchema), async (c) => { + const { olderThanDays } = c.req.valid('json') + const result = await cleanupOlderThan(olderThanDays) + return c.json(result) + }) diff --git a/packages/browseros-agent/apps/claw-server/src/server.ts b/packages/browseros-agent/apps/claw-server/src/server.ts index c39dd75a30..8f7de80caf 100644 --- a/packages/browseros-agent/apps/claw-server/src/server.ts +++ b/packages/browseros-agent/apps/claw-server/src/server.ts @@ -20,6 +20,7 @@ import { HttpError } from './lib/errors' import { logger } from './lib/logger' import { agentsControlRoute } from './routes/agents-control' import { auditRoute } from './routes/audit' +import { auditCleanupRoute } from './routes/audit/cleanup' import { auditScreenshotsRoute } from './routes/audit/screenshots' import { auditTasksRoute } from './routes/audit/tasks' import { auditReplayRoute } from './routes/audit-replay' @@ -111,6 +112,7 @@ export function createServer(options: CreateServerOptions = {}) { .route('/', connectionsRoute) .route('/', auditRoute) .route('/', auditTasksRoute) + .route('/', auditCleanupRoute) .route('/', auditScreenshotsRoute) .route('/', auditReplayRoute) .route('/', replayTabsRoute) diff --git a/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts new file mode 100644 index 0000000000..259d213476 --- /dev/null +++ b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup-startup.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Startup-side entry to the orphan sweep. Deferred so the sweep never + * delays the HTTP socket going live: main.ts calls this AFTER the + * server is listening, and the actual sweep runs after a short timer. + * Split out from audit-cleanup.ts so the startup scheduling can be + * mocked in tests without touching the sweep implementation. + */ + +import { logger } from '../lib/logger' +import { sweepOrphanFiles } from './audit-cleanup' + +/** Ms to wait after boot before running the first orphan sweep. */ +const STARTUP_SWEEP_DELAY_MS = 30_000 + +export function scheduleStartupOrphanSweep(): void { + const timer = setTimeout(() => { + void runStartupSweep() + }, STARTUP_SWEEP_DELAY_MS) + // Do not keep the process alive just for this timer; if the server + // is shutting down before the sweep fires, we skip this cycle. + timer.unref?.() +} + +/** Exported for testing. Runs the sweep immediately, no delay. */ +export async function runStartupSweep(): Promise { + try { + const result = await sweepOrphanFiles() + logger.info('orphan sweep', { + replayFilesDeleted: result.replayFilesDeleted, + screenshotFilesDeleted: result.screenshotFilesDeleted, + bytesFreed: result.bytesFreed, + source: 'startup', + }) + } catch (err) { + // Best-effort maintenance pass; never bubble up. + logger.warn('orphan sweep failed', { + error: err instanceof Error ? err.message : String(err), + }) + } +} diff --git a/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts new file mode 100644 index 0000000000..47128f9da1 --- /dev/null +++ b/packages/browseros-agent/apps/claw-server/src/services/audit-cleanup.ts @@ -0,0 +1,433 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * User-initiated cleanup of old audit data (SQLite rows + replay files + + * screenshot files). Three fixed thresholds (15/30/90 days) so the UI + * copy is stable and the SQL is trivial. "Old" is last-activity based: + * a session with any dispatch newer than the cutoff survives, even if + * it started long before. That way a resumed / long-running session is + * never nuked mid-life. + * + * SQL is transactional across all three audit tables. File deletions + * run AFTER commit and are best-effort. Ordering choice: SQL first + * means the worst-case failure is orphaned files (harmless, cleaned by + * the orphan sweep next time), not visible-but-broken sessions in the + * audit UI. + */ + +import { readdirSync, statSync } from 'node:fs' +import { unlink } from 'node:fs/promises' +import { + AUDIT_CLEANUP_THRESHOLD_DAYS, + type AuditCleanupThresholdDays, +} from '@browseros/shared/constants/audit' +import { inArray, lt, sql } from 'drizzle-orm' +import { resolveClawServerPath } from '../lib/browserclaw-dir' +import { logger } from '../lib/logger' +import { getAuditDb } from '../modules/db/db' +import { + agentSessionEnds, + agentSessionStarts, + toolDispatches, +} from '../modules/db/schema/schema' +import { + REPLAY_DIR_NAME, + REPLAY_FILE_EXTENSION, + replayStorage, +} from './replay-storage' +import { + SCREENSHOT_FILE_EXTENSION, + SCREENSHOTS_DIR_NAME, + screenshotPath, +} from './screenshots' + +const MS_PER_DAY = 86_400_000 + +/** + * Files younger than this are skipped by the orphan sweep. Protects + * fresh writes whose DB row has not flushed yet. 5 minutes is far + * longer than any realistic write-to-commit delay and short enough + * that drift stays bounded. + */ +const ORPHAN_MIN_AGE_MS = 5 * 60 * 1000 + +/** + * Safety cap on the sweep. Pathological cases (corrupt writer, + * accidental `cp` of a huge dir) would otherwise scan a million-plus + * entries and hang the request. When the cap trips we log a warning + * and return what we processed so the operator sees partial progress + * instead of a hang. + */ +const MAX_SWEEP_ENTRIES = 100_000 + +export interface CleanupCandidateStats { + olderThanDays: AuditCleanupThresholdDays + sessionCount: number + dispatchCount: number + bytesOnDisk: number +} + +export interface OrphanSweepResult { + replayFilesDeleted: number + screenshotFilesDeleted: number + bytesFreed: number +} + +export interface CleanupResult { + olderThanDays: AuditCleanupThresholdDays + sessionsDeleted: number + dispatchesDeleted: number + replayFilesDeleted: number + screenshotFilesDeleted: number + bytesFreed: number + orphans: OrphanSweepResult +} + +/** + * Returns one row per configured threshold with the counts the UI needs + * to decide which options to offer. Ranges with `sessionCount === 0` + * are still returned so the UI just filters; the endpoint's job is to + * be predictable, not to hide fields. + */ +export function listCandidates(): CleanupCandidateStats[] { + return AUDIT_CLEANUP_THRESHOLD_DAYS.map((days) => candidatesFor(days)) +} + +/** Session/dispatch/byte counts for a single threshold. */ +export function candidatesFor( + days: AuditCleanupThresholdDays, +): CleanupCandidateStats { + const cutoff = Date.now() - days * MS_PER_DAY + const sessionIds = eligibleSessionIds(cutoff) + if (sessionIds.length === 0) { + return { + olderThanDays: days, + sessionCount: 0, + dispatchCount: 0, + bytesOnDisk: 0, + } + } + const dispatchIds = dispatchIdsForSessions(sessionIds) + return { + olderThanDays: days, + sessionCount: sessionIds.length, + dispatchCount: dispatchIds.length, + bytesOnDisk: + sumReplayFileBytes(sessionIds) + sumScreenshotFileBytes(dispatchIds), + } +} + +/** + * Deletes every session whose latest dispatch is older than `days` days. + * Transactional across the three audit tables. Files unlinked + * best-effort after commit; a partial file failure leaves orphans that + * the sweep picks up next time (or on server restart). + */ +export async function cleanupOlderThan( + days: AuditCleanupThresholdDays, +): Promise { + const cutoff = Date.now() - days * MS_PER_DAY + + // Phase 1: compute the target set BEFORE mutating anything. + const sessionIds = eligibleSessionIds(cutoff) + if (sessionIds.length === 0) { + // Even with nothing age-eligible, sweep orphans. Any file that + // drifted from the DB by other paths (crashed writer, prior + // partial failure) still gets picked up here. + const orphans = await sweepOrphanFiles() + return { + olderThanDays: days, + sessionsDeleted: 0, + dispatchesDeleted: 0, + replayFilesDeleted: 0, + screenshotFilesDeleted: 0, + bytesFreed: 0, + orphans, + } + } + const dispatchIds = dispatchIdsForSessions(sessionIds) + + // Phase 2: single SQL transaction. All three tables together, or none. + const db = getAuditDb() + db.transaction((tx) => { + tx.delete(toolDispatches) + .where(inArray(toolDispatches.sessionId, sessionIds)) + .run() + tx.delete(agentSessionStarts) + .where(inArray(agentSessionStarts.sessionId, sessionIds)) + .run() + tx.delete(agentSessionEnds) + .where(inArray(agentSessionEnds.sessionId, sessionIds)) + .run() + }) + + // Phase 3: best-effort file cleanup AFTER commit. If a file unlink + // fails, we count it and move on. An orphaned file is harmless + // (nothing references it) and safer than surfacing a broken row in + // the audit UI. + const replayResult = await unlinkReplayFiles(sessionIds) + const screenshotResult = await unlinkScreenshotFiles(dispatchIds) + + // Phase 4: sweep any orphans left over from prior partial failures + // or crashed writers. Snapshot known ids AFTER our own deletes so + // rows we just removed are not treated as still-referenced. + const orphans = await sweepOrphanFiles() + + return { + olderThanDays: days, + sessionsDeleted: sessionIds.length, + dispatchesDeleted: dispatchIds.length, + replayFilesDeleted: replayResult.deleted, + screenshotFilesDeleted: screenshotResult.deleted, + bytesFreed: replayResult.bytes + screenshotResult.bytes, + orphans, + } +} + +/** + * Removes replay + screenshot files whose id no longer exists in the + * DB. Bundled into every cleanupOlderThan call and also invoked at + * server startup so partial failures don't drift over time. + * + * Safety guards: + * - Skips files younger than ORPHAN_MIN_AGE_MS (protects in-flight writes) + * - Strict filename filter: only *.ndjson (replays) or integer *.jpg (screenshots) + * - DB-first snapshot: known ids read before scanning disk to avoid TOCTOU + * - Best-effort throughout: readdir/stat/unlink failures logged, never throw + * - MAX_SWEEP_ENTRIES cap per directory + */ +export async function sweepOrphanFiles(): Promise { + const now = Date.now() + const db = getAuditDb() + + const knownSessionIds = new Set( + db + .selectDistinct({ id: toolDispatches.sessionId }) + .from(toolDispatches) + .all() + .map((r) => r.id), + ) + const knownDispatchIds = new Set( + db + .select({ id: toolDispatches.id }) + .from(toolDispatches) + .all() + .map((r) => r.id), + ) + + const replayRes = await sweepDir({ + dir: resolveClawServerPath(REPLAY_DIR_NAME), + ext: REPLAY_FILE_EXTENSION, + now, + isReferenced: (basename) => knownSessionIds.has(basename), + label: 'replays', + }) + const screenshotRes = await sweepDir({ + dir: resolveClawServerPath(SCREENSHOTS_DIR_NAME), + ext: SCREENSHOT_FILE_EXTENSION, + now, + isReferenced: (basename) => { + const asInt = Number.parseInt(basename, 10) + if (!Number.isFinite(asInt) || `${asInt}` !== basename) { + // Non-integer basename is not something the writer produces; + // treat as "referenced" so we don't touch it. Foreign files + // in the screenshots dir are left alone rather than deleted. + return true + } + return knownDispatchIds.has(asInt) + }, + label: 'screenshots', + }) + + return { + replayFilesDeleted: replayRes.deleted, + screenshotFilesDeleted: screenshotRes.deleted, + bytesFreed: replayRes.bytes + screenshotRes.bytes, + } +} + +interface SweepDirArgs { + dir: string + ext: string + now: number + isReferenced: (basename: string) => boolean + label: string +} + +async function sweepDir( + args: SweepDirArgs, +): Promise<{ deleted: number; bytes: number }> { + let entries: string[] + try { + entries = readdirSync(args.dir) + } catch (err) { + if (!isMissingFile(err)) { + logger.warn('orphan sweep readdir failed', { + dir: args.dir, + error: err instanceof Error ? err.message : String(err), + }) + } + return { deleted: 0, bytes: 0 } + } + + let deleted = 0 + let bytes = 0 + let scanned = 0 + let truncated = false + for (const entry of entries) { + scanned += 1 + if (scanned > MAX_SWEEP_ENTRIES) { + truncated = true + break + } + if (!entry.endsWith(args.ext)) continue + const basename = entry.slice(0, -args.ext.length) + if (args.isReferenced(basename)) continue + + const full = `${args.dir}/${entry}` + let size = 0 + let mtimeMs = 0 + try { + const st = statSync(full) + size = st.size + mtimeMs = st.mtimeMs + } catch { + // File vanished between readdir and stat, or unreadable. Skip. + continue + } + if (args.now - mtimeMs < ORPHAN_MIN_AGE_MS) continue // in-flight guard + + try { + await unlink(full) + deleted += 1 + bytes += size + } catch (err) { + if (isMissingFile(err)) continue + logger.warn('orphan sweep unlink failed', { + dir: args.dir, + entry, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + if (truncated) { + logger.warn('orphan sweep truncated', { + dir: args.dir, + label: args.label, + cap: MAX_SWEEP_ENTRIES, + }) + } + + return { deleted, bytes } +} + +function eligibleSessionIds(cutoff: number): string[] { + const db = getAuditDb() + const rows = db + .select({ sessionId: toolDispatches.sessionId }) + .from(toolDispatches) + .groupBy(toolDispatches.sessionId) + .having(lt(sql`max(${toolDispatches.createdAt})`, cutoff)) + .all() + return rows.map((r) => r.sessionId) +} + +function dispatchIdsForSessions(sessionIds: string[]): number[] { + if (sessionIds.length === 0) return [] + const db = getAuditDb() + const rows = db + .select({ id: toolDispatches.id }) + .from(toolDispatches) + .where(inArray(toolDispatches.sessionId, sessionIds)) + .all() + return rows.map((r) => r.id) +} + +function sumReplayFileBytes(sessionIds: string[]): number { + let total = 0 + for (const sessionId of sessionIds) { + total += safeFileSize(replayStorage.pathFor(sessionId)) + } + return total +} + +function sumScreenshotFileBytes(dispatchIds: number[]): number { + let total = 0 + for (const id of dispatchIds) { + total += safeFileSize(screenshotPath(id)) + } + return total +} + +function safeFileSize(path: string): number { + try { + return statSync(path).size + } catch { + return 0 + } +} + +async function unlinkReplayFiles( + sessionIds: string[], +): Promise<{ deleted: number; bytes: number }> { + let deleted = 0 + let bytes = 0 + for (const sessionId of sessionIds) { + const path = replayStorage.pathFor(sessionId) + const size = safeFileSize(path) + try { + await unlink(path) + deleted += 1 + bytes += size + } catch (err) { + // Missing files count as already-deleted; any other IO error is + // logged and skipped so a single stubborn file does not fail the + // whole cleanup. + if (isMissingFile(err)) { + continue + } + logger.warn('replay unlink failed during audit cleanup', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + return { deleted, bytes } +} + +async function unlinkScreenshotFiles( + dispatchIds: number[], +): Promise<{ deleted: number; bytes: number }> { + let deleted = 0 + let bytes = 0 + for (const id of dispatchIds) { + const path = screenshotPath(id) + const size = safeFileSize(path) + try { + await unlink(path) + deleted += 1 + bytes += size + } catch (err) { + if (isMissingFile(err)) { + continue + } + logger.warn('screenshot unlink failed during audit cleanup', { + dispatchId: id, + error: err instanceof Error ? err.message : String(err), + }) + } + } + return { deleted, bytes } +} + +function isMissingFile(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code: string }).code === 'ENOENT' + ) +} diff --git a/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts b/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts index b2cfebec8b..a295f6323d 100644 --- a/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts +++ b/packages/browseros-agent/apps/claw-server/src/services/replay-storage.ts @@ -26,7 +26,8 @@ import { dirname } from 'node:path' import { resolveClawServerPath } from '../lib/browserclaw-dir' import { logger } from '../lib/logger' -const REPLAY_DIR_NAME = 'replays' +export const REPLAY_DIR_NAME = 'replays' +export const REPLAY_FILE_EXTENSION = '.ndjson' const MAX_OPEN_HANDLES = 50 const IDLE_HANDLE_MS = 30_000 @@ -43,6 +44,13 @@ export interface ReplayStorage { readEvents(sessionId: string): Promise> statSession(sessionId: string): Promise deleteSession(sessionId: string): Promise + /** + * Resolves the on-disk NDJSON path for a session id (does not touch + * the file). Exposed so callers that need to size-check or sweep + * files without opening the append pipeline can do so without + * reimplementing the sanitise+join logic. + */ + pathFor(sessionId: string): string /** Test-only: forcibly close all open handles and drop the chain map. */ resetForTesting(): Promise } @@ -73,7 +81,7 @@ export function createReplayStorage( function resolvePath(sessionId: string): string { const sid = sanitiseSessionId(sessionId) const root = opts.rootDir ?? resolveClawServerPath(REPLAY_DIR_NAME) - return `${root.replace(/\/$/, '')}/${sid}.ndjson` + return `${root.replace(/\/$/, '')}/${sid}${REPLAY_FILE_EXTENSION}` } async function evictOldestIfNeeded(): Promise { @@ -225,6 +233,9 @@ export function createReplayStorage( await fh.close() } }, + pathFor(sessionId) { + return resolvePath(sessionId) + }, async deleteSession(sessionId) { await closeEntry(sessionId) const path = resolvePath(sessionId) diff --git a/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts b/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts index 3beeedc6a5..acd62c2de7 100644 --- a/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts +++ b/packages/browseros-agent/apps/claw-server/src/services/screenshots.ts @@ -46,8 +46,14 @@ import { logger } from '../lib/logger' import { screencastCache } from './screencast-cache' import { extractToolResultImageData } from './tool-result-image' +export const SCREENSHOTS_DIR_NAME = 'screenshots' +export const SCREENSHOT_FILE_EXTENSION = '.jpg' + export function screenshotPath(dispatchId: number): string { - return resolveClawServerPath('screenshots', `${dispatchId}.jpg`) + return resolveClawServerPath( + SCREENSHOTS_DIR_NAME, + `${dispatchId}${SCREENSHOT_FILE_EXTENSION}`, + ) } export interface PersistScreenshotInput { diff --git a/packages/browseros-agent/apps/claw-server/tests/routes/audit/cleanup.routes.test.ts b/packages/browseros-agent/apps/claw-server/tests/routes/audit/cleanup.routes.test.ts new file mode 100644 index 0000000000..0268f80290 --- /dev/null +++ b/packages/browseros-agent/apps/claw-server/tests/routes/audit/cleanup.routes.test.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * HTTP surface tests for /audit/cleanup*. Verifies the zod schema + * rejects out-of-set thresholds and that valid requests round-trip + * through the service layer with the expected response shape. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { eq } from 'drizzle-orm' +import { + getAuditDb, + resetAuditDbForTesting, + setAuditDbForTesting, +} from '../../../src/modules/db/db' +import { toolDispatches } from '../../../src/modules/db/schema/schema' +import app from '../../../src/server' +import { recordToolDispatch } from '../../../src/services/audit-log' +import { recordSessionStart } from '../../../src/services/session-events' +import { withTempBrowserClawDir } from '../../_helpers/temp-browserclaw-dir' + +const MS_PER_DAY = 86_400_000 + +function seed(sessionId: string): void { + recordSessionStart({ + sessionId, + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + clientName: 'claude-code', + clientVersion: '0.0.0', + }) + recordToolDispatch({ + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + sessionId, + toolName: 'act', + pageId: 1, + targetId: null, + url: null, + title: null, + rawArgs: {}, + durationMs: 5, + result: { + isError: false, + structuredContent: {}, + content: [{ type: 'text', text: 'ok' }], + }, + }) +} + +function backdate(sessionId: string, daysOld: number): void { + const target = Date.now() - daysOld * MS_PER_DAY + getAuditDb() + .update(toolDispatches) + .set({ createdAt: target }) + .where(eq(toolDispatches.sessionId, sessionId)) + .run() +} + +describe('/audit/cleanup routes', () => { + beforeEach(() => setAuditDbForTesting()) + afterEach(() => resetAuditDbForTesting()) + + it('GET /audit/cleanup/candidates returns three ranges', async () => { + await withTempBrowserClawDir(async () => { + const res = await app.request('/audit/cleanup/candidates') + expect(res.status).toBe(200) + const body = (await res.json()) as { + ranges: { olderThanDays: number; sessionCount: number }[] + } + expect(body.ranges.map((r) => r.olderThanDays)).toEqual([15, 30, 90]) + }) + }) + + it('POST /audit/cleanup deletes eligible sessions and returns counts', async () => { + await withTempBrowserClawDir(async () => { + seed('s-old') + backdate('s-old', 100) + seed('s-fresh') + backdate('s-fresh', 5) + + const res = await app.request('/audit/cleanup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ olderThanDays: 15 }), + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.olderThanDays).toBe(15) + expect(body.sessionsDeleted).toBe(1) + expect(body.dispatchesDeleted).toBe(1) + }) + }) + + it('POST /audit/cleanup rejects thresholds outside {15,30,90}', async () => { + await withTempBrowserClawDir(async () => { + const res = await app.request('/audit/cleanup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ olderThanDays: 7 }), + }) + expect(res.status).toBe(400) + }) + }) + + it('POST /audit/cleanup rejects a missing body', async () => { + await withTempBrowserClawDir(async () => { + const res = await app.request('/audit/cleanup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }) + expect(res.status).toBe(400) + }) + }) +}) diff --git a/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.orphans.test.ts b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.orphans.test.ts new file mode 100644 index 0000000000..bde87454e8 --- /dev/null +++ b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.orphans.test.ts @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Orphan sweep tests. The sweep is bundled into cleanupOlderThan() + * and also runs on a delayed startup pass. Tests seed files with + * controlled mtimes so the 5-minute in-flight guard fires + * deterministically. + */ + +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import { existsSync, mkdirSync, utimesSync, writeFileSync } from 'node:fs' +import { eq } from 'drizzle-orm' +import { resolveClawServerPath } from '../../src/lib/browserclaw-dir' +import { logger } from '../../src/lib/logger' +import { + getAuditDb, + resetAuditDbForTesting, + setAuditDbForTesting, +} from '../../src/modules/db/db' +import { toolDispatches } from '../../src/modules/db/schema/schema' +import { + cleanupOlderThan, + sweepOrphanFiles, +} from '../../src/services/audit-cleanup' +import { runStartupSweep } from '../../src/services/audit-cleanup-startup' +import { recordToolDispatch } from '../../src/services/audit-log' +import { screenshotPath } from '../../src/services/screenshots' +import { recordSessionStart } from '../../src/services/session-events' +import { withTempBrowserClawDir } from '../_helpers/temp-browserclaw-dir' + +const MS_PER_DAY = 86_400_000 +const TEN_MIN_AGO_SEC = Math.floor((Date.now() - 10 * 60 * 1000) / 1000) +const RECENT_SEC = Math.floor(Date.now() / 1000) + +function writeReplayFile(sessionId: string, mtimeSec: number): string { + const dir = resolveClawServerPath('replays') + mkdirSync(dir, { recursive: true }) + const path = `${dir}/${sessionId}.ndjson` + writeFileSync(path, `{"ts":${Date.now()},"type":4}\n`) + utimesSync(path, mtimeSec, mtimeSec) + return path +} + +function writeScreenshotFile(dispatchId: number, mtimeSec: number): string { + const path = screenshotPath(dispatchId) + mkdirSync(path.split('/').slice(0, -1).join('/'), { recursive: true }) + writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xd9])) + utimesSync(path, mtimeSec, mtimeSec) + return path +} + +function writeArbitraryFile( + dir: string, + name: string, + mtimeSec: number, +): string { + const full = resolveClawServerPath(dir, name) + mkdirSync(resolveClawServerPath(dir), { recursive: true }) + writeFileSync(full, 'garbage') + utimesSync(full, mtimeSec, mtimeSec) + return full +} + +function seedSession(sessionId: string, dispatchTool = 'act'): number | null { + recordSessionStart({ + sessionId, + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + clientName: 'claude-code', + clientVersion: '0.0.0', + }) + return recordToolDispatch({ + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + sessionId, + toolName: dispatchTool, + pageId: 1, + targetId: null, + url: null, + title: null, + rawArgs: {}, + durationMs: 5, + result: { + isError: false, + structuredContent: {}, + content: [{ type: 'text', text: 'ok' }], + }, + }) +} + +describe('audit-cleanup orphan sweep', () => { + beforeEach(() => setAuditDbForTesting()) + afterEach(() => resetAuditDbForTesting()) + + it('unlinks a replay file whose session id has no DB row', async () => { + await withTempBrowserClawDir(async () => { + const orphan = writeReplayFile('phantom-session', TEN_MIN_AGO_SEC) + expect(existsSync(orphan)).toBe(true) + + const r = await sweepOrphanFiles() + expect(r.replayFilesDeleted).toBe(1) + expect(r.bytesFreed).toBeGreaterThan(0) + expect(existsSync(orphan)).toBe(false) + }) + }) + + it('unlinks a screenshot whose dispatch id has no DB row', async () => { + await withTempBrowserClawDir(async () => { + const orphan = writeScreenshotFile(999_999, TEN_MIN_AGO_SEC) + expect(existsSync(orphan)).toBe(true) + + const r = await sweepOrphanFiles() + expect(r.screenshotFilesDeleted).toBe(1) + expect(existsSync(orphan)).toBe(false) + }) + }) + + it('leaves a fresh orphan alone (in-flight guard)', async () => { + await withTempBrowserClawDir(async () => { + const orphan = writeReplayFile('fresh-session', RECENT_SEC) + const r = await sweepOrphanFiles() + expect(r.replayFilesDeleted).toBe(0) + expect(existsSync(orphan)).toBe(true) + }) + }) + + it('keeps files whose id still has a DB row', async () => { + await withTempBrowserClawDir(async () => { + const dispatchId = seedSession('kept-session') as number + writeReplayFile('kept-session', TEN_MIN_AGO_SEC) + writeScreenshotFile(dispatchId, TEN_MIN_AGO_SEC) + + const r = await sweepOrphanFiles() + expect(r.replayFilesDeleted).toBe(0) + expect(r.screenshotFilesDeleted).toBe(0) + expect( + existsSync(`${resolveClawServerPath('replays')}/kept-session.ndjson`), + ).toBe(true) + expect(existsSync(screenshotPath(dispatchId))).toBe(true) + }) + }) + + it('ignores files with unexpected extensions', async () => { + await withTempBrowserClawDir(async () => { + const foreign = writeArbitraryFile('replays', 'note.txt', TEN_MIN_AGO_SEC) + const r = await sweepOrphanFiles() + expect(r.replayFilesDeleted).toBe(0) + expect(existsSync(foreign)).toBe(true) + }) + }) + + it('ignores screenshot files whose basename is not an integer', async () => { + await withTempBrowserClawDir(async () => { + const foreign = writeArbitraryFile( + 'screenshots', + 'junk.jpg', + TEN_MIN_AGO_SEC, + ) + const r = await sweepOrphanFiles() + expect(r.screenshotFilesDeleted).toBe(0) + expect(existsSync(foreign)).toBe(true) + }) + }) + + it('reports orphan counts as part of cleanupOlderThan result', async () => { + await withTempBrowserClawDir(async () => { + // Seed a real (fresh) session so cleanupOlderThan finds nothing to + // age-delete, and drop an orphan file into the replays dir. + seedSession('s-fresh') + writeReplayFile('drifted', TEN_MIN_AGO_SEC) + + const r = await cleanupOlderThan(15) + expect(r.sessionsDeleted).toBe(0) + expect(r.orphans.replayFilesDeleted).toBe(1) + expect(r.orphans.bytesFreed).toBeGreaterThan(0) + }) + }) + + it('handles a missing directory gracefully', async () => { + await withTempBrowserClawDir(async () => { + // No replays/ or screenshots/ directory exists yet. + const r = await sweepOrphanFiles() + expect(r.replayFilesDeleted).toBe(0) + expect(r.screenshotFilesDeleted).toBe(0) + expect(r.bytesFreed).toBe(0) + }) + }) + + it('runStartupSweep logs a result even when nothing to sweep', async () => { + await withTempBrowserClawDir(async () => { + const infoSpy = spyOn(logger, 'info') + try { + await runStartupSweep() + const call = infoSpy.mock.calls.find((c) => c[0] === 'orphan sweep') + expect(call).toBeDefined() + expect(call?.[1]).toMatchObject({ + replayFilesDeleted: 0, + screenshotFilesDeleted: 0, + bytesFreed: 0, + source: 'startup', + }) + } finally { + infoSpy.mockRestore() + } + }) + }) + + it('after an age-based cleanup deletes rows, orphan sweep skips their files (already unlinked)', async () => { + await withTempBrowserClawDir(async () => { + recordSessionStart({ + sessionId: 's-old', + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + clientName: 'claude-code', + clientVersion: '0.0.0', + }) + const dOld = recordToolDispatch({ + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + sessionId: 's-old', + toolName: 'act', + pageId: 1, + targetId: null, + url: null, + title: null, + rawArgs: {}, + durationMs: 5, + result: { + isError: false, + structuredContent: {}, + content: [{ type: 'text', text: 'ok' }], + }, + }) as number + // Backdate the session so cleanupOlderThan finds it. + getAuditDb() + .update(toolDispatches) + .set({ createdAt: Date.now() - 100 * MS_PER_DAY }) + .where(eq(toolDispatches.sessionId, 's-old')) + .run() + + // Seed the on-disk artefacts. + writeReplayFile('s-old', TEN_MIN_AGO_SEC) + writeScreenshotFile(dOld, TEN_MIN_AGO_SEC) + + const r = await cleanupOlderThan(15) + // Age-based delete removed 1 session + 1 dispatch + 2 files. + expect(r.sessionsDeleted).toBe(1) + expect(r.replayFilesDeleted).toBe(1) + expect(r.screenshotFilesDeleted).toBe(1) + // Orphan sweep in the same call finds nothing left to do. + expect(r.orphans.replayFilesDeleted).toBe(0) + expect(r.orphans.screenshotFilesDeleted).toBe(0) + }) + }) +}) diff --git a/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.test.ts b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.test.ts new file mode 100644 index 0000000000..bce0f19fe5 --- /dev/null +++ b/packages/browseros-agent/apps/claw-server/tests/services/audit-cleanup.test.ts @@ -0,0 +1,360 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Age-based cleanup tests. Seeds sessions with controlled createdAt + * values by patching the row after the dispatch writer runs (the + * writer sets createdAt to Date.now() via $defaultFn per PR #1846, so + * we UPDATE after insert to backdate). + * + * Orphan-sweep tests live in the sibling file + * `audit-cleanup.orphans.test.ts` so this file stays focused on the + * age-based path. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdirSync, statSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { eq, sql } from 'drizzle-orm' +import { + getAuditDb, + resetAuditDbForTesting, + setAuditDbForTesting, +} from '../../src/modules/db/db' +import { + agentSessionEnds, + agentSessionStarts, + toolDispatches, +} from '../../src/modules/db/schema/schema' +import { + candidatesFor, + cleanupOlderThan, + listCandidates, +} from '../../src/services/audit-cleanup' +import { recordToolDispatch } from '../../src/services/audit-log' +import { replayStorage } from '../../src/services/replay-storage' +import { screenshotPath } from '../../src/services/screenshots' +import { + recordSessionEnd, + recordSessionStart, +} from '../../src/services/session-events' +import { withTempBrowserClawDir } from '../_helpers/temp-browserclaw-dir' + +const MS_PER_DAY = 86_400_000 + +function dispatch(sessionId: string, toolName = 'act'): number | null { + return recordToolDispatch({ + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + sessionId, + toolName, + pageId: 1, + targetId: null, + url: null, + title: null, + rawArgs: {}, + durationMs: 5, + result: { + isError: false, + structuredContent: {}, + content: [{ type: 'text', text: 'ok' }], + }, + }) +} + +/** + * Backdates every dispatch AND session-start/end row for a session so + * `max(created_at)` for the session becomes exactly `daysOld` days + * ago. Cleanup groups sessions by `max(created_at)`, so we backdate to + * a single moment instead of scattering. + */ +function backdateSession(sessionId: string, daysOld: number): void { + const targetMs = Date.now() - daysOld * MS_PER_DAY + const db = getAuditDb() + db.update(toolDispatches) + .set({ createdAt: targetMs }) + .where(eq(toolDispatches.sessionId, sessionId)) + .run() + db.update(agentSessionStarts) + .set({ createdAt: targetMs }) + .where(eq(agentSessionStarts.sessionId, sessionId)) + .run() + db.update(agentSessionEnds) + .set({ createdAt: targetMs }) + .where(eq(agentSessionEnds.sessionId, sessionId)) + .run() +} + +function startSession(sessionId: string): void { + recordSessionStart({ + sessionId, + agentId: 'claude-code', + slug: 'claude-code', + agentLabel: 'Claude Code', + clientName: 'claude-code', + clientVersion: '0.0.0', + }) +} + +function seedScreenshotFile(dispatchId: number | null): void { + if (typeof dispatchId !== 'number') return + const path = screenshotPath(dispatchId) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, Buffer.from([0xff, 0xd8, 0xff, 0xd9])) +} + +async function seedReplayFile(sessionId: string): Promise { + // Uses the storage's own writer so paths/sanitisation stay in sync. + await replayStorage.appendEvents(sessionId, [ + JSON.stringify({ ts: Date.now(), type: 4 }), + ]) +} + +describe('audit-cleanup age-based', () => { + beforeEach(() => setAuditDbForTesting()) + afterEach(async () => { + resetAuditDbForTesting() + await replayStorage.resetForTesting() + }) + + it('deletes sessions strictly older than the threshold', async () => { + await withTempBrowserClawDir(async () => { + // Three sessions aged 5, 20, 100 days. + startSession('s-5') + const d5 = dispatch('s-5', 'act') + backdateSession('s-5', 5) + startSession('s-20') + const d20 = dispatch('s-20', 'act') + backdateSession('s-20', 20) + startSession('s-100') + const d100 = dispatch('s-100', 'act') + backdateSession('s-100', 100) + + seedScreenshotFile(d5) + seedScreenshotFile(d20) + seedScreenshotFile(d100) + + // cleanupOlderThan(15) removes 20d and 100d, leaves 5d. + const r15 = await cleanupOlderThan(15) + expect(r15.sessionsDeleted).toBe(2) + expect(r15.dispatchesDeleted).toBe(2) + + const survivors = getAuditDb() + .select({ id: toolDispatches.sessionId }) + .from(toolDispatches) + .groupBy(toolDispatches.sessionId) + .all() + .map((r) => r.id) + expect(survivors).toEqual(['s-5']) + }) + }) + + it('90-day threshold only nukes truly ancient sessions', async () => { + await withTempBrowserClawDir(async () => { + startSession('s-30') + dispatch('s-30', 'act') + backdateSession('s-30', 30) + startSession('s-100') + dispatch('s-100', 'act') + backdateSession('s-100', 100) + + const r90 = await cleanupOlderThan(90) + expect(r90.sessionsDeleted).toBe(1) + + const survivors = getAuditDb() + .select({ id: toolDispatches.sessionId }) + .from(toolDispatches) + .groupBy(toolDispatches.sessionId) + .all() + .map((r) => r.id) + expect(survivors).toEqual(['s-30']) + }) + }) + + it('leaves a long-lived session with a fresh straggler dispatch alone', async () => { + // The bug we're guarding against: computing "old" on session start + // rather than session last-activity would nuke this one. + await withTempBrowserClawDir(async () => { + startSession('s-long') + dispatch('s-long', 'act') + backdateSession('s-long', 100) + + // Now add a fresh dispatch (undated, i.e. Date.now()) to the + // same session. `max(created_at)` should shift to ~now. + dispatch('s-long', 'act') + + const r15 = await cleanupOlderThan(15) + expect(r15.sessionsDeleted).toBe(0) + + const remaining = getAuditDb() + .select({ count: sql`count(*)` }) + .from(toolDispatches) + .all()[0]?.count + expect(remaining).toBe(2) + }) + }) + + it('unlinks replay + screenshot files for deleted sessions and leaves survivors on disk', async () => { + await withTempBrowserClawDir(async () => { + startSession('s-fresh') + const dFresh = dispatch('s-fresh', 'act') + backdateSession('s-fresh', 5) + startSession('s-old') + const dOld = dispatch('s-old', 'act') + backdateSession('s-old', 100) + + await seedReplayFile('s-fresh') + await seedReplayFile('s-old') + seedScreenshotFile(dFresh) + seedScreenshotFile(dOld) + + const oldReplay = replayStorage.pathFor('s-old') + const freshReplay = replayStorage.pathFor('s-fresh') + const oldShot = screenshotPath(dOld as number) + const freshShot = screenshotPath(dFresh as number) + + // All four files exist pre-cleanup. + expect(statSync(oldReplay).size).toBeGreaterThan(0) + expect(statSync(freshReplay).size).toBeGreaterThan(0) + expect(statSync(oldShot).size).toBeGreaterThan(0) + expect(statSync(freshShot).size).toBeGreaterThan(0) + + const r = await cleanupOlderThan(15) + expect(r.replayFilesDeleted).toBe(1) + expect(r.screenshotFilesDeleted).toBe(1) + expect(r.bytesFreed).toBeGreaterThan(0) + + // Old files gone. + expect(() => statSync(oldReplay)).toThrow() + expect(() => statSync(oldShot)).toThrow() + // Fresh files still there. + expect(statSync(freshReplay).size).toBeGreaterThan(0) + expect(statSync(freshShot).size).toBeGreaterThan(0) + }) + }) + + it('is idempotent: a second cleanup with the same threshold is a no-op', async () => { + await withTempBrowserClawDir(async () => { + startSession('s-old') + dispatch('s-old', 'act') + backdateSession('s-old', 100) + + const first = await cleanupOlderThan(15) + expect(first.sessionsDeleted).toBe(1) + + const second = await cleanupOlderThan(15) + expect(second.sessionsDeleted).toBe(0) + expect(second.dispatchesDeleted).toBe(0) + expect(second.replayFilesDeleted).toBe(0) + expect(second.screenshotFilesDeleted).toBe(0) + }) + }) + + it('listCandidates returns three zero rows on an empty DB', async () => { + await withTempBrowserClawDir(async () => { + const ranges = listCandidates() + expect(ranges.map((r) => r.olderThanDays)).toEqual([15, 30, 90]) + for (const r of ranges) { + expect(r.sessionCount).toBe(0) + expect(r.dispatchCount).toBe(0) + expect(r.bytesOnDisk).toBe(0) + } + }) + }) + + it('candidatesFor reports counts that match cleanup outcome', async () => { + await withTempBrowserClawDir(async () => { + startSession('s-old-1') + dispatch('s-old-1', 'act') + dispatch('s-old-1', 'read') + backdateSession('s-old-1', 100) + + startSession('s-old-2') + dispatch('s-old-2', 'act') + backdateSession('s-old-2', 40) + + startSession('s-fresh') + dispatch('s-fresh', 'act') + backdateSession('s-fresh', 5) + + // 15-day threshold: covers both old-1 and old-2. + const c15 = candidatesFor(15) + expect(c15.sessionCount).toBe(2) + expect(c15.dispatchCount).toBe(3) + + // 90-day threshold: only old-1. + const c90 = candidatesFor(90) + expect(c90.sessionCount).toBe(1) + expect(c90.dispatchCount).toBe(2) + + // And now delete: the reported count matches candidates. + const r = await cleanupOlderThan(15) + expect(r.sessionsDeleted).toBe(c15.sessionCount) + expect(r.dispatchesDeleted).toBe(c15.dispatchCount) + }) + }) + + it('deletes agent_session_starts and agent_session_ends rows together with dispatches', async () => { + await withTempBrowserClawDir(async () => { + startSession('s-old') + dispatch('s-old', 'act') + recordSessionEnd({ sessionId: 's-old', kind: 'closed' }) + backdateSession('s-old', 100) + + await cleanupOlderThan(15) + + const db = getAuditDb() + expect(db.select().from(toolDispatches).all().length).toBe(0) + expect(db.select().from(agentSessionStarts).all().length).toBe(0) + expect(db.select().from(agentSessionEnds).all().length).toBe(0) + }) + }) + + it('lets a concurrent dispatch that lands during cleanup survive as a resurrected session', async () => { + // Regression coverage for the accepted concurrency behaviour: a + // dispatch that arrives after cleanupOlderThan has computed its + // target set but before it commits will end up committed AFTER the + // deletes finish (WAL serialises writers). The row belongs to a + // session that we just "deleted", so it resurrects that session in + // the audit UI, minus its old start/end rows. That's fine: the + // deriver already handles missing start rows. This test documents + // the behaviour so a future concurrency guard would surface here. + await withTempBrowserClawDir(async () => { + startSession('s-doomed') + dispatch('s-doomed', 'act') + backdateSession('s-doomed', 100) + + const cleanupPromise = cleanupOlderThan(15) + // Kick off a fresh dispatch immediately; bun:sqlite serialises + // through a single writer so this queues behind the cleanup's + // transaction and lands after commit. + const resurrectedId = dispatch('s-doomed', 'act') + const cleanupResult = await cleanupPromise + + expect(cleanupResult.sessionsDeleted).toBe(1) + + const remaining = getAuditDb() + .select({ sessionId: toolDispatches.sessionId }) + .from(toolDispatches) + .all() + .map((r) => r.sessionId) + // The doomed session comes back as a "zombie" with only the + // resurrected dispatch. + expect(remaining).toEqual(['s-doomed']) + // And it's the fresh row, not one from before cleanup. + const rows = getAuditDb() + .select({ id: toolDispatches.id }) + .from(toolDispatches) + .all() + expect(rows.map((r) => r.id)).toEqual([resurrectedId]) + + // Session start/end rows are gone (cleanup deleted them and the + // fresh dispatch does not re-emit a start event). + expect(getAuditDb().select().from(agentSessionStarts).all().length).toBe( + 0, + ) + }) + }) +}) diff --git a/packages/browseros-agent/packages/shared/package.json b/packages/browseros-agent/packages/shared/package.json index e7db968c7a..2f529397f4 100644 --- a/packages/browseros-agent/packages/shared/package.json +++ b/packages/browseros-agent/packages/shared/package.json @@ -34,6 +34,10 @@ "types": "./src/constants/paths.ts", "default": "./src/constants/paths.ts" }, + "./constants/audit": { + "types": "./src/constants/audit.ts", + "default": "./src/constants/audit.ts" + }, "./constants/hermes": { "types": "./src/constants/hermes.ts", "default": "./src/constants/hermes.ts" diff --git a/packages/browseros-agent/packages/shared/src/constants/audit.ts b/packages/browseros-agent/packages/shared/src/constants/audit.ts new file mode 100644 index 0000000000..d779e82b00 --- /dev/null +++ b/packages/browseros-agent/packages/shared/src/constants/audit.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * BrowserClaw audit-log cleanup thresholds. Shared across claw-server + * (route validation, service predicate) and claw-app (dialog options) + * so the three offered thresholds never drift. + */ + +export const AUDIT_CLEANUP_THRESHOLD_DAYS = [15, 30, 90] as const + +export type AuditCleanupThresholdDays = + (typeof AUDIT_CLEANUP_THRESHOLD_DAYS)[number]